kind 别名表查表加 Object.hasOwn 守卫:原型键不再被误当成别名

- `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 字段构成与顺序不变
This commit is contained in:
2026-09-11 19:44:04 +08:00
parent eb6f72ab9d
commit 1be5218aeb
4 changed files with 47 additions and 2 deletions
@@ -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)(
@@ -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');
});
@@ -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,
@@ -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