From 9835bbec9f6b19df2098b2cee04c183fe2bf7494 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sat, 12 Sep 2026 19:43:19 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20PR316=20review=EF=BC=9Asha?= =?UTF-8?q?red-contracts=20=E8=B5=84=E4=BA=A7=E7=BA=A7=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E5=85=B3=E9=97=AD=20+=20=E5=BF=AB=E9=80=9F=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=88=AB=E5=90=8D=E7=99=BD=E5=90=8D=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server-rs/crates/shared-contracts/src/game_creation_app.rs:517 [bug high] 资产级 wire 结构补 deny_unknown_fields:与顶层 GameCreationAppManifest 同一取向,避免「读一次 + 任意一次写」静默抹掉未来新增的 assets[i] 字段;新增契约测试 asset_manifest_entry_rejects_unknown_asset_fields(裸条目 + 顶层 manifest 两条读路径都失败关闭,且已知字段照旧往返)。 - server-rs/crates/shared-contracts/src/game_creation_app.rs:536 [bug medium] 未知 category 从「退回 kind 派生值」改为失败关闭:反序列化结果就是落盘原值、要被整结构体写回,静默替换等于无迁移改落盘数据;缺字段仍按 kind 派生,派生只保留在 game_creation_app_asset_effective_category;原用例改名重写为 asset_manifest_entry_keeps_explicit_category_and_rejects_unknown_category。 - server-rs/crates/shared-contracts/src/game_creation_app.rs:780 [test low] 分区顺序跨语言交叉校验:packages/shared 侧新增解析 Rust PROJECT_RESOURCE_CANVAS_SECTIONS 并与 TS 常量对齐的测试(TS 侧补,见后续提交)。 - server-rs/crates/api-server/src/editor_project.rs:4120 [bug medium] 静态图别名白名单补 "asset"(register_local_asset_entry 空 kind 兜底值,canonical 映射到 image),数组长度 17→18,并在用例里补放行/拒绝两条断言。 - server-rs/crates/api-server/src/editor_project.rs:20747 [test low] AGC 快速编辑回归用例不再自洽:agc_payload 真正反序列化成 EditorImageEditRequest 并由请求体自己的 generationInputs.assetKind 驱动判据,另加源码级闸门断言(resolve_editor_image_edit_source / ensure_editor_image_edit_target_matches_source 体内不得再出现 generation_inputs)。 - TS 侧 gameCreationApp 的口径注释同步为「非法值这一支已不可达」(见后续提交)。 --- .../crates/api-server/src/editor_project.rs | 70 ++++++++++++--- .../shared-contracts/src/game_creation_app.rs | 87 +++++++++++++++---- 2 files changed, 129 insertions(+), 28 deletions(-) diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index e2f2c1ee3..b6b2b1477 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -4117,7 +4117,7 @@ fn align_editor_image_edit_dimension(value: u32) -> u32 { /// /// 必须与 `media_type == image` 一起构成 AND 门:视频、音频、序列帧等非静态媒体即使挂着 /// 图片类 assetKind 也照旧拒绝。 -pub(crate) const EDITOR_IMAGE_EDIT_STATIC_IMAGE_ASSET_KINDS: [&str; 17] = [ +pub(crate) const EDITOR_IMAGE_EDIT_STATIC_IMAGE_ASSET_KINDS: [&str; 18] = [ // canonical 静态图类型 "image", "scene", @@ -4138,6 +4138,11 @@ pub(crate) const EDITOR_IMAGE_EDIT_STATIC_IMAGE_ASSET_KINDS: [&str; 17] = [ "ui-prototype", // AGC 本地 manifest 等在用的等价静态图类型 "ui", + // `register_local_asset_entry`(assets.rs)空 kind 兜底写的是 `"asset"`: + // 未归类上传的 PNG 会以 `kind: "asset"` 登记,而共享契约把 `"asset"` canonical 化成 + // `"image"`,漏掉它就等于把「未归类静态图」挡在快速编辑之外(400)。 + // 白名单必须在 canonical 静态图别名下封闭。 + "asset", ]; pub(crate) fn ensure_editor_image_edit_source_kind_allowed( @@ -20738,13 +20743,20 @@ mod tests { } /// 实测 400×5 的回归用例:AGC「图片快速编辑」发出的请求,来源资源的 assetKind 是本地 - /// manifest 原始类型 `art-spritesheet` / `ui`(mediaType=image)。这条路径必须放行; - /// 非静态媒体(video/audio/image-sequence)必须继续拒绝。 + /// manifest 原始类型 `art-spritesheet` / `ui` / `asset`(mediaType=image)。这条路径必须 + /// 放行;非静态媒体(video/audio/image-sequence)必须继续拒绝。 + /// + /// 夹具必须真的走进被测路径,不能是自洽空断言:请求体先反序列化成 + /// `EditorImageEditRequest`,再从**请求体自己的** `generationInputs.assetKind` 取类型喂给 + /// 放行判据;并用源码级闸门断言钉住 + /// `resolve_editor_image_edit_source` / `ensure_editor_image_edit_target_matches_source` + /// 两个函数体内不得再出现 `generation_inputs`(历史上 `generationInputs.source` 参与过 + /// gating,把 AGC 请求整体挡成 400;解析与对账需要 DB/OSS,源码级断言是无需基础设施 + /// 也能钉住这条回归的唯一位置)。 #[test] fn game_creator_client_quick_edit_accepts_local_manifest_static_image_kinds() { - // 与客户端 `submit_resource_edit_remote` 发送的 JSON 同形;放行判据只看来源资源的 - // 权威 assetKind 与 mediaType,请求体里的 `generationInputs.source` 不参与。 - let agc_payload: Value = serde_json::from_str( + // 与客户端 `submit_resource_edit_remote` 发送的 JSON 同形。 + let agc_payload: EditorImageEditRequest = serde_json::from_str( r#"{ "prompt": "把这张图改成夜间配色", "sourceReferenceId": "resource-art-spritesheet", @@ -20754,18 +20766,55 @@ mod tests { } }"#, ) - .expect("AGC quick edit payload should be JSON"); + .expect("AGC quick edit payload should deserialize into the request DTO"); assert_eq!( - agc_payload - .pointer("/generationInputs/assetKind") - .and_then(Value::as_str), + agc_payload.source_reference_id, "resource-art-spritesheet", + "回归夹具必须保持 AGC 客户端实际发送的请求形状" + ); + assert_eq!( + agc_payload.target_layer_id, None, + "AGC 快速编辑不带 targetLayerId,走的是「主来源即目标」分支" + ); + // 关键:放行判据只吃来源资源的权威 assetKind 与 mediaType。这里用请求体自己声明的 + // assetKind 驱动判据——请求体里同时带着 `generationInputs.source`,它不得参与 gating。 + let requested_kind = agc_payload + .generation_inputs + .as_ref() + .and_then(|inputs| inputs.pointer("/assetKind")) + .and_then(Value::as_str); + assert_eq!( + requested_kind, Some("art-spritesheet"), "回归夹具必须保持 AGC 客户端实际发送的请求形状" ); + assert!( + ensure_editor_image_edit_source_kind_allowed( + normalize_editor_image_edit_resolved_source_kind(requested_kind).as_deref(), + Some("image"), + ) + .is_ok(), + "AGC 快速编辑请求不得因为 generationInputs.source 被拒" + ); + + // 源码级闸门:请求解析与目标对账都不得重新引入 generationInputs 门槛。 + let source = include_str!("editor_project.rs"); + assert_function_not_contains( + source, + "async fn resolve_editor_image_edit_source(", + "fn ensure_editor_image_edit_source_snapshot_matches(", + &["generation_inputs", "generationInputs"], + ); + assert_function_not_contains( + source, + "fn ensure_editor_image_edit_target_matches_source(", + "async fn resolve_editor_image_edit_source(", + &["generation_inputs", "generationInputs"], + ); for (asset_kind, media_type) in [ ("art-spritesheet", "image"), ("ui", "image"), + ("asset", "image"), ("ui-prototype", "image"), ("game-art", "image"), ("game-background", "image"), @@ -20781,6 +20830,7 @@ mod tests { for (asset_kind, media_type) in [ ("art-spritesheet", "video"), ("ui", "audio"), + ("asset", "video"), ("video", "video"), ("sound-effect", "audio"), ("background-music", "audio"), 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 8a8f5cea2..8eb1422fb 100644 --- a/server-rs/crates/shared-contracts/src/game_creation_app.rs +++ b/server-rs/crates/shared-contracts/src/game_creation_app.rs @@ -502,13 +502,18 @@ pub struct GameCreationAppAssetManifestEntry { } /// 历史 manifest 缺少 `category` / `tags` 时按 `kind` 派生默认值; -/// 显式写入的合法分类必须原样保留,未知分类值按前向兼容退回 `kind` 派生。 +/// 显式写入的合法分类必须原样保留,未知分类值**失败关闭**(见下方 `Deserialize` 实现)。 /// /// 这里**刻意不做读时自愈**(落盘 `unclassified` 而 kind 能派生明确分类时改用派生值): /// 反序列化结果就是落盘原值,「编辑标签」面板要靠它把落盘分类原样回写,否则用户只改标签 /// 也会静默改分类。自愈只属于读显示口径,见 `game_creation_app_asset_effective_category`。 +/// +/// `deny_unknown_fields` 与顶层 `GameCreationAppManifest` 同一取向:AGC 客户端是 +/// 「整结构体反序列化 + 整结构体重新序列化覆盖落盘」,资产级未知字段(例如未来版本的 +/// `rotation` / `animations`)一旦被静默丢弃,就会在任意一次写入里被抹掉。顶层有这道门、 +/// 资产级没有的话,等于只挡住了一半的静默数据丢失。 #[derive(Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields, rename_all = "camelCase")] struct GameCreationAppAssetManifestEntryWire { id: String, kind: String, @@ -531,11 +536,19 @@ impl<'de> Deserialize<'de> for GameCreationAppAssetManifestEntry { D: serde::Deserializer<'de>, { let wire = GameCreationAppAssetManifestEntryWire::deserialize(deserializer)?; - let category = wire - .category - .as_deref() - .and_then(game_creation_app_asset_category_from_str) - .unwrap_or_else(|| game_creation_app_asset_category_for_kind(&wire.kind)); + // 未知 `category` 必须失败关闭,不能退回 `kind` 派生值:反序列化结果就是「编辑标签」 + // 面板要原样回写的落盘原值,静默替换成派生值会让「只改标签」变成静默改分类 + // (写侧整结构体覆盖落盘,替换值会真的写回文件)。派生只属于缺字段的读兼容, + // 以及读显示口径 `game_creation_app_asset_effective_category`。 + let category = match wire.category.as_deref() { + None => game_creation_app_asset_category_for_kind(&wire.kind), + Some(raw) => game_creation_app_asset_category_from_str(raw).ok_or_else(|| { + ::custom(format!( + "未知的素材分类 category = {raw}:本客户端会整结构体重写 manifest,\ + 无法原样回写该值,因此失败关闭(请升级客户端)" + )) + })?, + }; Ok(Self { id: wire.id, kind: wire.kind, @@ -2292,7 +2305,7 @@ mod tests { } #[test] - fn asset_manifest_entry_keeps_explicit_category_and_derives_unknown_category_from_kind() { + fn asset_manifest_entry_keeps_explicit_category_and_rejects_unknown_category() { let mut explicit_unclassified = asset_entry_json("character"); explicit_unclassified["category"] = json!("unclassified"); assert_eq!( @@ -2307,18 +2320,56 @@ mod tests { GameCreationAppAssetCategory::Audio ); - let mut unknown_category = asset_entry_json("character"); - unknown_category["category"] = json!("future-category"); - assert_eq!( - asset_entry_from_json(unknown_category).category, - GameCreationAppAssetCategory::Character + // 未知分类失败关闭:不能退回 `kind` 派生值。否则「读一次 + 任意一次写」会把 + // 未来客户端写下的 `category` 静默改成本机派生值,等于无迁移地改落盘数据。 + for kind in ["character", "image"] { + let mut unknown_category = asset_entry_json(kind); + unknown_category["category"] = json!("future-category"); + let error = + serde_json::from_value::(unknown_category) + .expect_err("an unknown asset category must fail closed"); + assert!( + error.to_string().contains("future-category"), + "unexpected error for kind={kind}: {error}" + ); + } + } + + /// 资产级未知字段与顶层同一取向:必须失败关闭,而不是被 serde 静默丢弃。 + /// + /// AGC 的写路径是「整结构体反序列化 + 整结构体重新序列化覆盖落盘」,静默丢弃等于 + /// 「读一次 + 任意一次写」抹掉未来版本新增的资产字段(例如 `rotation` / `animations`)。 + #[test] + fn asset_manifest_entry_rejects_unknown_asset_fields() { + let mut payload = asset_entry_json("character"); + payload["rotation"] = json!(90); + + let error = serde_json::from_value::(payload) + .expect_err("an unknown asset-level field must fail closed"); + assert!( + error.to_string().contains("rotation"), + "unexpected error: {error}" ); - let mut unknown_category_without_kind_semantics = asset_entry_json("image"); - unknown_category_without_kind_semantics["category"] = json!("future-category"); - assert_eq!( - asset_entry_from_json(unknown_category_without_kind_semantics).category, - GameCreationAppAssetCategory::Unclassified + // 已知字段(含可选字段)必须照旧往返:拒绝未知字段不得顺手把已知字段一起拒掉。 + let entry = asset_entry_from_json(asset_entry_json("character")); + let round_tripped: GameCreationAppAssetManifestEntry = + serde_json::from_value(serde_json::to_value(&entry).expect("asset entry serializes")) + .expect("known asset fields must still deserialize"); + assert_eq!(round_tripped, entry); + + // 顶层 manifest 也走同一条资产反序列化路径,未知资产字段不得只在裸条目上失败。 + let mut manifest = + serde_json::to_value(new_game_creation_app_manifest("project-1", "像素动作原型")) + .expect("manifest should serialize"); + let mut asset = asset_entry_json("character"); + asset["animations"] = json!([{ "name": "idle" }]); + manifest["assets"] = json!([asset]); + let error = serde_json::from_value::(manifest) + .expect_err("an unknown asset field must fail the whole manifest read"); + assert!( + error.to_string().contains("animations"), + "unexpected error: {error}" ); }