From 4c4c43f7d920c2fcf902751606ea32cda77c3901 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Wed, 21 Oct 2026 10:10:03 +0800 Subject: [PATCH] =?UTF-8?q?fix(adapter-wp):=20=E5=8E=9F=E5=9E=8B=E9=94=AE?= =?UTF-8?q?=E9=98=B2=E5=BE=A1=E4=B8=8E=E5=AF=B9=E6=8A=97=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=EF=BC=9Bmeta=20=E5=86=B2=E7=AA=81=E7=AD=96=E7=95=A5=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=EF=BC=88=E5=8D=95=E5=80=BC=E5=85=88=E8=80=85+issue/ph?= =?UTF-8?q?pRaw=20=E6=B8=85=E7=90=86=EF=BC=89=EF=BC=9Bextras=20=E5=8E=9F?= =?UTF-8?q?=E5=9E=8B=E6=B1=A1=E6=9F=93=E9=98=B2=E5=BE=A1=EF=BC=9Bconfig=20?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=EF=BC=9Bprebuild=20=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=E5=9B=BA=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix 1:routes/STATUS_MAP 查键一律 Object.hasOwn 守卫('constructor'/ 'valueOf' 等继承键裸索引命中函数值绕过 undefined 检查)+ 对抗测试 - Fix 2:prebuild 脚本先建 contracts dist(fresh-clone build 不再 TS2307, 亦防 stale dist 静默编译;已实测 rm -rf dist 后全链成功) - Fix 3:单值 meta(thumbnail/seo×2)重复→先者胜出+issue(WP 单值语义, 首个占位即便值非法不被顶替);wppop 重复→最后一次决定终态(成功清 phpRaw/失败清 wppop_sections,两键互斥);三方策略 JSDoc 冻结 - Fix 4:extras 改 null 原型字典——'__proto__' meta 不再被 setter 静默 吞掉(测试钉死自有键+JSON 往返);gallery 累积语义明确 - Fix 5:typeRoutes 空字符串路由值 → TypeError fail fast - 173→184 测试全绿;typecheck/lint/build 绿 Co-Authored-By: Claude Code --- packages/adapter-wordpress/package.json | 1 + .../adapter-wordpress/src/map/item-to-row.ts | 64 ++++++- .../test/item-to-row.test.ts | 160 +++++++++++++++++- 3 files changed, 218 insertions(+), 7 deletions(-) diff --git a/packages/adapter-wordpress/package.json b/packages/adapter-wordpress/package.json index 35f0ead..3adf55a 100644 --- a/packages/adapter-wordpress/package.json +++ b/packages/adapter-wordpress/package.json @@ -12,6 +12,7 @@ } }, "scripts": { + "prebuild": "pnpm --filter @sf/contracts build", "build": "tsc -p tsconfig.build.json", "typecheck": "tsc -p tsconfig.json --noEmit" }, diff --git a/packages/adapter-wordpress/src/map/item-to-row.ts b/packages/adapter-wordpress/src/map/item-to-row.ts index 2ac09f9..df7d1f8 100644 --- a/packages/adapter-wordpress/src/map/item-to-row.ts +++ b/packages/adapter-wordpress/src/map/item-to-row.ts @@ -50,6 +50,15 @@ export const UNKNOWN_STATUS = 'unknown' * thumbnail → attachmentRefs{role:'featured'};gallery(逗号分隔 id)→ {role:'gallery'}; * seoTitle/seoDescription → seo.title/description;wppopSections → PHP 解析(见 mapWppop); * 其余 meta 一律原样进 extras。 + * + * 同 item 内键冲突的三方策略(审查 Fix 3,JSDoc 冻结): + * - 未识别键 → **后者覆盖**(文档序 last-wins,进 extras); + * - 单值键(thumbnail/seoTitle/seoDescription)→ **先者胜出** + issue + * (照 WP get_post_meta($id,$key,true) 取首值的语义;首个出现即占位,即便其值 + * 为空/非法也不被后续同键值顶替——重复即数据异常,显式报告不猜); + * - wppopSections → **最后一次出现决定终态**:成功则清 extras.phpRaw,失败则清 + * extras.wppop_sections(issues 为追加式日志,不随终态回滚)。 + * gallery 为累积语义(多段并集),不在单值策略内。 */ export const META_KEYS = Object.freeze({ thumbnail: '_thumbnail_id', @@ -66,6 +75,8 @@ export interface MapConfig { /** * post_type → sourceType 路由;缺省 {@link DEFAULT_TYPE_ROUTES}。 * 传则**整体替换**(非合并)——需增改请展开默认表:`{ ...DEFAULT_TYPE_ROUTES, ... }`。 + * 路由值不得为空字符串(post_type 映射到空 sourceType 是调用方程序错误, + * mapItem 直接抛 TypeError fail fast——审查 Fix 5)。 */ typeRoutes?: Readonly> } @@ -86,12 +97,17 @@ export type MappedResult = * wppop_sections 分流:PHP 串经 phpUnserialize + phpValueToJson 进 extras(Map 陷阱 * 防线:phpValueToJson 前的 Map 不可 JSON.stringify,会静默变 {});任何解析失败 * (PhpParseError/PhpLimitError)不崩不丢——原串存 extras.phpRaw 并记 issue。 + * + * 同 item 重复 wppop_sections(审查 Fix 3b):最后一次出现决定终态——成功清 + * extras.phpRaw,失败对称清 extras.wppop_sections(两键互斥,不残留混态)。 */ function mapWppop(raw: string, extras: Record, issues: string[]): void { try { extras[META_KEYS.wppopSections] = phpValueToJson(phpUnserialize(raw)) + delete extras.phpRaw } catch (err) { extras.phpRaw = raw + delete extras[META_KEYS.wppopSections] const detail = err instanceof Error ? `${err.name}: ${err.message}` : String(err) issues.push(`meta ${META_KEYS.wppopSections} PHP 解析失败(${detail}),原串保留于 extras.phpRaw`) } @@ -106,13 +122,28 @@ function mapWppop(raw: string, extras: Record, issues: string[] * 字段以"缺席"表达"源无此值",而非空串。 */ export function mapItem(item: WxrItem, config: MapConfig): MappedResult { + // config 校验先于一切(程序员错误 fail fast——审查 Fix 5):空串路由值会产出 + // sourceType:'' 的 row,静默穿透守卫的非空不变量 + if (config.typeRoutes !== undefined) { + for (const [key, value] of Object.entries(config.typeRoutes)) { + if (value === '') { + throw new TypeError( + `MapConfig.typeRoutes 含空字符串路由值(key=${JSON.stringify(key)}):post_type 必须映射到非空 sourceType`, + ) + } + } + } + // Task 3 审查移交策略:post_id 是 sourceId 唯一来源,缺失/非法即无法建立稳定身份 if (item.postId === null) { return { kind: 'error', issue: 'post_id 缺失或非法', rawLocation: item.rawLocation } } + // 原型键防御(审查 Fix 1):postType/status 来自不可信输入,裸索引会命中继承属性 + // (如 'constructor'/'valueOf' 返回函数,绕过 undefined 检查被当合法值使用)—— + // 一律 Object.hasOwn 守卫,继承键视同未配置/未知 const routes = config.typeRoutes ?? DEFAULT_TYPE_ROUTES - const sourceType = routes[item.postType] + const sourceType = Object.hasOwn(routes, item.postType) ? routes[item.postType] : undefined if (sourceType === undefined) { return { kind: 'unsupported', @@ -123,7 +154,7 @@ export function mapItem(item: WxrItem, config: MapConfig): MappedResult { } const issues: string[] = [] - const knownStatus = STATUS_MAP[item.status] + const knownStatus = Object.hasOwn(STATUS_MAP, item.status) ? STATUS_MAP[item.status] : undefined const status = knownStatus ?? UNKNOWN_STATUS if (knownStatus === undefined) { issues.push(`status 值未识别:${JSON.stringify(item.status)},已映射为 '${UNKNOWN_STATUS}'`) @@ -142,9 +173,21 @@ export function mapItem(item: WxrItem, config: MapConfig): MappedResult { const attachmentRefs: UniversalRow['attachmentRefs'] = [] const seo: Record = {} - const extras: Record = {} + // 原型污染防御(审查 Fix 4):null 原型字典——meta key 是不可信输入,普通对象上 + // `extras['__proto__'] = v` 命中 setter 被静默吞掉(字符串原型非对象,赋值无效果); + // null 原型下该键成为普通自有键。isUniversalRow/JSON.stringify/展开复制均兼容。 + const extras: Record = Object.create(null) + // 单值键占位标记(冲突策略见 META_KEYS JSDoc):首个出现即占位,重复 → issue + 取先者 + let thumbnailSeen = false + let seoTitleSeen = false + let seoDescriptionSeen = false for (const meta of item.metas) { if (meta.key === META_KEYS.thumbnail) { + if (thumbnailSeen) { + issues.push(`meta ${META_KEYS.thumbnail} 重复,取先者`) + continue + } + thumbnailSeen = true const sourceId = meta.value.trim() if (sourceId === '') { // 畸形:meta 存在但值为空(对照:空 gallery 值 = 无图库,合法) @@ -153,14 +196,25 @@ export function mapItem(item: WxrItem, config: MapConfig): MappedResult { } attachmentRefs.push({ role: 'featured', sourceId }) } else if (meta.key === META_KEYS.gallery) { + // 累积语义:多段 gallery meta 的引用按文档序并入(无冲突策略) for (const part of meta.value.split(',')) { const sourceId = part.trim() if (sourceId !== '') attachmentRefs.push({ role: 'gallery', sourceId }) } } else if (meta.key === META_KEYS.seoTitle) { - seo.title = meta.value + if (seoTitleSeen) { + issues.push(`meta ${META_KEYS.seoTitle} 重复,取先者`) + continue + } + seoTitleSeen = true + if (meta.value !== '') seo.title = meta.value } else if (meta.key === META_KEYS.seoDescription) { - seo.description = meta.value + if (seoDescriptionSeen) { + issues.push(`meta ${META_KEYS.seoDescription} 重复,取先者`) + continue + } + seoDescriptionSeen = true + if (meta.value !== '') seo.description = meta.value } else if (meta.key === META_KEYS.wppopSections) { mapWppop(meta.value, extras, issues) } else { diff --git a/packages/adapter-wordpress/test/item-to-row.test.ts b/packages/adapter-wordpress/test/item-to-row.test.ts index 9b45df9..f321b48 100644 --- a/packages/adapter-wordpress/test/item-to-row.test.ts +++ b/packages/adapter-wordpress/test/item-to-row.test.ts @@ -79,6 +79,11 @@ function expectRowInvariants(row: UniversalRow): void { } } +/** extras 为 null 原型字典(原型污染防御,审查 Fix 4):展开为普通对象再作整体等值比较 */ +function plain(extras: Record): Record { + return { ...extras } +} + describe('mapItem:mini.xml 端到端(parseWxrStream → mapItem)', () => { it('post(101):全字段映射(路由/status/locale/slug/seo/taxonomyRefs/featured/knownFields/bodyHtml 原文)', async () => { const { row, issues } = rowOf(mapItem(byPostId(await miniItems(), 101), CONFIG)) @@ -112,7 +117,7 @@ describe('mapItem:mini.xml 端到端(parseWxrStream → mapItem)', () => { creator: 'editor', }) // 101 的 3 条 meta 全部被分流(thumbnail/yoast×2)——extras 无未识别残余 - expect(row.extras).toStrictEqual({}) + expect(plain(row.extras)).toStrictEqual({}) expectRowInvariants(row) }) @@ -281,7 +286,7 @@ describe('mapItem:meta 分流表', () => { ), ) expect(issues).toStrictEqual([]) - expect(row.extras).toStrictEqual({ custom_a: 'va-2', custom_b: 'vb' }) + expect(plain(row.extras)).toStrictEqual({ custom_a: 'va-2', custom_b: 'vb' }) expect(row.attachmentRefs).toStrictEqual([{ role: 'featured', sourceId: '301' }]) }) @@ -348,3 +353,154 @@ describe('mapItem:category 引用形状防线', () => { expectRowInvariants(row) }) }) + +describe('mapItem:原型键防御(审查 Fix 1——裸索引会命中继承属性)', () => { + it("postType 'constructor'(继承键,值为函数非路由)→ unsupported,不被当合法路由命中", () => { + const result = mapItem(makeItem({ postType: 'constructor' }), CONFIG) + expect(result).toStrictEqual({ + kind: 'unsupported', + sourceType: 'wp:constructor', + reason: 'post_type 未配置映射', + rawLocation: 'wxr://item/handmade', + }) + }) + + it("status 'valueOf'(继承键,值为函数非字符串)→ 'unknown' + issue", () => { + const { row, issues } = rowOf(mapItem(makeItem({ status: 'valueOf' }), CONFIG)) + expect(row.status).toBe('unknown') + expect(issues).toStrictEqual([`status 值未识别:"valueOf",已映射为 'unknown'`]) + }) + + it("typeRoutes 自定义表同样守卫:'toString' 键未被显式配置 → unsupported", () => { + const custom: MapConfig = { locale: 'zh-CN', typeRoutes: { post: 'wp:post' } } + expect(mapItem(makeItem({ postType: 'toString' }), custom).kind).toBe('unsupported') + }) +}) + +describe('mapItem:meta 冲突策略三方统一(审查 Fix 3)', () => { + it('单值键重复(thumbnail/seoTitle/seoDescription)→ 先者胜出 + issue(WP 单值语义)', () => { + const { row, issues } = rowOf( + mapItem( + makeItem({ + metas: [ + { key: '_thumbnail_id', value: '501' }, + { key: '_thumbnail_id', value: '502' }, + { key: '_yoast_wpseo_title', value: 'T1' }, + { key: '_yoast_wpseo_title', value: 'T2' }, + { key: '_yoast_wpseo_metadesc', value: 'D1' }, + { key: '_yoast_wpseo_metadesc', value: 'D2' }, + ], + }), + CONFIG, + ), + ) + expect(row.attachmentRefs).toStrictEqual([{ role: 'featured', sourceId: '501' }]) + expect(row.seo).toStrictEqual({ title: 'T1', description: 'D1' }) + expect(issues).toStrictEqual([ + 'meta _thumbnail_id 重复,取先者', + 'meta _yoast_wpseo_title 重复,取先者', + 'meta _yoast_wpseo_metadesc 重复,取先者', + ]) + }) + + it('首个单值键即便值非法也占位:后续同键值不顶替(重复即异常,显式报告不猜)', () => { + const { row, issues } = rowOf( + mapItem( + makeItem({ + metas: [ + { key: '_thumbnail_id', value: '' }, + { key: '_thumbnail_id', value: '503' }, + ], + }), + CONFIG, + ), + ) + expect(row.attachmentRefs).toStrictEqual([]) + expect(issues).toStrictEqual([ + 'meta _thumbnail_id 值为空,featured 引用已跳过', + 'meta _thumbnail_id 重复,取先者', + ]) + }) + + it('wppop 先败后成:extras.phpRaw 被清除、终态为成功产物(issues 留痕不回滚)', () => { + const { row, issues } = rowOf( + mapItem( + makeItem({ + metas: [ + { key: 'wppop_sections', value: 'a:1:{i:0;s:5:"ab' }, // 截断 → 失败 + { key: 'wppop_sections', value: 'a:1:{i:0;s:3:"foo";}' }, // 成功 + ], + }), + CONFIG, + ), + ) + expect(Object.hasOwn(row.extras, 'phpRaw')).toBe(false) + expect(row.extras.wppop_sections).toStrictEqual(['foo']) + expect(issues).toHaveLength(1) // 首次失败仍在 issue 日志中 + }) + + it('wppop 先成后败:对称清理——终态 phpRaw、无 wppop_sections 残留', () => { + const bad = 'a:1:{i:0;s:5:"ab' + const { row } = rowOf( + mapItem( + makeItem({ + metas: [ + { key: 'wppop_sections', value: 'a:1:{i:0;s:3:"foo";}' }, + { key: 'wppop_sections', value: bad }, + ], + }), + CONFIG, + ), + ) + expect(Object.hasOwn(row.extras, 'wppop_sections')).toBe(false) + expect(row.extras.phpRaw).toBe(bad) + }) + + it('gallery 累积语义:两段 gallery meta 的引用按文档序并入(不属单值冲突策略)', () => { + const { row, issues } = rowOf( + mapItem( + makeItem({ + metas: [ + { key: '_product_image_gallery', value: '601' }, + { key: '_product_image_gallery', value: '602,603' }, + ], + }), + CONFIG, + ), + ) + expect(row.attachmentRefs).toStrictEqual([ + { role: 'gallery', sourceId: '601' }, + { role: 'gallery', sourceId: '602' }, + { role: 'gallery', sourceId: '603' }, + ]) + expect(issues).toStrictEqual([]) + }) +}) + +describe('mapItem:原型污染防御(审查 Fix 4)', () => { + it("meta key '__proto__' 不静默丢失:extras 保留为自有可枚举键(null 原型字典)", () => { + const { row, issues } = rowOf( + mapItem(makeItem({ metas: [{ key: '__proto__', value: 'proto-v' }] }), CONFIG), + ) + expect(issues).toStrictEqual([]) + expect(Object.hasOwn(row.extras, '__proto__')).toBe(true) + expect(row.extras['__proto__']).toBe('proto-v') + expect(JSON.stringify(row.extras)).toContain('"__proto__":"proto-v"') + expectRowInvariants(row) // 守卫(含 seo/knownFields/extras 对象形状检查)仍通过 + }) +}) + +describe('mapItem:config 校验(审查 Fix 5)', () => { + it('typeRoutes 含空字符串路由值 → TypeError(程序员错误,fail fast)', () => { + expect(() => mapItem(makeItem(), { locale: 'zh-CN', typeRoutes: { post: '' } })).toThrow(TypeError) + expect(() => + mapItem(makeItem(), { locale: 'zh-CN', typeRoutes: { post: 'wp:post', page: '' } }), + ).toThrow(/typeRoutes/) + }) + + it('非空路由值不误伤:合法自定义表正常产出', () => { + expect(() => + rowOf(mapItem(makeItem(), { locale: 'zh-CN', typeRoutes: { post: 'wp:post' } })), + ).not.toThrow() + }) +})