From 4f90b63c6101d8dfd3954031092a8e0248eb4649 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Wed, 21 Oct 2026 08:37:08 +0800 Subject: [PATCH] =?UTF-8?q?fix(adapter-wp):=20phpValueToJson=20=E8=BE=85?= =?UTF-8?q?=E5=8A=A9=E4=B8=8E=20JSON=20=E9=99=B7=E9=98=B1=E8=AD=A6?= =?UTF-8?q?=E7=A4=BA=EF=BC=9B=E9=94=99=E8=AF=AF=E4=B8=8A=E4=B8=8B=E6=96=87?= =?UTF-8?q?=E6=91=98=E5=BD=95=EF=BC=9BPhpError=20=E5=85=AC=E5=85=B1?= =?UTF-8?q?=E5=9F=BA=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审修复:新增 phpValueToJson(Map→字符串键普通对象,递归;bigint 分支 冻结为抛 TypeError 不静默转换),PhpValue JSDoc 警示 Map 直接 JSON.stringify 会静默变 {};两类错误 message 附 offset ±20 码元原文 摘录(控制字符转义、120 字符封顶);PhpError 公共抽象基类,两子类 互不 instanceof 的既有断言保持通过。 Co-Authored-By: Claude Code --- .../src/parse/php-unserialize.ts | 145 +++++++++++++----- .../test/php-unserialize.test.ts | 52 +++++++ 2 files changed, 158 insertions(+), 39 deletions(-) diff --git a/packages/adapter-wordpress/src/parse/php-unserialize.ts b/packages/adapter-wordpress/src/parse/php-unserialize.ts index 51617f1..8abdbe6 100644 --- a/packages/adapter-wordpress/src/parse/php-unserialize.ts +++ b/packages/adapter-wordpress/src/parse/php-unserialize.ts @@ -4,6 +4,9 @@ // (默认取 DEFAULT_WXR_LIMITS 的 phpMaxDepth=8 / phpMaxElements=1000), // 长度前缀错误与一切结构错误必须抛错(不静默截断),解析完成后必须到达串尾。 // 绝不经 eval——纯单指针扫描(位置变量 + 按类型分派)。 +// 错误体系:PhpError 公共基类,PhpParseError(结构错、含 offset)/ PhpLimitError(限额、kind 区分); +// 两类 message 均附带 offset ±20 码元的原文摘录(控制字符转义、总长封顶)。 +// JSON 桥接:phpValueToJson——Map 不可直接 JSON.stringify(会静默变 {}),见其 JSDoc。 // // 与计划注记的一处有意强化:s: 长度前缀按 UTF-8 字节计,而 JS string 按 UTF-16 码元计, // 两者对 BMP 内非 ASCII 字符并不一致(如 '中' 1 码元 = 3 字节;中文 WP 站点的 postmeta @@ -21,6 +24,8 @@ import { DEFAULT_WXR_LIMITS } from '../limits.js' * `i:` 的产出类型而不破坏 PhpValue 消费方 * - `a:` → 全部为从 0 连续依序的整数键 → 密集数组 `PhpValue[]`; * 否则(含 s: 键、i:/s: 混合、稀疏或乱序整数键)→ `Map`(保留插入序) + * + * ⚠ Map 不可直接 JSON.stringify(会静默变 {})——序列化前用 {@link phpValueToJson}。 */ export type PhpValue = | string @@ -30,24 +35,58 @@ export type PhpValue = | Map | PhpValue[] -/** 结构/语法错误:offset 为出错位置的 UTF-16 码元下标(message 中亦含) */ -export class PhpParseError extends Error { +/** + * 错误 message 的原文摘录后缀:offset ±20 码元窗口,控制字符转义为可见形式 + * (\n/\r/\t 及 \uXXXX),转义后超 120 字符封顶截断;窗口两端被裁剪时加 … 标记。 + */ +function excerptSuffix(input: string | undefined, offset: number): string { + if (input === undefined) return '' + const start = Math.max(0, offset - 20) + const end = Math.min(input.length, offset + 20) + let text = '' + // 逐码元转义控制字符为可见形式(\n/\r/\t 及 \uXXXX),避免 no-control-regex + // 且对 DEL(0x7f) 一并处理 + for (let i = start; i < end; i++) { + const code = input.charCodeAt(i) + if (code === 0x0a) text += '\\n' + else if (code === 0x0d) text += '\\r' + else if (code === 0x09) text += '\\t' + else if (code < 0x20 || code === 0x7f) text += `\\u${code.toString(16).padStart(4, '0')}` + else text += input[i] ?? '' + } + if (text.length > 120) text = `${text.slice(0, 120)}…` + const prefix = start > 0 ? '…' : '' + const suffix = end < input.length ? '…' : '' + return `;摘录「${prefix}${text}${suffix}」` +} + +/** 本模块错误公共基类:统一 name 约定(仅经子类实例化) */ +export abstract class PhpError extends Error { + constructor(name: string, message: string) { + super(message) + this.name = name + } +} + +/** 结构/语法错误:offset 为出错位置的 UTF-16 码元下标(message 中亦含,并附原文摘录) */ +export class PhpParseError extends PhpError { readonly offset: number - constructor(message: string, offset: number) { - super(`${message}(offset ${offset})`) - this.name = 'PhpParseError' + constructor(message: string, offset: number, input?: string) { + super('PhpParseError', `${message}(offset ${offset})${excerptSuffix(input, offset)}`) this.offset = offset } } -/** 限额错误(区别于 PhpParseError):kind 指明超限维度 */ -export class PhpLimitError extends Error { +/** 限额错误(区别于 PhpParseError):kind 指明超限维度;提供 offset 时 message 附位置与摘录 */ +export class PhpLimitError extends PhpError { readonly kind: 'depth' | 'elements' - constructor(kind: 'depth' | 'elements', message: string) { - super(message) - this.name = 'PhpLimitError' + constructor(kind: 'depth' | 'elements', message: string, offset?: number, input?: string) { + super( + 'PhpLimitError', + offset === undefined ? message : `${message}(offset ${offset})${excerptSuffix(input, offset)}`, + ) this.kind = kind } } @@ -86,16 +125,16 @@ export function phpUnserialize(input: string, limits: PhpParseLimits = {}): PhpV maxElements: limits.maxElements ?? DEFAULT_WXR_LIMITS.phpMaxElements, } const value = parseValue(ctx, 1) - if (ctx.pos !== input.length) throw new PhpParseError('解析完成后仍有尾随数据', ctx.pos) + if (ctx.pos !== input.length) throw new PhpParseError('解析完成后仍有尾随数据', ctx.pos, input) return value } -function unexpectedEnd(pos: number): never { - throw new PhpParseError('输入在值中途结束(截断)', pos) +function unexpectedEnd(ctx: Ctx, pos: number): never { + throw new PhpParseError('输入在值中途结束(截断)', pos, ctx.input) } -function throwLimit(kind: 'depth' | 'elements', message: string, offset: number): never { - throw new PhpLimitError(kind, `${message}(offset ${offset})`) +function throwLimit(ctx: Ctx, kind: 'depth' | 'elements', message: string, offset: number): never { + throw new PhpLimitError(kind, message, offset, ctx.input) } /** 当前位置字符;越界返回 ''(空串与任何字符比较均不等,天然落入各错误分支) */ @@ -105,8 +144,8 @@ function peek(ctx: Ctx): string { function expect(ctx: Ctx, ch: string): void { if (peek(ctx) !== ch) { - if (ctx.pos >= ctx.input.length) unexpectedEnd(ctx.pos) - throw new PhpParseError(`期望 ${JSON.stringify(ch)},实际 ${JSON.stringify(ctx.input[ctx.pos])}`, ctx.pos) + if (ctx.pos >= ctx.input.length) unexpectedEnd(ctx, ctx.pos) + throw new PhpParseError(`期望 ${JSON.stringify(ch)},实际 ${JSON.stringify(ctx.input[ctx.pos])}`, ctx.pos, ctx.input) } ctx.pos++ } @@ -116,11 +155,11 @@ function readUint(ctx: Ctx, what: string): number { const start = ctx.pos while (peek(ctx) >= '0' && peek(ctx) <= '9') ctx.pos++ if (ctx.pos === start) { - if (ctx.pos >= ctx.input.length) unexpectedEnd(ctx.pos) - throw new PhpParseError(`${what}缺少数字`, ctx.pos) + if (ctx.pos >= ctx.input.length) unexpectedEnd(ctx, ctx.pos) + throw new PhpParseError(`${what}缺少数字`, ctx.pos, ctx.input) } const n = Number(ctx.input.slice(start, ctx.pos)) - if (!Number.isSafeInteger(n)) throw new PhpParseError(`${what}数值超出安全整数范围`, start) + if (!Number.isSafeInteger(n)) throw new PhpParseError(`${what}数值超出安全整数范围`, start, ctx.input) return n } @@ -128,7 +167,7 @@ function parseValue(ctx: Ctx, depth: number): PhpValue { const value = parseOne(ctx, depth) ctx.count++ if (ctx.count > ctx.maxElements) { - throwLimit('elements', `元素数 ${ctx.count} 超过上限 ${ctx.maxElements}`, ctx.pos) + throwLimit(ctx, 'elements', `元素数 ${ctx.count} 超过上限 ${ctx.maxElements}`, ctx.pos) } return value } @@ -147,8 +186,8 @@ function parseOne(ctx: Ctx, depth: number): PhpValue { return parseArray(ctx, depth) default: { const ch = peek(ctx) - if (ch === '') unexpectedEnd(ctx.pos) - throw new PhpParseError(`不支持的类型前缀 ${JSON.stringify(ch)}(仅支持 i/b/d/s/a)`, ctx.pos) + if (ch === '') unexpectedEnd(ctx, ctx.pos) + throw new PhpParseError(`不支持的类型前缀 ${JSON.stringify(ch)}(仅支持 i/b/d/s/a)`, ctx.pos, ctx.input) } } } @@ -162,7 +201,7 @@ function parsePhpString(ctx: Ctx): string { const start = ctx.pos let need = declared while (need > 0) { - if (ctx.pos >= ctx.input.length) unexpectedEnd(ctx.pos) + if (ctx.pos >= ctx.input.length) unexpectedEnd(ctx, ctx.pos) const code = ctx.input.charCodeAt(ctx.pos) let width: number if (code < 0x80) width = 1 @@ -170,16 +209,16 @@ function parsePhpString(ctx: Ctx): string { else if (code >= 0xd800 && code <= 0xdbff) { const lo = ctx.input.charCodeAt(ctx.pos + 1) if (!(lo >= 0xdc00 && lo <= 0xdfff)) { - throw new PhpParseError('孤立的高代理项(非法 UTF-16,无法对应 PHP 字节串)', ctx.pos) + throw new PhpParseError('孤立的高代理项(非法 UTF-16,无法对应 PHP 字节串)', ctx.pos, ctx.input) } width = 4 } else if (code >= 0xdc00 && code <= 0xdfff) { - throw new PhpParseError('孤立的低代理项(非法 UTF-16,无法对应 PHP 字节串)', ctx.pos) + throw new PhpParseError('孤立的低代理项(非法 UTF-16,无法对应 PHP 字节串)', ctx.pos, ctx.input) } else { width = 3 } if (width > need) { - throw new PhpParseError(`字符串字节长度与前缀不符(前缀 ${declared} 字节)`, ctx.pos) + throw new PhpParseError(`字符串字节长度与前缀不符(前缀 ${declared} 字节)`, ctx.pos, ctx.input) } need -= width ctx.pos += width === 4 ? 2 : 1 @@ -198,8 +237,8 @@ function parsePhpInt(ctx: Ctx): number { const digitsStart = ctx.pos while (peek(ctx) >= '0' && peek(ctx) <= '9') ctx.pos++ if (ctx.pos === digitsStart) { - if (ctx.pos >= ctx.input.length) unexpectedEnd(ctx.pos) - throw new PhpParseError('i: 缺少数字', ctx.pos) + if (ctx.pos >= ctx.input.length) unexpectedEnd(ctx, ctx.pos) + throw new PhpParseError('i: 缺少数字', ctx.pos, ctx.input) } const token = ctx.input.slice(start, ctx.pos) expect(ctx, ';') @@ -211,8 +250,8 @@ function parsePhpBool(ctx: Ctx): boolean { expect(ctx, ':') const ch = peek(ctx) if (ch !== '0' && ch !== '1') { - if (ch === '') unexpectedEnd(ctx.pos) - throw new PhpParseError(`b: 只接受 0/1,实际 ${JSON.stringify(ch)}`, ctx.pos) + if (ch === '') unexpectedEnd(ctx, ctx.pos) + throw new PhpParseError(`b: 只接受 0/1,实际 ${JSON.stringify(ch)}`, ctx.pos, ctx.input) } ctx.pos++ expect(ctx, ';') @@ -225,13 +264,13 @@ function parsePhpDouble(ctx: Ctx): number { const start = ctx.pos while (peek(ctx) !== ';' && peek(ctx) !== '') ctx.pos++ if (ctx.pos === start) { - if (start >= ctx.input.length) unexpectedEnd(start) - throw new PhpParseError('d: 缺少数值', start) + if (start >= ctx.input.length) unexpectedEnd(ctx, start) + throw new PhpParseError('d: 缺少数值', start, ctx.input) } const token = ctx.input.slice(start, ctx.pos) const n = Number(token) if (!Number.isFinite(n)) { - throw new PhpParseError(`d: 数值非法或非有限(INF/NAN 不支持):${token}`, start) + throw new PhpParseError(`d: 数值非法或非有限(INF/NAN 不支持):${token}`, start, ctx.input) } expect(ctx, ';') return n @@ -244,24 +283,24 @@ function parseArray(ctx: Ctx, depth: number): Map | P expect(ctx, ':') expect(ctx, '{') if (depth > ctx.maxDepth) { - throwLimit('depth', `数组嵌套深度 ${depth} 超过上限 ${ctx.maxDepth}`, ctx.pos) + throwLimit(ctx, 'depth', `数组嵌套深度 ${depth} 超过上限 ${ctx.maxDepth}`, ctx.pos) } // 预检:N 个键值对至少贡献 2N 个值节点(键+值),先于 body 解析即拒绝 // a:1001:{} 之类"声明即超限"的构造(否则会先以截断错误呈现,语义错位) if (ctx.count + 2 * n > ctx.maxElements) { - throwLimit('elements', `元素数将达 ${ctx.count + 2 * n},超过上限 ${ctx.maxElements}`, ctx.pos) + throwLimit(ctx, 'elements', `元素数将达 ${ctx.count + 2 * n},超过上限 ${ctx.maxElements}`, ctx.pos) } const entries: Array<[string | number, PhpValue]> = [] for (let k = 0; k < n; k++) { const keyCh = peek(ctx) if (keyCh !== 'i' && keyCh !== 's') { - if (keyCh === '') unexpectedEnd(ctx.pos) - throw new PhpParseError(`数组键只支持 i:/s:,实际 ${JSON.stringify(keyCh)}`, ctx.pos) + if (keyCh === '') unexpectedEnd(ctx, ctx.pos) + throw new PhpParseError(`数组键只支持 i:/s:,实际 ${JSON.stringify(keyCh)}`, ctx.pos, ctx.input) } const key = parseValue(ctx, depth) if (typeof key !== 'number' && typeof key !== 'string') { // 防御性收窄:上方已限定键的类型前缀为 i:/s:,正常不可达 - throw new PhpParseError('数组键必须为整数或字符串标量', ctx.pos) + throw new PhpParseError('数组键必须为整数或字符串标量', ctx.pos, ctx.input) } const value = parseValue(ctx, depth + 1) entries.push([key, value]) @@ -271,3 +310,31 @@ function parseArray(ctx: Ctx, depth: number): Map | P if (entries.every(([key], idx) => key === idx)) return entries.map(([, v]) => v) return new Map(entries) } + +/** + * 将 PhpValue 转为可直接 JSON.stringify 的普通值:Map → 字符串键普通对象、 + * 数组 → 数组、标量原样,递归处理。 + * + * ⚠ Map 不可直接 JSON.stringify——Map 无可枚举自有属性,会静默变 `{}`(内容全部 + * 丢失且无报错);序列化前必须经本函数。 + * + * 键一律 String() 化(数字键 1 → "1")。PHP 数组键经规范化不会同时存在 i:1 与 + * s:"1"(数字字符串键在 PHP 侧已转 int;"01" 之类保持字符串且不与 1 冲突),故 + * 解析产物无键碰撞;手工构造的畸形数据如同时含两者,后写覆盖先写。 + * + * bigint 分支冻结:抛 TypeError——当前解析器不产出 bigint(见 PhpValue JSDoc 的 + * 契约预留)。若未来引入 bigint 产出,须在此显式决定转换策略(转字符串或拒收), + * 拒绝静默变更类型。 + */ +export function phpValueToJson(value: PhpValue): unknown { + if (value instanceof Map) { + const out: Record = {} + for (const [key, item] of value) out[String(key)] = phpValueToJson(item) + return out + } + if (Array.isArray(value)) return value.map((item) => phpValueToJson(item)) + if (typeof value === 'bigint') { + throw new TypeError('phpValueToJson:遇到 bigint(当前解析器不产出;拒绝静默转换,需显式决定策略)') + } + return value +} diff --git a/packages/adapter-wordpress/test/php-unserialize.test.ts b/packages/adapter-wordpress/test/php-unserialize.test.ts index b491517..d6e3567 100644 --- a/packages/adapter-wordpress/test/php-unserialize.test.ts +++ b/packages/adapter-wordpress/test/php-unserialize.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' import { phpUnserialize, + phpValueToJson, + PhpError, PhpLimitError, PhpParseError, type PhpValue, @@ -171,3 +173,53 @@ describe('phpUnserialize:限额(默认取 DEFAULT_WXR_LIMITS:phpMaxDepth=8 expect(phpUnserialize('a:2:{i:0;s:1:"a";i:1;s:1:"b";}', { maxElements: 5 })).toStrictEqual(['a', 'b']) }) }) + +describe('phpValueToJson:JSON 陷阱辅助(Map 不可直接 JSON.stringify)', () => { + it('Map → 字符串键普通对象(并佐证直接 stringify Map 会静默变 {} 的陷阱)', () => { + const v = phpUnserialize('a:1:{s:1:"k";i:1;}') + expect(JSON.stringify(v)).toBe('{}') // 陷阱:Map 被 JSON.stringify 静默丢弃全部内容 + expect(JSON.stringify(phpValueToJson(v))).toBe('{"k":1}') + }) + + it('嵌套 Map-in-array 递归往返', () => { + const v = phpUnserialize('a:2:{i:0;a:1:{s:3:"foo";s:3:"bar";}i:1;b:0;}') + expect(JSON.stringify(phpValueToJson(v))).toBe('[{"foo":"bar"},false]') + }) + + it('bigint 分支冻结:抛 TypeError(当前解析器不产出 bigint,拒绝静默转换)', () => { + expect(() => phpValueToJson(42n)).toThrow(TypeError) + }) +}) + +describe('错误信息上下文摘录(offset ±20 码元窗口、控制字符转义、总长封顶)', () => { + it('message 含 offset 附近原文摘录', () => { + const e = capture(() => phpUnserialize('i:1;junk')) as PhpParseError + expect(e.message).toContain('摘录') + expect(e.message).toContain('i:1;junk') + }) + + it('控制字符转义为可见形式,message 不含裸控制字符', () => { + const e = capture(() => phpUnserialize('s:7:"a\nb\nc";')) as PhpParseError + expect(e.message).toContain('a\\nb\\nc') + expect(e.message).not.toContain('\n') + }) + + it('长输入窗口截断(… 标记)且摘录总长封顶', () => { + const e = capture(() => phpUnserialize(`i:1;${'\u0001'.repeat(60)}`)) as PhpParseError + expect(e.message).toContain('…') + const excerpt = e.message.match(/「([^」]*)」/)?.[1] + expect(excerpt).toBeDefined() + expect((excerpt ?? '').length).toBeLessThanOrEqual(123) // 120 字符封顶 + 截断/窗口省略号 + }) +}) + +describe('错误类型层级:PhpError 公共基类', () => { + it('PhpParseError 与 PhpLimitError 均为 PhpError 子类,且互不为对方子类', () => { + const parse = capture(() => phpUnserialize('x:1:irrelevant;')) as PhpParseError + const limit = capture(() => phpUnserialize('a:1001:{}')) as PhpLimitError + expect(parse).toBeInstanceOf(PhpError) + expect(limit).toBeInstanceOf(PhpError) + expect(parse).not.toBeInstanceOf(PhpLimitError) + expect(limit).not.toBeInstanceOf(PhpParseError) + }) +})