diff --git a/src/core/operations/PHPDeserialize.mjs b/src/core/operations/PHPDeserialize.mjs index 77d18bc2..bd820375 100644 --- a/src/core/operations/PHPDeserialize.mjs +++ b/src/core/operations/PHPDeserialize.mjs @@ -20,7 +20,7 @@ class PHPDeserialize extends Operation { this.name = "PHP Deserialize"; this.module = "Default"; - this.description = "Deserializes PHP serialized data, outputting keyed arrays as JSON.

This function does not support object tags.

Example:
a:2:{s:1:"a";i:10;i:0;a:1:{s:2:"ab";b:1;}}
becomes
{"a": 10,0: {"ab": true}}

Output valid JSON: JSON doesn't support integers as keys, whereas PHP serialization does. Enabling this will cast these integers to strings. This will also escape backslashes."; + this.description = "Deserializes PHP serialized data, outputting keyed arrays as JSON.

Example:
a:2:{s:1:"a";i:10;i:0;a:1:{s:2:"ab";b:1;}}
becomes
{"a": 10,0: {"ab": true}}

Output valid JSON: JSON doesn't support integers as keys, whereas PHP serialization does. Enabling this will cast these integers to strings. This will also escape backslashes."; this.infoURL = "http://www.phpinternalsbook.com/classes_objects/serialization.html"; this.inputType = "string"; this.outputType = "string"; @@ -39,6 +39,8 @@ class PHPDeserialize extends Operation { * @returns {string} */ run(input, args) { + const refStore = []; + const inputPart = input.split(""); /** * Recursive method for deserializing. * @returns {*} @@ -60,7 +62,6 @@ class PHPDeserialize extends Operation { } return result; } - /** * Read characters from the input until `until` is found. * @param until @@ -70,28 +71,57 @@ class PHPDeserialize extends Operation { let result = ""; for (;;) { const char = read(1); - if (char === until) { - break; - } else { - result += char; - } + if (char === until) break; + result += char; } return result; - } /** * Read characters from the input that must be equal to `expect` - * @param expect + * @param expectStr * @returns {string} */ - function expect(expect) { - const result = read(expect.length); - if (result !== expect) { - throw new OperationError("Unexpected input found"); + function expect(expectStr) { + const result = read(expectStr.length); + if (result !== expectStr) { + throw new OperationError(`Expected "${expectStr}", but got "${result}"`); } return result; } + /** + * Records a value by pushing it into the reference store and returns it. + * @param {any} value - The value to be recorded. + * @returns {any} - The recorded value. + */ + function record(value) { + refStore.push(value); + return value; + } + + /** + * Normalizes the key by converting private and protected keys to standard formats. + * @param {string} key - The key to be normalized. + * @returns {string} - The normalized key. + */ + function normalizeKey(key) { + if (typeof key !== "string") return key; + + // Match private: "\0ClassName\0prop" + const privateMatch = key.match(/^\u0000(.+)\u0000(.+)$/); + if (privateMatch) { + const [_, className, prop] = privateMatch; // eslint-disable-line no-unused-vars + return `private:${prop}`; + } + + // Match protected: "\0*\0prop" + const protectedMatch = key.match(/^\u0000\*\u0000(.+)$/); + if (protectedMatch) { + return `protected:${protectedMatch[1]}`; + } + + return key; + } /** * Helper function to handle deserialized arrays. @@ -100,61 +130,113 @@ class PHPDeserialize extends Operation { function handleArray() { const items = parseInt(readUntil(":"), 10) * 2; expect("{"); - const result = []; - let isKey = true; - let lastItem = null; - for (let idx = 0; idx < items; idx++) { - const item = handleInput(); - if (isKey) { - lastItem = item; - isKey = false; - } else { - const numberCheck = lastItem.match(/[0-9]+/); - if (args[0] && numberCheck && numberCheck[0].length === lastItem.length) { - result.push('"' + lastItem + '": ' + item); - } else { - result.push(lastItem + ": " + item); - } - isKey = true; - } + const result = {}; + for (let idx = 0; idx < items; idx += 2) { + const keyInfo = handleInput(); + const valueInfo = handleInput(); + let key = keyInfo.value; + if (keyInfo.keyType === "i") key = parseInt(key, 10); + result[key] = valueInfo.value; } expect("}"); return result; } - const kind = read(1).toLowerCase(); switch (kind) { case "n": expect(";"); - return "null"; - case "i": - case "d": + return record({ value: null, keyType: kind }); + + case "i": { + expect(":"); + const data = readUntil(";"); + return record({ value: parseInt(data, 10), keyType: kind }); + } + + case "d": { + expect(":"); + const data = readUntil(";"); + return record({ value: parseFloat(data), keyType: kind }); + } + case "b": { expect(":"); const data = readUntil(";"); - if (kind === "b") { - return (parseInt(data, 10) !== 0); - } - return data; + return record({ value: data !== 0, keyType: kind }); } case "a": expect(":"); - return "{" + handleArray() + "}"; + return record({ value: handleArray(), keyType: kind }); case "s": { expect(":"); - const length = readUntil(":"); + const lengthRaw = readUntil(":").trim(); + const length = parseInt(lengthRaw, 10); expect("\""); - const value = read(length); - expect('";'); - if (args[0]) { - return '"' + value.replace(/"/g, '\\"') + '"'; // lgtm [js/incomplete-sanitization] - } else { - return '"' + value + '"'; + + // Read until the next quote-semicolon + let str = ""; + while (true) { + const next = read(1); + if (next === '"' && inputPart[0] === ";") { + inputPart.shift(); // Consume the ; + break; + } + str += next; } + + const actualByteLength = new TextEncoder().encode(str).length; + if (actualByteLength !== length) { + // eslint-disable-next-line no-console + console.warn(`Length mismatch: declared ${length}, got ${actualByteLength} — proceeding anyway`); + } + + return record({ value: str, keyType: kind }); + } + + case "o": { + expect(":"); + const classNameLength = parseInt(readUntil(":"), 10); + expect("\""); + const className = read(classNameLength); + expect("\""); + expect(":"); + const propertyCount = parseInt(readUntil(":"), 10); + expect("{"); + + const obj = { + __className: className + }; + + for (let i = 0; i < propertyCount; i++) { + const keyRaw = handleInput(); + const valueRaw = handleInput(); + let key = keyRaw.value; + if (typeof key === "string" && key.startsWith('"') && key.endsWith('"')) { + key = key.slice(1, -1); + } + key = normalizeKey(key); + obj[key] = valueRaw.value; + } + + expect("}"); + return record({ value: obj, keyType: kind }); + } + + case "r": { + expect(":"); + const refIndex = parseInt(readUntil(";"), 10); + if (refIndex >= refStore.length || refIndex < 0) { + throw new OperationError(`Invalid reference index: ${refIndex}`); + } + const refValue = refStore[refIndex]; + if (typeof refValue === "object" && refValue !== null && "value" in refValue && "keyType" in refValue) { + return refValue; + } + return record({ value: refValue, keyType: kind }); } default: @@ -162,10 +244,25 @@ class PHPDeserialize extends Operation { } } - const inputPart = input.split(""); - return handleInput(); - } + /** + * Helper function to make invalid json output (legacy support) + * @returns {String} + */ + function stringifyWithIntegerKeys(obj) { + const entries = Object.entries(obj).map(([key, value]) => { + const jsonKey = Number.isInteger(+key) ? key : JSON.stringify(key); + const jsonValue = JSON.stringify(value); + return `${jsonKey}:${jsonValue}`; + }); + return `{${entries.join(',')}}`; // eslint-disable-line quotes + } + if (args[0]) { + return JSON.stringify(handleInput().value); + } else { + return stringifyWithIntegerKeys(handleInput().value); + } + } } export default PHPDeserialize; diff --git a/tests/operations/tests/PHP.mjs b/tests/operations/tests/PHP.mjs index b9d6a8f0..65085946 100644 --- a/tests/operations/tests/PHP.mjs +++ b/tests/operations/tests/PHP.mjs @@ -46,7 +46,7 @@ TestRegister.addTests([ { name: "PHP Deserialize array (JSON)", input: "a:2:{s:1:\"a\";i:10;i:0;a:1:{s:2:\"ab\";b:1;}}", - expectedOutput: "{\"a\": 10,\"0\": {\"ab\": true}}", + expectedOutput: '{"0":{"ab":true},"a":10}', recipeConfig: [ { op: "PHP Deserialize", @@ -57,7 +57,7 @@ TestRegister.addTests([ { name: "PHP Deserialize array (non-JSON)", input: "a:2:{s:1:\"a\";i:10;i:0;a:1:{s:2:\"ab\";b:1;}}", - expectedOutput: "{\"a\": 10,0: {\"ab\": true}}", + expectedOutput: '{0:{"ab":true},"a":10}', recipeConfig: [ { op: "PHP Deserialize", @@ -65,4 +65,15 @@ TestRegister.addTests([ }, ], }, + { + name: "PHP Deserialize array with object and reference (JSON)", + input: 'a:1:{s:6:"navbar";O:18:"APP\\View\\Menu\\Item":3:{s:4:"name";s:16:"Secondary Navbar";s:8:"children";a:1:{s:9:"View Cart";O:18:"APP\\View\\Menu\\Item":2:{s:4:"name";s:9:"View Cart";s:6:"parent";r:2;}}s:6:"parent";N;}}', // eslint-disable-line no-useless-escape + expectedOutput: `{"navbar":{"__className":"APP\\\\View\\\\Menu\\\\Item","name":"Secondary Navbar","children":{"View Cart":{"__className":"APP\\\\View\\\\Menu\\\\Item","name":"View Cart","parent":"Secondary Navbar"}},"parent":null}}`, + recipeConfig: [ + { + op: "PHP Deserialize", + args: [true], + }, + ], + } ]);