Files
cms-website/packages/adapter-wordpress/test/php-unserialize.test.ts
T
浅念 5550952fdb 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>
2026-10-21 08:23:32 +08:00

174 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:"<p>Tab</p>";}}'
// 外层 i:0 连续键 → 密集数组;内层 s: 键 → Map(保留插入序)
expect(phpUnserialize(input)).toStrictEqual([
new Map<string, PhpValue>([
['title', 'SpecsTab'],
['content', '<p>Tab</p>'],
]),
])
})
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<string | number, PhpValue>([
[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'])
})
})