② GameCreationAppManifest 加 deny_unknown_fields:未知顶层字段失败关闭

- 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 也报错、两次失败后磁盘文件逐字节未变即未知字段没被抹掉)
This commit is contained in:
2026-09-11 21:18:29 +08:00
parent 5eaf9faefd
commit 6cc73b07ac
2 changed files with 83 additions and 1 deletions
@@ -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]
@@ -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::<GameCreationAppManifest>(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",