From 6cc73b07ac244bb24bc974ef478a3d87d2877688 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Fri, 11 Sep 2026 21:18:29 +0800 Subject: [PATCH] =?UTF-8?q?=E2=91=A1=20GameCreationAppManifest=20=E5=8A=A0?= =?UTF-8?q?=20deny=5Funknown=5Ffields=EF=BC=9A=E6=9C=AA=E7=9F=A5=E9=A1=B6?= =?UTF-8?q?=E5=B1=82=E5=AD=97=E6=AE=B5=E5=A4=B1=E8=B4=A5=E5=85=B3=E9=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shared-contracts 的 GameCreationAppManifest 加 #[serde(deny_unknown_fields)],并写明取向与取舍 - 选「失败关闭」而不是「保留未知字段 round-trip」的理由:AGC 读写 manifest 是「整结构体反序列化 + 整结构体重新序列化覆盖落盘」,放行未知顶层字段就等于让「读一次 + 任意一次写」静默抹掉未来版本新增的字段;而 flatten catch-all 只能覆盖加了它的那一层,tasks / assets / versions / preview / commandRuns 内部的未来新增字段照样被抹掉,且写侧的 skip_serializing_if 会同时把已知字段归一化,落盘结果是「新字段原样 + 旧字段被规范化」的混合体,比直接报错更难排查 - 与既有取向同口径:本批新增的资源布局 sidecar 对未知 schema 就是失败关闭,UpdateLocalProjectResourceClassificationInput 也已 deny_unknown_fields - 影响面已核实:server-rs 内 game_creation_app 模块只被自身引用,不进 /api/external/v1、不进 SpacetimeDB;全仓 GameCreationAppManifest 的反序列化点只有 src-tauri 的 read_manifest 一处;仓库自带 smoke 脚本写的 manifest 只有 schemaVersion/projectId/name/assets 四个已知键 - 断言:shared-contracts 补契约级用例(未知顶层字段反序列化失败且报出字段名、已知字段含可选字段照旧往返);project/manifest/import_tests.rs 补消费侧用例(纯读报错、读+写入口 mutate_manifest_at 也报错、两次失败后磁盘文件逐字节未变即未知字段没被抹掉) --- .../src/project/manifest/import_tests.rs | 41 ++++++++++++++++++ .../shared-contracts/src/game_creation_app.rs | 43 ++++++++++++++++++- 2 files changed, 83 insertions(+), 1 deletion(-) 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",