Skip to content

Commit 86bb6fa

Browse files
nadimtuhinclaude
andauthored
fix: correct PHP serializer/unserializer bugs (closes #8) (#19)
- Add O: (PHP object) support to unserializeValue; previously threw 'Unsupported or invalid serialized format' for any O: input - Fix serializeValue to emit O: format when input object has __class - Handle PHP special float values: d:INF;, d:-INF;, d:NAN; (parseFloat('INF') returns NaN in JS — now handled explicitly) - Fix empty array deserialization: a:0:{} now returns [] not {} (previous code had 'isSequentialArray && length > 0' guard) - Remove incorrect input.trim() in unserializeValue — trimming shifts byte positions, breaking UTF-8 s: string length parsing - Add proper UTF-8 byte-aware s: string decoder (Uint8Array path) so multi-byte characters are measured and parsed by byte length - Add PhpSerializer.test.mjs (27 assertions, runs with plain node) Co-authored-by: Claude <claude@anthropic.com>
1 parent 475682a commit 86bb6fa

2 files changed

Lines changed: 281 additions & 39 deletions

File tree

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/**
2+
* Standalone logic tests for PHP serializer/unserializer (issue #8).
3+
* Run: node --experimental-vm-modules src/components/PhpSerializer.test.mjs
4+
*/
5+
import { TextEncoder, TextDecoder } from 'util';
6+
7+
function serializeValue(value) {
8+
if (value === null || value === undefined) return 'N;';
9+
if (typeof value === 'boolean') return `b:${value ? '1' : '0'};`;
10+
if (typeof value === 'number') {
11+
if (!isFinite(value)) {
12+
if (isNaN(value)) return 'd:NAN;';
13+
return value > 0 ? 'd:INF;' : 'd:-INF;';
14+
}
15+
if (Number.isInteger(value)) return `i:${value};`;
16+
return `d:${value};`;
17+
}
18+
if (typeof value === 'string') {
19+
const byteLength = new TextEncoder().encode(value).length;
20+
return `s:${byteLength}:"${value}";`;
21+
}
22+
if (Array.isArray(value)) {
23+
const elements = value.map((v, i) => `${serializeValue(i)}${serializeValue(v)}`).join('');
24+
return `a:${value.length}:{${elements}}`;
25+
}
26+
if (typeof value === 'object') {
27+
const { __class, ...props } = value;
28+
if (__class && typeof __class === 'string') {
29+
const entries = Object.entries(props);
30+
const elems = entries.map(([k, v]) => `${serializeValue(k)}${serializeValue(v)}`).join('');
31+
const byteLen = new TextEncoder().encode(__class).length;
32+
return `O:${byteLen}:"${__class}":${entries.length}:{${elems}}`;
33+
}
34+
const entries = Object.entries(value);
35+
const elems = entries.map(([k, v]) => `${serializeValue(k)}${serializeValue(v)}`).join('');
36+
return `a:${entries.length}:{${elems}}`;
37+
}
38+
throw new Error(`Unsupported type: ${typeof value}`);
39+
}
40+
41+
function unserializeValue(input) {
42+
if (input.startsWith('N;')) return { value: null, rest: input.slice(2) };
43+
if (input.startsWith('b:')) {
44+
if (input.length < 4) throw new Error('Invalid boolean');
45+
return { value: input[2] === '1', rest: input.slice(4) };
46+
}
47+
if (input.startsWith('i:')) {
48+
const end = input.indexOf(';');
49+
if (end === -1) throw new Error('Invalid integer');
50+
return { value: parseInt(input.slice(2, end), 10), rest: input.slice(end + 1) };
51+
}
52+
if (input.startsWith('d:')) {
53+
const end = input.indexOf(';');
54+
if (end === -1) throw new Error('Invalid double');
55+
const n = input.slice(2, end);
56+
if (n === 'INF') return { value: Infinity, rest: input.slice(end + 1) };
57+
if (n === '-INF') return { value: -Infinity, rest: input.slice(end + 1) };
58+
if (n === 'NAN') return { value: NaN, rest: input.slice(end + 1) };
59+
return { value: parseFloat(n), rest: input.slice(end + 1) };
60+
}
61+
if (input.startsWith('s:')) {
62+
const colonPos = input.indexOf(':', 2);
63+
if (colonPos === -1) throw new Error('Invalid string');
64+
const byteLength = parseInt(input.slice(2, colonPos), 10);
65+
if (input[colonPos + 1] !== '"') throw new Error('String must start with quote');
66+
const startPos = colonPos + 2;
67+
const bytes = new Uint8Array(byteLength);
68+
let byteIdx = 0, charIdx = startPos;
69+
while (byteIdx < byteLength && charIdx < input.length) {
70+
const code = input.codePointAt(charIdx);
71+
if (code < 0x80) { bytes[byteIdx++] = code; charIdx++; }
72+
else if (code < 0x800) { bytes[byteIdx++] = 0xc0|(code>>6); bytes[byteIdx++] = 0x80|(code&0x3f); charIdx++; }
73+
else if (code < 0x10000) { bytes[byteIdx++] = 0xe0|(code>>12); bytes[byteIdx++] = 0x80|((code>>6)&0x3f); bytes[byteIdx++] = 0x80|(code&0x3f); charIdx++; }
74+
else { bytes[byteIdx++] = 0xf0|(code>>18); bytes[byteIdx++] = 0x80|((code>>12)&0x3f); bytes[byteIdx++] = 0x80|((code>>6)&0x3f); bytes[byteIdx++] = 0x80|(code&0x3f); charIdx += 2; }
75+
}
76+
const value = new TextDecoder().decode(bytes);
77+
if (input[charIdx] !== '"' || input[charIdx+1] !== ';') throw new Error('String end mismatch');
78+
return { value, rest: input.slice(charIdx + 2) };
79+
}
80+
if (input.startsWith('a:')) {
81+
const colonPos = input.indexOf(':', 2);
82+
const length = parseInt(input.slice(2, colonPos), 10);
83+
let rest = input.slice(colonPos + 1).slice(1); // skip {
84+
const result = {};
85+
let seq = true;
86+
for (let i = 0; i < length; i++) {
87+
const kr = unserializeValue(rest);
88+
const vr = unserializeValue(kr.rest);
89+
rest = vr.rest;
90+
result[kr.value] = vr.value;
91+
if (kr.value !== i) seq = false;
92+
}
93+
rest = rest.slice(1); // skip }
94+
if (length === 0 || seq) return { value: Array.from({ length }, (_, i) => result[i]), rest };
95+
return { value: result, rest };
96+
}
97+
if (input.startsWith('O:')) {
98+
const firstColon = input.indexOf(':', 2);
99+
const qs = input.indexOf('"', firstColon);
100+
const qe = input.indexOf('"', qs + 1);
101+
const className = input.slice(qs + 1, qe);
102+
const afterClass = input.slice(qe + 1);
103+
const pce = afterClass.indexOf(':', 1);
104+
const propCount = parseInt(afterClass.slice(1, pce), 10);
105+
let rest = afterClass.slice(pce + 1).slice(1); // skip {
106+
const result = { __class: className };
107+
for (let i = 0; i < propCount; i++) {
108+
const kr = unserializeValue(rest);
109+
const vr = unserializeValue(kr.rest);
110+
rest = vr.rest;
111+
result[String(kr.value)] = vr.value;
112+
}
113+
rest = rest.slice(1); // skip }
114+
return { value: result, rest };
115+
}
116+
throw new Error(`Unsupported: ${JSON.stringify(input.slice(0, 20))}`);
117+
}
118+
119+
let pass = 0, fail = 0;
120+
function eq(a, b, label) {
121+
const sa = JSON.stringify(a), sb = JSON.stringify(b);
122+
if (sa === sb) { console.log(` \u2713 ${label}`); pass++; }
123+
else { console.error(` \u2717 ${label}\n got: ${sa}\n expected: ${sb}`); fail++; }
124+
}
125+
126+
console.log('=== serialize ===');
127+
eq(serializeValue(null), 'N;', 'null');
128+
eq(serializeValue(true), 'b:1;', 'bool true');
129+
eq(serializeValue(false), 'b:0;', 'bool false');
130+
eq(serializeValue(42), 'i:42;', 'int 42');
131+
eq(serializeValue(-7), 'i:-7;', 'int -7');
132+
eq(serializeValue(3.14), 'd:3.14;', 'float');
133+
eq(serializeValue(Infinity), 'd:INF;', 'INF');
134+
eq(serializeValue(-Infinity), 'd:-INF;', '-INF');
135+
eq(serializeValue(NaN), 'd:NAN;', 'NaN');
136+
eq(serializeValue('hello'), 's:5:"hello";', 'ascii string');
137+
eq(serializeValue([1, 2]), 'a:2:{i:0;i:1;i:1;i:2;}', 'array');
138+
eq(serializeValue([]), 'a:0:{}', 'empty array');
139+
eq(serializeValue({ a: 1 }), 'a:1:{s:1:"a";i:1;}', 'assoc');
140+
eq(
141+
serializeValue({ __class: 'MyClass', prop: 'val' }),
142+
'O:7:"MyClass":1:{s:4:"prop";s:3:"val";}',
143+
'PHP object'
144+
);
145+
146+
console.log('\n=== unserialize ===');
147+
eq(unserializeValue('N;').value, null, 'null');
148+
eq(unserializeValue('b:1;').value, true, 'bool true');
149+
eq(unserializeValue('b:0;').value, false, 'bool false');
150+
eq(unserializeValue('i:42;').value, 42, 'int');
151+
eq(unserializeValue('d:3.14;').value, 3.14, 'double');
152+
eq(unserializeValue('d:INF;').value, Infinity, 'INF');
153+
eq(unserializeValue('d:-INF;').value, -Infinity, '-INF');
154+
const nanVal = unserializeValue('d:NAN;').value;
155+
(isNaN(nanVal) ? (console.log(' \u2713 NAN'), pass++) : (console.error(' \u2717 NAN'), fail++));
156+
eq(unserializeValue('s:5:"hello";').value, 'hello', 'string');
157+
eq(unserializeValue('a:0:{}').value, [], 'empty array');
158+
eq(unserializeValue('a:2:{i:0;i:10;i:1;i:20;}').value, [10, 20], 'seq array');
159+
eq(
160+
unserializeValue('a:2:{s:3:"foo";s:3:"bar";s:3:"baz";i:1;}').value,
161+
{ foo: 'bar', baz: 1 },
162+
'assoc array'
163+
);
164+
eq(
165+
unserializeValue('O:8:"stdClass":1:{s:4:"prop";i:1;}').value,
166+
{ __class: 'stdClass', prop: 1 },
167+
'PHP object O:'
168+
);
169+
170+
console.log(`\n${pass} passed, ${fail} failed`);
171+
if (fail > 0) process.exit(1);

0 commit comments

Comments
 (0)