From 1be5218aeb4a4dc6e603cc948a5181015a92c416 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Fri, 11 Sep 2026 19:44:04 +0800 Subject: [PATCH] =?UTF-8?q?kind=20=E5=88=AB=E5=90=8D=E8=A1=A8=E6=9F=A5?= =?UTF-8?q?=E8=A1=A8=E5=8A=A0=20Object.hasOwn=20=E5=AE=88=E5=8D=AB?= =?UTF-8?q?=EF=BC=9A=E5=8E=9F=E5=9E=8B=E9=94=AE=E4=B8=8D=E5=86=8D=E8=A2=AB?= =?UTF-8?q?=E8=AF=AF=E5=BD=93=E6=88=90=E5=88=AB=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `canonicalGameCreationAppAssetKind`(TS)此前用对象字面量直接下标查别名表:`kind` 是外部输入,`constructor` / `__proto__` / `toString` / `valueOf` / `hasOwnProperty` 会命中 `Object.prototype` 上的成员,被当成 canonical kind 返回(`__proto__` 返回原型对象本身);Rust 侧是 `match` 字面量,只会落 `image` 兜底,同一输入两侧分叉 - 改用 `Object.hasOwn(GAME_CREATION_APP_LEGACY_ASSET_KINDS, normalized)` 先确认命中的是表自己的键,再取值;`noUncheckedIndexedAccess` 下仍保留一次取值后的 truthy 判定 - 补断言(无断言的边界修法下次会被改回去):`gameCreationApp.test.ts` 对 `constructor` / `__proto__` / `toString` / `valueOf` / `hasOwnProperty` 逐条断言 `canonicalGameCreationAppAssetKind` 落 `image`、`gameCreationAppAssetCategoryForKind` 落 `unclassified` - 跨语言对照:`assetKindCanonicalMapping.test.ts` 的 `decided` 表新增 `constructor` 与 `__proto__` 两行(`image` / `unclassified`),与 TS 同一份口径对照 - Rust 侧同步断言:`game_creation_app.rs` 的 `asset_category_mapping_covers_every_canonical_kind` 补 `constructor` / `__proto__` / `toString` / `valueOf` 必须落 `image` 与 `unclassified`,把「两侧一致」这件事写进两侧各自的用例 - 边界范围:只收口「原型成员被当成别名」这一种极端输入;`kind` 的正常词表、大小写口径与读时自愈规则不变,manifest 字段构成与顺序不变 --- .../tests/assetKindCanonicalMapping.test.ts | 4 ++++ .../src/contracts/gameCreationApp.test.ts | 19 +++++++++++++++++++ .../shared/src/contracts/gameCreationApp.ts | 12 ++++++++++-- .../shared-contracts/src/game_creation_app.rs | 14 ++++++++++++++ 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts b/apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts index 8ceb8ccc3..40308e594 100644 --- a/apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts +++ b/apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts @@ -222,6 +222,10 @@ describe('真机出现的 kind 归类口径', () => { // 现役写入侧的大写字面量与字体 kind,两者都必须落进明确栏目。 { kind: 'UI', canonical: 'ui-design', category: 'ui-interaction' }, { kind: 'font', canonical: 'document', category: 'document' }, + // `Object.prototype` 上的键不是别名:TS 侧必须用 `Object.hasOwn` 挡住对象字面量的 + // 原型命中,Rust 侧 match 字面量本来就落 `image`;这类极端输入两侧也必须一致。 + { kind: 'constructor', canonical: 'image', category: 'unclassified' }, + { kind: '__proto__', canonical: 'image', category: 'unclassified' }, ]; test.each(decided)( diff --git a/packages/shared/src/contracts/gameCreationApp.test.ts b/packages/shared/src/contracts/gameCreationApp.test.ts index b6ab26444..b187171a7 100644 --- a/packages/shared/src/contracts/gameCreationApp.test.ts +++ b/packages/shared/src/contracts/gameCreationApp.test.ts @@ -753,6 +753,25 @@ describe('AI 游戏创作 App 共享契约', () => { // 字体资产的现役写入侧写的是 `font`。 expect(canonicalGameCreationAppAssetKind('font')).toBe('document'); expect(canonicalGameCreationAppAssetKind('FONT')).toBe('document'); + /** + * `Object.prototype` 上的键不是别名:别名表是对象字面量,直接下标取值会拿到原型上的 + * 函数/对象(truthy)并被当成 canonical kind 返回;Rust 侧是 `match` 字面量,只会落 + * `image`。必须用 `Object.hasOwn` 挡掉,两侧对这类输入的归类才一致 + * (`image → unclassified`)。跨语言对照另见 `assetKindCanonicalMapping.test.ts` 的 + * `decided` 表。 + */ + for (const prototypeKey of [ + 'constructor', + '__proto__', + 'toString', + 'valueOf', + 'hasOwnProperty', + ]) { + expect(canonicalGameCreationAppAssetKind(prototypeKey)).toBe('image'); + expect(gameCreationAppAssetCategoryForKind(prototypeKey)).toBe( + 'unclassified', + ); + } expect(canonicalGameCreationAppAssetKind('unknown-kind')).toBe('image'); }); diff --git a/packages/shared/src/contracts/gameCreationApp.ts b/packages/shared/src/contracts/gameCreationApp.ts index 70e82533b..3aee24381 100644 --- a/packages/shared/src/contracts/gameCreationApp.ts +++ b/packages/shared/src/contracts/gameCreationApp.ts @@ -547,8 +547,16 @@ export function canonicalGameCreationAppAssetKind( * 且派生值本身就是 `unclassified`,读时自愈也救不回来。 */ const normalized = value.trim().toLowerCase(); - const legacyKind = GAME_CREATION_APP_LEGACY_ASSET_KINDS[normalized]; - if (legacyKind) return legacyKind; + /** + * `kind` 是外部输入,可能正好是 `Object.prototype` 上的键(`constructor` / `__proto__` / + * `toString` / `valueOf` …)。别名表是对象字面量,直接下标取值会拿到原型上的函数/对象 + * (truthy),被当成 canonical kind 返回;Rust 侧是 `match` 字面量,只会落 `image`。 + * 必须先确认命中的是**表自己的键**,两侧对这类输入的归类才一致(都落 `image → unclassified`)。 + */ + if (Object.hasOwn(GAME_CREATION_APP_LEGACY_ASSET_KINDS, normalized)) { + const legacyKind = GAME_CREATION_APP_LEGACY_ASSET_KINDS[normalized]; + if (legacyKind) return legacyKind; + } if ( (GAME_CREATION_APP_CANONICAL_ASSET_KINDS as readonly string[]).includes( normalized, 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 073eae8d9..d210f5e44 100644 --- a/server-rs/crates/shared-contracts/src/game_creation_app.rs +++ b/server-rs/crates/shared-contracts/src/game_creation_app.rs @@ -2177,6 +2177,20 @@ mod tests { game_creation_app_asset_category_for_kind("font"), GameCreationAppAssetCategory::Document ); + // `Object.prototype` 上的键不是别名:Rust 侧是 `match` 字面量,本来就落 `image`; + // TS 侧别名表是对象字面量,必须用 `Object.hasOwn` 挡住原型命中,两侧才一致。 + // 这两条与 TS `gameCreationApp.test.ts` / `assetKindCanonicalMapping.test.ts` 成对。 + for prototype_key in ["constructor", "__proto__", "toString", "valueOf"] { + assert_eq!( + canonical_game_creation_app_asset_kind(prototype_key), + "image", + "{prototype_key} 必须落 image 兜底" + ); + assert_eq!( + game_creation_app_asset_category_for_kind(prototype_key), + GameCreationAppAssetCategory::Unclassified + ); + } assert_eq!( game_creation_app_asset_category_for_kind("unknown-kind"), GameCreationAppAssetCategory::Unclassified