diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs index 5a1ef2390..434adb727 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs @@ -432,6 +432,47 @@ fn manifest_read_accepts_the_current_schema_version() { fs::remove_dir_all(workspace).ok(); } +/// 未知顶层字段必须失败关闭,绝不能被「读一次 + 任意一次写」静默抹掉。 +/// +/// 判据分三层,缺任何一层都挡不住「静默抹掉」:① 纯读就报错,而不是读进来丢掉未知字段; +/// ② 真正的读+写入口(`mutate_manifest_at`)也不放行;③ 两次失败之后磁盘上的文件逐字节未变, +/// 未知字段仍然在文件里——也就是这次失败没有以任何形式「清理」它。 +#[test] +fn manifest_rejects_unknown_top_level_fields_without_dropping_them() { + let workspace = godot_import_test_path("unknown-manifest-field"); + let mut payload = + serde_json::to_value(new_game_creation_app_manifest("schema-project", "Schema")) + .expect("serialize manifest fixture"); + let future_value = serde_json::json!({ "addedBy": "a-newer-client" }); + payload["futureTopLevelField"] = future_value.clone(); + let (manifest_path, fixture) = write_raw_manifest_fixture(&workspace, &payload); + + let error = + read_manifest(&manifest_path).expect_err("an unknown top-level field must fail closed"); + assert!( + error.contains("futureTopLevelField"), + "unexpected error: {error}" + ); + + let mutate_error = mutate_manifest_at(&workspace, |manifest| Ok(manifest.project_id.clone())) + .expect_err("the read+write entry point must not silently drop unknown fields"); + assert!( + mutate_error.contains("futureTopLevelField"), + "unexpected error: {mutate_error}" + ); + + let persisted: serde_json::Value = serde_json::from_str( + &fs::read_to_string(&manifest_path).expect("re-read manifest fixture"), + ) + .expect("parse manifest fixture"); + assert_eq!(persisted["futureTopLevelField"], future_value); + assert_eq!( + fs::read_to_string(&manifest_path).expect("re-read manifest fixture"), + fixture + ); + fs::remove_dir_all(workspace).ok(); +} + /// 读到未知 `schemaVersion` 必须失败关闭,而且**只读失败**:不能在失败路径上顺手把 /// 「新版本文件」按本客户端的结构重写一遍。 #[test] diff --git a/server-rs/crates/shared-contracts/src/game_creation_app.rs b/server-rs/crates/shared-contracts/src/game_creation_app.rs index d210f5e44..8a8f5cea2 100644 --- a/server-rs/crates/shared-contracts/src/game_creation_app.rs +++ b/server-rs/crates/shared-contracts/src/game_creation_app.rs @@ -1013,8 +1013,23 @@ pub fn validate_game_iteration_versions(versions: &[GameIterationVersion]) -> Re Ok(()) } +/// 本地项目 `.agent/manifest.json` 的持久化结构。 +/// +/// `deny_unknown_fields` 是**前向兼容的失败关闭**,不是多余的严格性:AGC 客户端读写 manifest 的方式是 +/// 「整结构体反序列化 + 整结构体重新序列化覆盖落盘」(见 `src-tauri` 的 `write_manifest_locked`), +/// 且写回只带本结构体已知的字段。所以一旦放行未知顶层字段,「读一次 + 任意一次写」就会把未来版本 +/// 新增的顶层字段静默抹掉——这是持久化数据上的静默数据丢失,而不是一次可以忽略的宽松解析。 +/// +/// 为什么不选「保留未知字段 round-trip」:`#[serde(flatten)]` 只能覆盖加了它的那一层,`tasks` / +/// `assets` / `versions` / `preview` / `commandRuns` 内部的未来新增字段仍会被抹掉,等于给出一层 +/// 「顶层安全、嵌套照样丢」的假安全感;而且写侧本来就会用 `skip_serializing_if` 把已知字段归一化, +/// 于是落盘结果会变成「新字段原样 + 旧字段被本客户端规范化」的混合体,比直接报错更难排查。 +/// +/// 代价与配套:未知顶层字段本身就是「这个文件比本客户端新」的信号。客户端读到直接报错并拒绝改写, +/// 由调用方提示升级;同一读路径上还有 `schemaVersion` 的失败关闭门,两者互补——`schemaVersion` 管 +/// 「文件明说自己是新版本」,本属性管「版本号没升但字段已经变了」。 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct GameCreationAppManifest { pub schema_version: String, pub project_id: String, @@ -2094,6 +2109,32 @@ mod tests { assert_eq!(payload["godotProjectRoot"], json!("game-source")); } + /// 未知顶层字段必须失败关闭。这条契约级断言钉住的是本结构体本身的取向,而不是某个调用方: + /// AGC 客户端读写 manifest 的方式是「整结构体反序列化 + 整结构体重新序列化覆盖落盘」, + /// 一旦放行未知顶层字段,「读一次 + 任意一次写」就会静默抹掉未来版本新增的字段。 + #[test] + fn manifest_rejects_unknown_top_level_fields() { + let mut manifest = new_game_creation_app_manifest("project-1", "像素动作原型"); + manifest.goal = Some("做一个像素动作原型".to_string()); + + let mut payload = serde_json::to_value(&manifest).expect("manifest should serialize"); + payload["futureTopLevelField"] = json!({ "addedBy": "a-newer-client" }); + + let error = serde_json::from_value::(payload) + .expect_err("an unknown manifest top-level field must fail closed"); + assert!( + error.to_string().contains("futureTopLevelField"), + "unexpected error: {error}" + ); + + // 已知字段(含可选字段)必须照旧往返:拒绝未知字段不得顺手把已知字段一起拒掉。 + let round_tripped: GameCreationAppManifest = serde_json::from_value( + serde_json::to_value(&manifest).expect("manifest should serialize"), + ) + .expect("known manifest fields must still deserialize"); + assert_eq!(round_tripped, manifest); + } + fn asset_entry_json(kind: &str) -> serde_json::Value { json!({ "id": "asset-1",