|
| 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