① manifest 的 schemaVersion 补读/写失败关闭门

- 新增 validate_manifest_schema_version:只接受 GAME_CREATION_APP_MANIFEST_SCHEMA_VERSION,未知版本报「manifest schemaVersion 不受支持:{实际值}(当前支持 {当前值})」
- read_manifest 在解析后立即校验 schemaVersion,与既有的 godotProjectRoot / versions 校验同级;读到未知版本直接拒绝打开,不再被当成已知版本继续使用
- write_manifest_locked 落盘前同样校验,保证本客户端永远不会把未知 schemaVersion 写进项目(该函数是 write_manifest 与 mutate_manifest_at 共用的唯一落盘入口)
- 前向兼容取舍:刻意不做「接受未来版本 + 读时就地升级」——当前并不存在 v2 定义,凭空写一个升级只能把未知数据改写成当前版本的形状,正是本次要修掉的「静默接受」;代价是未来发 v2 时旧客户端明确报错要求升级,而不是把项目按旧结构写回
- project/manifest/import_tests.rs 补 3 条断言:当前版本必须被接受且全字段回读相等(正向断言,挡住「无条件拒绝」这种改法)、未知版本读失败且磁盘文件逐字节未变、未知版本写失败且不落盘
This commit is contained in:
2026-09-11 21:18:15 +08:00
parent 15660a98b2
commit 5eaf9faefd
2 changed files with 104 additions and 0 deletions
@@ -1608,6 +1608,8 @@ pub(crate) fn read_manifest(path: &Path) -> Result<GameCreationAppManifest, Stri
.map_err(|error| format!("读取 {label} 失败:{}: {error}", source_path.display()))?;
let manifest: GameCreationAppManifest = serde_json::from_str(&payload)
.map_err(|error| format!("解析 {label} 失败:{}: {error}", source_path.display()))?;
validate_manifest_schema_version(&manifest.schema_version)
.map_err(|error| format!("校验 {label} schema 版本失败:{error}"))?;
validate_manifest_godot_project_root(manifest.godot_project_root.as_deref())
.map_err(|error| format!("校验 {label} Godot 项目根失败:{error}"))?;
validate_game_iteration_versions(&manifest.versions)
@@ -1615,6 +1617,32 @@ pub(crate) fn read_manifest(path: &Path) -> Result<GameCreationAppManifest, Stri
Ok(manifest)
}
/// manifest 的 `schemaVersion` 门:只接受当前唯一已知版本,未知版本一律失败关闭。
///
/// 为什么必须在**读侧**校验:manifest 是项目的持久化业务真相,而 [`write_manifest_locked`] 会把
/// 整个 `GameCreationAppManifest` 重新序列化后覆盖落盘。也就是说「读一次 + 任意一次写」等价于
/// 按**本客户端已知的结构**全量重写该文件。读到未知版本还继续,旧客户端就会把新版本文件降级写回,
/// 静默丢掉新版本的字段与语义;这跟同批新增的资源布局 sidecar[`validate_resource_layout`]
/// 对未知 `schemaVersion` 直接报「不支持的资源布局 schema」)是同一条失败关闭口径。
///
/// 前向兼容取舍:这里刻意**不做**「接受未来版本 + 读时就地升级」。就地升级需要迁移函数和迁移测试,
/// 而当前并不存在 v2 的定义,凭空写一个「升级」只能把未知数据改写成当前版本的形状——那正是本次
/// 要修掉的静默接受。代价是明确的:未来发 v2 时,旧客户端会报「schemaVersion 不受支持」并拒绝打开,
/// 用户拿到的是可行动的「请升级客户端」,而不是一个已经被写坏的项目。
///
/// 与 [`validate_game_iteration_versions`] 等一样,读侧([`read_manifest`])和写侧
/// [`write_manifest_locked`])都调用本函数:读侧保证不把未知版本当成已知版本用,写侧保证本客户端
/// 永远不会把未知版本写进项目。
fn validate_manifest_schema_version(schema_version: &str) -> Result<(), String> {
let supported = shared_contracts::game_creation_app::GAME_CREATION_APP_MANIFEST_SCHEMA_VERSION;
if schema_version != supported {
return Err(format!(
"manifest schemaVersion 不受支持:{schema_version}(当前支持 {supported}"
));
}
Ok(())
}
fn install_manifest_temp_with<F>(
path: &Path,
temp_path: &Path,
@@ -1721,6 +1749,8 @@ fn write_manifest_locked(
manifest: &GameCreationAppManifest,
allowed_version_removals: &dyn Fn(&GameCreationAppManifest) -> Vec<String>,
) -> Result<(), String> {
validate_manifest_schema_version(&manifest.schema_version)
.map_err(|error| format!("校验 manifest schema 版本失败:{error}"))?;
validate_game_iteration_versions(&manifest.versions)
.map_err(|error| format!("校验 manifest 项目版本失败:{error}"))?;
let payload = serde_json::to_string_pretty(manifest)
@@ -396,3 +396,77 @@ fn rejects_non_godot_directory_without_writing_agent_metadata() {
assert!(!root.join(".agent").exists());
fs::remove_dir_all(root).ok();
}
fn write_raw_manifest_fixture(workspace: &Path, payload: &serde_json::Value) -> (PathBuf, String) {
let manifest_path = workspace.join(".agent/manifest.json");
fs::create_dir_all(manifest_path.parent().expect("manifest parent"))
.expect("create manifest parent");
let fixture = format!(
"{}\n",
serde_json::to_string_pretty(payload).expect("serialize manifest fixture")
);
fs::write(&manifest_path, &fixture).expect("write manifest fixture");
(manifest_path, fixture)
}
/// 读侧必须明确接受当前版本:只有「拒绝未知版本」的负向断言挡不住「无条件拒绝」这种改法。
#[test]
fn manifest_read_accepts_the_current_schema_version() {
let workspace = godot_import_test_path("current-manifest-schema");
let mut manifest = new_game_creation_app_manifest("schema-project", "Schema");
manifest.goal = Some("做一个像素动作原型".to_string());
manifest.godot_project_root = Some("game-source".to_string());
assert_eq!(
manifest.schema_version,
shared_contracts::game_creation_app::GAME_CREATION_APP_MANIFEST_SCHEMA_VERSION
);
let manifest_path = workspace.join(".agent/manifest.json");
write_manifest(&manifest_path, &manifest).expect("write current manifest");
let read = read_manifest(&manifest_path).expect("current schemaVersion must be accepted");
// 全字段回读相等,同时证明 `deny_unknown_fields` 没有把已知字段/可选字段一起拒掉。
assert_eq!(read, manifest);
assert_eq!(read.project_id, "schema-project");
assert_eq!(read.godot_project_root.as_deref(), Some("game-source"));
fs::remove_dir_all(workspace).ok();
}
/// 读到未知 `schemaVersion` 必须失败关闭,而且**只读失败**:不能在失败路径上顺手把
/// 「新版本文件」按本客户端的结构重写一遍。
#[test]
fn manifest_read_rejects_an_unsupported_schema_version_without_rewriting_the_file() {
let workspace = godot_import_test_path("unsupported-manifest-schema");
let mut payload =
serde_json::to_value(new_game_creation_app_manifest("schema-project", "Schema"))
.expect("serialize manifest fixture");
payload["schemaVersion"] = serde_json::json!("game-creation-app.manifest.v2");
let (manifest_path, fixture) = write_raw_manifest_fixture(&workspace, &payload);
let error = read_manifest(&manifest_path)
.expect_err("an unsupported manifest schemaVersion must fail closed");
assert!(error.contains("schemaVersion"), "unexpected error: {error}");
assert!(error.contains("game-creation-app.manifest.v2"), "{error}");
assert_eq!(
fs::read_to_string(&manifest_path).expect("re-read manifest fixture"),
fixture
);
fs::remove_dir_all(workspace).ok();
}
/// 写侧同一口径:本客户端永远不许把未知 `schemaVersion` 写进项目,且失败发生在落盘之前。
#[test]
fn manifest_write_rejects_an_unsupported_schema_version_before_touching_the_file() {
let workspace = godot_import_test_path("unsupported-manifest-write-schema");
let manifest_path = workspace.join(".agent/manifest.json");
let mut manifest = new_game_creation_app_manifest("schema-project", "Schema");
manifest.schema_version = "game-creation-app.manifest.v2".to_string();
let error = write_manifest(&manifest_path, &manifest)
.expect_err("writing an unsupported manifest schemaVersion must fail closed");
assert!(error.contains("schemaVersion"), "unexpected error: {error}");
assert!(!manifest_path.exists());
fs::remove_dir_all(workspace).ok();
}