From 5550952fdb11ff53d15532772267d05dd8ec8a3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Wed, 21 Oct 2026 08:23:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(adapter-wp):=20PHP=20serialize=20=E5=8F=97?= =?UTF-8?q?=E9=99=90=E8=A7=A3=E6=9E=90=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 只支持 i/b/d/s/a 四类型的单指针递归下降解析器:深度与元素数双限额 (默认 DEFAULT_WXR_LIMITS phpMaxDepth=8/phpMaxElements=1000,超限抛 PhpLimitError 按 kind 区分),长度前缀/结构错误一律抛 PhpParseError (含 offset),尾随垃圾抛错。a: 形状推断:连续 0 起整数键→密集数组, 否则 Map。s: 按 UTF-8 字节宽度取串(含代理对),中文 postmeta 准确。 Co-Authored-By: Claude Code --- packages/adapter-wordpress/src/index.ts | 1 + .../src/parse/php-unserialize.ts | 273 ++++++++++++++++++ .../test/php-unserialize.test.ts | 173 +++++++++++ 3 files changed, 447 insertions(+) create mode 100644 packages/adapter-wordpress/src/parse/php-unserialize.ts create mode 100644 packages/adapter-wordpress/test/php-unserialize.test.ts diff --git a/packages/adapter-wordpress/src/index.ts b/packages/adapter-wordpress/src/index.ts index 82bd09c..0fb704c 100644 --- a/packages/adapter-wordpress/src/index.ts +++ b/packages/adapter-wordpress/src/index.ts @@ -1 +1,2 @@ export * from './limits.js' +export * from './parse/php-unserialize.js' diff --git a/packages/adapter-wordpress/src/parse/php-unserialize.ts b/packages/adapter-wordpress/src/parse/php-unserialize.ts new file mode 100644 index 0000000..51617f1 --- /dev/null +++ b/packages/adapter-wordpress/src/parse/php-unserialize.ts @@ -0,0 +1,273 @@ +// PHP serialize 受限递归下降解析器(Plan 2 Task 2): +// WordPress 把数组/对象数据以 PHP serialize 格式存进 postmeta(如 wppop_sections), +// 本解析器只支持其中真实出现的 i:/b:/d:/s:/a: 四类型,深度与元素数双限额 +// (默认取 DEFAULT_WXR_LIMITS 的 phpMaxDepth=8 / phpMaxElements=1000), +// 长度前缀错误与一切结构错误必须抛错(不静默截断),解析完成后必须到达串尾。 +// 绝不经 eval——纯单指针扫描(位置变量 + 按类型分派)。 +// +// 与计划注记的一处有意强化:s: 长度前缀按 UTF-8 字节计,而 JS string 按 UTF-16 码元计, +// 两者对 BMP 内非 ASCII 字符并不一致(如 '中' 1 码元 = 3 字节;中文 WP 站点的 postmeta +// 恰是本项目主要真实数据源),故按 UTF-8 字节宽度逐码元累计取串(含代理对 4 字节, +// 增补平面亦准确);仅孤立代理项无法对应 PHP 字节串 → 抛错。 + +import { DEFAULT_WXR_LIMITS } from '../limits.js' + +/** + * PHP serialize 值的 JS 表示: + * - `s:` → string;`b:` → boolean;`d:` → number(IEEE 754 双精度,与 PHP float 同构) + * - `i:` → number(冻结决策:真实 WP postmeta 整数均在 ±2^53 安全区内,取 number + * 利于下游 JSON 序列化;超安全区的理论精度损失记录于此,不另行处理) + * - bigint 分支为契约预留:当前实现不产出;若未来需精确恢复超大整数,可仅切换 + * `i:` 的产出类型而不破坏 PhpValue 消费方 + * - `a:` → 全部为从 0 连续依序的整数键 → 密集数组 `PhpValue[]`; + * 否则(含 s: 键、i:/s: 混合、稀疏或乱序整数键)→ `Map`(保留插入序) + */ +export type PhpValue = + | string + | number + | boolean + | bigint + | Map + | PhpValue[] + +/** 结构/语法错误:offset 为出错位置的 UTF-16 码元下标(message 中亦含) */ +export class PhpParseError extends Error { + readonly offset: number + + constructor(message: string, offset: number) { + super(`${message}(offset ${offset})`) + this.name = 'PhpParseError' + this.offset = offset + } +} + +/** 限额错误(区别于 PhpParseError):kind 指明超限维度 */ +export class PhpLimitError extends Error { + readonly kind: 'depth' | 'elements' + + constructor(kind: 'depth' | 'elements', message: string) { + super(message) + this.name = 'PhpLimitError' + this.kind = kind + } +} + +/** 可覆盖的双限额(缺省取 DEFAULT_WXR_LIMITS 的 phpMaxDepth / phpMaxElements) */ +export interface PhpParseLimits { + maxDepth?: number + maxElements?: number +} + +interface Ctx { + input: string + pos: number + /** 已解析的值节点总数(数组键亦计为值节点) */ + count: number + maxDepth: number + maxElements: number +} + +/** + * 解析一段 PHP serialize 数据(WordPress postmeta 值)。 + * + * 约定与边界(详见模块头注释与 PhpValue JSDoc): + * - 仅支持 i:/b:/d:/s:/a:;`N;`、`O:`、`R:`、`C:` 等其余类型 → PhpParseError + * - `d:` 只产出有限数值:INF/-INF/NAN → PhpParseError(冻结:不引入非有限数值) + * - 尾随垃圾 → PhpParseError(成功解析必须恰好消费到串尾) + * - 超深度/超元素数 → PhpLimitError(kind 区分),在 body 解析前预检 + * (`a:1001:{}` 之类不会先变成截断错误) + */ +export function phpUnserialize(input: string, limits: PhpParseLimits = {}): PhpValue { + const ctx: Ctx = { + input, + pos: 0, + count: 0, + maxDepth: limits.maxDepth ?? DEFAULT_WXR_LIMITS.phpMaxDepth, + maxElements: limits.maxElements ?? DEFAULT_WXR_LIMITS.phpMaxElements, + } + const value = parseValue(ctx, 1) + if (ctx.pos !== input.length) throw new PhpParseError('解析完成后仍有尾随数据', ctx.pos) + return value +} + +function unexpectedEnd(pos: number): never { + throw new PhpParseError('输入在值中途结束(截断)', pos) +} + +function throwLimit(kind: 'depth' | 'elements', message: string, offset: number): never { + throw new PhpLimitError(kind, `${message}(offset ${offset})`) +} + +/** 当前位置字符;越界返回 ''(空串与任何字符比较均不等,天然落入各错误分支) */ +function peek(ctx: Ctx): string { + return ctx.input[ctx.pos] ?? '' +} + +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) + } + ctx.pos++ +} + +/** 读取无符号整数 token(至少一位数字,止于首个非数字;用于 s:/a: 前缀计数) */ +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) + } + const n = Number(ctx.input.slice(start, ctx.pos)) + if (!Number.isSafeInteger(n)) throw new PhpParseError(`${what}数值超出安全整数范围`, start) + return n +} + +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) + } + return value +} + +function parseOne(ctx: Ctx, depth: number): PhpValue { + switch (peek(ctx)) { + case 's': + return parsePhpString(ctx) + case 'i': + return parsePhpInt(ctx) + case 'b': + return parsePhpBool(ctx) + case 'd': + return parsePhpDouble(ctx) + case 'a': + 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) + } + } +} + +function parsePhpString(ctx: Ctx): string { + expect(ctx, 's') + expect(ctx, ':') + const declared = readUint(ctx, 's: 长度前缀') + expect(ctx, ':') + expect(ctx, '"') + const start = ctx.pos + let need = declared + while (need > 0) { + if (ctx.pos >= ctx.input.length) unexpectedEnd(ctx.pos) + const code = ctx.input.charCodeAt(ctx.pos) + let width: number + if (code < 0x80) width = 1 + else if (code < 0x800) width = 2 + 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) + } + width = 4 + } else if (code >= 0xdc00 && code <= 0xdfff) { + throw new PhpParseError('孤立的低代理项(非法 UTF-16,无法对应 PHP 字节串)', ctx.pos) + } else { + width = 3 + } + if (width > need) { + throw new PhpParseError(`字符串字节长度与前缀不符(前缀 ${declared} 字节)`, ctx.pos) + } + need -= width + ctx.pos += width === 4 ? 2 : 1 + } + const value = ctx.input.slice(start, ctx.pos) + expect(ctx, '"') + expect(ctx, ';') + return value +} + +function parsePhpInt(ctx: Ctx): number { + expect(ctx, 'i') + expect(ctx, ':') + const start = ctx.pos + if (peek(ctx) === '-') ctx.pos++ + 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) + } + const token = ctx.input.slice(start, ctx.pos) + expect(ctx, ';') + return Number(token) +} + +function parsePhpBool(ctx: Ctx): boolean { + expect(ctx, 'b') + 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) + } + ctx.pos++ + expect(ctx, ';') + return ch === '1' +} + +function parsePhpDouble(ctx: Ctx): number { + expect(ctx, 'd') + expect(ctx, ':') + 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) + } + const token = ctx.input.slice(start, ctx.pos) + const n = Number(token) + if (!Number.isFinite(n)) { + throw new PhpParseError(`d: 数值非法或非有限(INF/NAN 不支持):${token}`, start) + } + expect(ctx, ';') + return n +} + +function parseArray(ctx: Ctx, depth: number): Map | PhpValue[] { + expect(ctx, 'a') + expect(ctx, ':') + const n = readUint(ctx, 'a: 元素数') + expect(ctx, ':') + expect(ctx, '{') + if (depth > ctx.maxDepth) { + throwLimit('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) + } + 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) + } + const key = parseValue(ctx, depth) + if (typeof key !== 'number' && typeof key !== 'string') { + // 防御性收窄:上方已限定键的类型前缀为 i:/s:,正常不可达 + throw new PhpParseError('数组键必须为整数或字符串标量', ctx.pos) + } + const value = parseValue(ctx, depth + 1) + entries.push([key, value]) + } + expect(ctx, '}') + // 形状推断:全部为从 0 连续依序的整数键 → 密集数组;否则 → Map(保留插入序与原键值) + if (entries.every(([key], idx) => key === idx)) return entries.map(([, v]) => v) + return new Map(entries) +} diff --git a/packages/adapter-wordpress/test/php-unserialize.test.ts b/packages/adapter-wordpress/test/php-unserialize.test.ts new file mode 100644 index 0000000..b491517 --- /dev/null +++ b/packages/adapter-wordpress/test/php-unserialize.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from 'vitest' +import { + phpUnserialize, + PhpLimitError, + PhpParseError, + type PhpValue, +} from '../src/parse/php-unserialize.js' + +// 捕获抛出的错误实例(而非仅匹配),以便断言 offset / kind / message 细节 +function capture(fn: () => unknown): unknown { + try { + fn() + } catch (e) { + return e + } + throw new Error('期望抛出但未抛出') +} + +describe('phpUnserialize:标量与真实夹具', () => { + it('真实夹具串(wppop_sections 形状):逐层逐值深度等值', () => { + const input = 'a:1:{i:0;a:2:{s:5:"title";s:8:"SpecsTab";s:7:"content";s:10:"

Tab

";}}' + // 外层 i:0 连续键 → 密集数组;内层 s: 键 → Map(保留插入序) + expect(phpUnserialize(input)).toStrictEqual([ + new Map([ + ['title', 'SpecsTab'], + ['content', '

Tab

'], + ]), + ]) + }) + + it('标量四类型:s → string、i → number(冻结)、b → boolean、d → number', () => { + expect(phpUnserialize('s:5:"hello";')).toBe('hello') + expect(phpUnserialize('i:42;')).toBe(42) + expect(phpUnserialize('i:-7;')).toBe(-7) + expect(phpUnserialize('b:1;')).toBe(true) + expect(phpUnserialize('b:0;')).toBe(false) + expect(phpUnserialize('d:1.5;')).toBe(1.5) + }) + + it('s: 长度前缀按 UTF-8 字节计:CJK(3 字节/字)与增补平面(4 字节)均准确', () => { + expect(phpUnserialize('s:6:"中文";')).toBe('中文') + expect(phpUnserialize('s:4:"😀";')).toBe('😀') + expect(phpUnserialize('s:8:"a中😀";')).toBe('a中😀') + }) +}) + +describe('phpUnserialize:a: 形状推断(Map vs 密集数组)', () => { + it('字符串键 → Map', () => { + expect(phpUnserialize('a:1:{s:3:"foo";i:1;}')).toStrictEqual(new Map([['foo', 1]])) + }) + + it('连续整数键(0..n-1 依序)→ 密集数组', () => { + expect(phpUnserialize('a:3:{i:0;i:10;i:1;i:20;i:2;i:30;}')).toStrictEqual([10, 20, 30]) + }) + + it('混合键(i: 与 s: 并存,真实 WP 数据两类都有)→ Map', () => { + expect(phpUnserialize('a:2:{i:0;b:1;s:1:"k";s:1:"v";}')).toStrictEqual( + new Map([ + [0, true], + ['k', 'v'], + ]), + ) + }) + + it('稀疏/非 0 起始的整数键 → Map(键保留原值)', () => { + expect(phpUnserialize('a:2:{i:1;s:1:"a";i:2;s:1:"b";}')).toStrictEqual( + new Map([ + [1, 'a'], + [2, 'b'], + ]), + ) + }) + + it('a:0:{} → 空数组', () => { + expect(phpUnserialize('a:0:{}')).toStrictEqual([]) + }) +}) + +describe('phpUnserialize:长度前缀与结构错误(不静默,抛 PhpParseError 带 offset)', () => { + it('长度前缀错误:声明 7 实际 8 字符 → 抛,offset 指向不符处,message 含位置', () => { + const e = capture(() => phpUnserialize('s:7:"SpecsTab";')) as PhpParseError + expect(e).toBeInstanceOf(PhpParseError) + expect(e).not.toBeInstanceOf(PhpLimitError) + expect(e.offset).toBe(12) // 's:7:"SpecsTa|b'——字节计与声明不符处 + expect(e.message).toContain('12') + }) + + it('多字节字符使字节计数超出声明 → 抛', () => { + const e = capture(() => phpUnserialize('s:2:"中";')) as PhpParseError + expect(e).toBeInstanceOf(PhpParseError) + expect(e.offset).toBe(5) + }) + + it('空串 → 抛,offset 0', () => { + const e = capture(() => phpUnserialize('')) as PhpParseError + expect(e).toBeInstanceOf(PhpParseError) + expect(e.offset).toBe(0) + }) + + it('截断串(字符串内容未达声明字节数)→ 抛,offset 指向扫描停点', () => { + const e = capture(() => phpUnserialize('a:1:{i:0;s:5:"ab')) as PhpParseError + expect(e).toBeInstanceOf(PhpParseError) + expect(e.offset).toBe(16) + }) + + it('尾随垃圾 → 抛(解析完成后必须到达串尾)', () => { + const e = capture(() => phpUnserialize('i:1;junk')) as PhpParseError + expect(e).toBeInstanceOf(PhpParseError) + expect(e.offset).toBe(4) + }) + + it('非法类型前缀 x: → 抛', () => { + const e = capture(() => phpUnserialize('x:1:irrelevant;')) as PhpParseError + expect(e).toBeInstanceOf(PhpParseError) + expect(e.offset).toBe(0) + }) + + it('不在支持范围的 PHP 类型(N;/O:)→ 抛(本解析器仅支持 i/b/d/s/a)', () => { + expect(capture(() => phpUnserialize('N;'))).toBeInstanceOf(PhpParseError) + expect(capture(() => phpUnserialize('O:8:"stdClass":0:{}'))).toBeInstanceOf(PhpParseError) + }) + + it('d:INF / d:NAN → 抛(冻结:不引入非有限数值)', () => { + expect(capture(() => phpUnserialize('d:INF;'))).toBeInstanceOf(PhpParseError) + expect(capture(() => phpUnserialize('d:NAN;'))).toBeInstanceOf(PhpParseError) + }) + + it('a: 声明元素数与实际不符 → 抛', () => { + expect(capture(() => phpUnserialize('a:2:{i:0;s:1:"a";}'))).toBeInstanceOf(PhpParseError) + }) + + it('数组键只支持 i:/s: → b: 作键抛', () => { + expect(capture(() => phpUnserialize('a:1:{b:1;i:0;}'))).toBeInstanceOf(PhpParseError) + }) +}) + +describe('phpUnserialize:限额(默认取 DEFAULT_WXR_LIMITS:phpMaxDepth=8 / phpMaxElements=1000)', () => { + const nestStr = (depth: number): string => (depth === 0 ? 'i:0;' : `a:1:{i:0;${nestStr(depth - 1)}}`) + + it('深度边界:默认限下嵌套 8 层通过,且形状逐层正确', () => { + const result = phpUnserialize(nestStr(8)) + expect(JSON.stringify(result)).toBe('['.repeat(8) + '0' + ']'.repeat(8)) + }) + + it('深度超限:第 9 层 → 抛 PhpLimitError(kind=depth,区别于 PhpParseError)', () => { + const e = capture(() => phpUnserialize(nestStr(9))) as PhpLimitError + expect(e).toBeInstanceOf(PhpLimitError) + expect(e).not.toBeInstanceOf(PhpParseError) + expect(e.kind).toBe('depth') + }) + + it('自定义 maxDepth:2 层过、3 层抛', () => { + expect(JSON.stringify(phpUnserialize(nestStr(2), { maxDepth: 2 }))).toBe('[[0]]') + const e = capture(() => phpUnserialize(nestStr(3), { maxDepth: 2 })) as PhpLimitError + expect(e).toBeInstanceOf(PhpLimitError) + expect(e.kind).toBe('depth') + }) + + it('元素数超限(预检路径):扁平 a:1001:{} → 抛 PhpLimitError(kind=elements)', () => { + const e = capture(() => phpUnserialize('a:1001:{}')) as PhpLimitError + expect(e).toBeInstanceOf(PhpLimitError) + expect(e).not.toBeInstanceOf(PhpParseError) + expect(e.kind).toBe('elements') + }) + + it('元素数超限(解析中计数路径):值节点计数超自定义上限抛,恰好达限放行', () => { + // 节点数 = 2 键 + 2 值 + 1 数组自身 = 5 + const e = capture(() => phpUnserialize('a:2:{i:0;s:1:"a";i:1;s:1:"b";}', { maxElements: 4 })) as PhpLimitError + expect(e).toBeInstanceOf(PhpLimitError) + expect(e.kind).toBe('elements') + expect(phpUnserialize('a:2:{i:0;s:1:"a";i:1;s:1:"b";}', { maxElements: 5 })).toStrictEqual(['a', 'b']) + }) +})