import { describe, expect, it } from 'vitest'
import {
phpUnserialize,
phpValueToJson,
PhpError,
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'])
})
})
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 字符封顶 + 截断/窗口省略号
})
})
// Task 2 审查移交补测(Plan 2 Task 4 落实):四例边界/形状/性能钉死
describe('Task 2 审查移交补测:数组键类型、NUL、数字串键形状、1MB 性能烟雾', () => {
it('d: 作数组键 → 拒绝(数组键只认 i:/s:,浮点键是 PHP 序列化产物中不存在的形状)', () => {
const e = capture(() => phpUnserialize('a:1:{d:1.5;i:0;}')) as PhpParseError
expect(e).toBeInstanceOf(PhpParseError)
expect(e.message).toContain('数组键')
})
it('s: 内容含 NUL(\\u0000)原样往返,不截断不报错(单指针扫描无 C 式终止符语义)', () => {
expect(phpUnserialize('s:3:"a\0b";')).toBe('a\0b')
// NUL 作键亦不炸:Map 保留该键
expect(phpUnserialize('a:1:{s:3:"a\0b";i:1;}')).toStrictEqual(new Map([['a\0b', 1]]))
})
it('数字串键 s:1:"0" → Map(密集数组判定用严格 ===,字符串键永不伪装整数键 0)', () => {
expect(phpUnserialize('a:1:{s:1:"0";i:5;}')).toStrictEqual(new Map([['0', 5]]))
expect(JSON.stringify(phpValueToJson(phpUnserialize('a:1:{s:1:"0";i:5;}')))).toBe('{"0":5}')
})
it('1MB 输入性能烟雾:<50ms(防 O(n²) 回归——单指针扫描应线性)', () => {
const parts: string[] = []
for (let i = 0; i < 400; i++) parts.push(`i:${i};s:2500:"${'x'.repeat(2500)}";`)
const input = `a:400:{${parts.join('')}}`
expect(input.length).toBeGreaterThan(1_000_000) // 确证规模 ≥1MB(纯 ASCII:码元数=字节数)
// 元素数 = 400 键 + 400 值 + 1 数组自身 = 801 ≤ 默认 phpMaxElements=1000(不经限额捷径)
const t0 = performance.now()
const value = phpUnserialize(input)
const elapsed = performance.now() - t0
expect(Array.isArray(value)).toBe(true)
expect(value).toHaveLength(400)
expect(elapsed).toBeLessThan(50)
})
})
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)
})
})