修复 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:
@@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user