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

226 lines
9.1 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,
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:"<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'])
})
})
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)
})
})