fix(adapter-wp): phpValueToJson 辅助与 JSON 陷阱警示;错误上下文摘录;PhpError 公共基类

复审修复:新增 phpValueToJson(Map→字符串键普通对象,递归;bigint 分支
冻结为抛 TypeError 不静默转换),PhpValue JSDoc 警示 Map 直接
JSON.stringify 会静默变 {};两类错误 message 附 offset ±20 码元原文
摘录(控制字符转义、120 字符封顶);PhpError 公共抽象基类,两子类
互不 instanceof 的既有断言保持通过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-21 08:37:08 +08:00
parent 5550952fdb
commit 4f90b63c61
2 changed files with 158 additions and 39 deletions
@@ -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<string | number, PhpValue>
| 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<string | number, PhpValue> | 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<string | number, PhpValue> | 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<string, unknown> = {}
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
}
@@ -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)
})
})