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],
+ },
+ ],
+ }
]);