修复 PR316 review:shared-contracts 资产级失败关闭 + 快速编辑别名白名单

- 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 的口径注释同步为「非法值这一支已不可达」(见后续提交)。
This commit is contained in:
2026-09-12 19:43:19 +08:00
parent e4c2f2466f
commit 9835bbec9f
2 changed files with 129 additions and 28 deletions
@@ -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"),
@@ -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(|| {
<D::Error as serde::de::Error>::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::<GameCreationAppAssetManifestEntry>(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::<GameCreationAppAssetManifestEntry>(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::<GameCreationAppManifest>(manifest)
.expect_err("an unknown asset field must fail the whole manifest read");
assert!(
error.to_string().contains("animations"),
"unexpected error: {error}"
);
}