feat(adapter-wp): PHP serialize 受限解析器
只支持 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string | number, PhpValue>
|
||||
| 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<string | number, PhpValue> | 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)
|
||||
}
|
||||
Reference in New Issue
Block a user