diff --git a/src/core/operations/AMFDecode.mjs b/src/core/operations/AMFDecode.mjs index 50a0d551f9..1b125c6e58 100644 --- a/src/core/operations/AMFDecode.mjs +++ b/src/core/operations/AMFDecode.mjs @@ -5,6 +5,7 @@ */ import Operation from "../Operation.mjs"; +import OperationError from "../errors/OperationError.mjs"; import "reflect-metadata"; // Required as a shim for the amf library import { AMF0, AMF3 } from "@astronautlabs/amf"; @@ -44,7 +45,16 @@ class AMFDecode extends Operation { const [format] = args; const handler = format === "AMF0" ? AMF0 : AMF3; const encoded = new Uint8Array(input); - return handler.Value.deserialize(encoded); + + if (encoded.length === 0) { + throw new OperationError(`Could not decode ${format} data: input is empty.`); + } + + try { + return handler.Value.deserialize(encoded); + } catch { + throw new OperationError(`Could not decode ${format} data. The input may be invalid or incomplete.`); + } } } diff --git a/src/core/operations/AMFEncode.mjs b/src/core/operations/AMFEncode.mjs index c21ba7dc71..bb396aba2d 100644 --- a/src/core/operations/AMFEncode.mjs +++ b/src/core/operations/AMFEncode.mjs @@ -5,8 +5,97 @@ */ import Operation from "../Operation.mjs"; +import Utils from "../Utils.mjs"; import "reflect-metadata"; // Required as a shim for the amf library -import { AMF0, AMF3 } from "@astronautlabs/amf"; +import { AMF0 } from "@astronautlabs/amf"; + +const AMF3_MARKER = { + Null: 0x01, + False: 0x02, + True: 0x03, + Double: 0x05, + String: 0x06, + Array: 0x09, + Object: 0x0a +}; + +/** + * @param {number} value + * @returns {number[]} + */ +function encodeU29(value) { + if (value < 0x80) { + return [value]; + } + if (value < 0x4000) { + return [ + ((value >> 7) & 0x7f) | 0x80, + value & 0x7f + ]; + } + if (value < 0x200000) { + return [ + ((value >> 14) & 0x7f) | 0x80, + ((value >> 7) & 0x7f) | 0x80, + value & 0x7f + ]; + } + return [ + ((value >> 22) & 0x7f) | 0x80, + ((value >> 15) & 0x7f) | 0x80, + ((value >> 8) & 0x7f) | 0x80, + value & 0xff + ]; +} + +/** + * @param {string} value + * @returns {number[]} + */ +function encodeAMF3Utf8(value) { + const bytes = Utils.strToUtf8ByteArray(value); + return encodeU29((bytes.length << 1) | 1).concat(bytes); +} + +/** + * @param {number} value + * @returns {number[]} + */ +function encodeAMF3Double(value) { + const bytes = new Uint8Array(8); + new DataView(bytes.buffer).setFloat64(0, value, false); + return [AMF3_MARKER.Double].concat(Array.from(bytes)); +} + +/** + * @param {JSON} value + * @returns {number[]} + */ +function encodeAMF3Value(value) { + if (value === null) return [AMF3_MARKER.Null]; + if (value === false) return [AMF3_MARKER.False]; + if (value === true) return [AMF3_MARKER.True]; + if (typeof value === "number") return encodeAMF3Double(value); + if (typeof value === "string") return [AMF3_MARKER.String].concat(encodeAMF3Utf8(value)); + + if (Array.isArray(value)) { + return [ + AMF3_MARKER.Array, + ...encodeU29((value.length << 1) | 1), + 0x01, + ...value.flatMap(encodeAMF3Value) + ]; + } + + const keys = Object.keys(value); + return [ + AMF3_MARKER.Object, + ...encodeU29((keys.length << 4) | 0x03), + 0x01, + ...keys.flatMap(encodeAMF3Utf8), + ...keys.flatMap(key => encodeAMF3Value(value[key])) + ]; +} /** * AMF Encode operation @@ -42,8 +131,9 @@ class AMFEncode extends Operation { */ run(input, args) { const [format] = args; - const handler = format === "AMF0" ? AMF0 : AMF3; - const output = handler.Value.any(input).serialize(); + const output = format === "AMF0" ? + AMF0.Value.any(input).serialize() : + Uint8Array.from(encodeAMF3Value(input)); return output.buffer; } diff --git a/tests/operations/tests/AMF.mjs b/tests/operations/tests/AMF.mjs new file mode 100644 index 0000000000..394aa62984 --- /dev/null +++ b/tests/operations/tests/AMF.mjs @@ -0,0 +1,159 @@ +/** + * AMF tests. + * + * @copyright Crown Copyright 2026 + * @license Apache-2.0 + */ +import TestRegister from "../../lib/TestRegister.mjs"; + +TestRegister.addTests([ + { + name: "AMF3 Encode: object string", + input: "{\"a\": \"test\"}", + expectedOutput: "0a13010361060974657374", + recipeConfig: [ + { + op: "AMF Encode", + args: ["AMF3"] + }, + { + op: "To Hex", + args: ["None", 0] + } + ], + }, + { + name: "AMF3 Encode: object boolean true", + input: "{\"a\": true}", + expectedOutput: "0a1301036103", + recipeConfig: [ + { + op: "AMF Encode", + args: ["AMF3"] + }, + { + op: "To Hex", + args: ["None", 0] + } + ], + }, + { + name: "AMF3 Encode: object boolean false", + input: "{\"a\": false}", + expectedOutput: "0a1301036102", + recipeConfig: [ + { + op: "AMF Encode", + args: ["AMF3"] + }, + { + op: "To Hex", + args: ["None", 0] + } + ], + }, + { + name: "AMF3 Encode: object null", + input: "{\"a\": null}", + expectedOutput: "0a1301036101", + recipeConfig: [ + { + op: "AMF Encode", + args: ["AMF3"] + }, + { + op: "To Hex", + args: ["None", 0] + } + ], + }, + { + name: "AMF3 Encode: object array", + input: "{\"a\": []}", + expectedOutput: "0a13010361090101", + recipeConfig: [ + { + op: "AMF Encode", + args: ["AMF3"] + }, + { + op: "To Hex", + args: ["None", 0] + } + ], + }, + { + name: "AMF3 Encode: object nested JSON values", + input: "{\"a\": [true, false, null, \"x\", 1]}", + expectedOutput: "0a13010361090b01030201060378053ff0000000000000", + recipeConfig: [ + { + op: "AMF Encode", + args: ["AMF3"] + }, + { + op: "To Hex", + args: ["None", 0] + } + ], + }, + { + name: "AMF3 Encode/Decode: object string", + input: "{\"a\": \"test\"}", + expectedMatch: /"\$value": "a"[\s\S]*"\$value": "test"/, + recipeConfig: [ + { + op: "AMF Encode", + args: ["AMF3"] + }, + { + op: "AMF Decode", + args: ["AMF3"] + } + ], + }, + { + name: "AMF3 Decode: empty input error", + input: "", + expectedOutput: "Could not decode AMF3 data: input is empty.", + recipeConfig: [ + { + op: "AMF Decode", + args: ["AMF3"] + } + ], + }, + { + name: "AMF3 Decode: newline input error", + input: "\n", + expectedOutput: "Could not decode AMF3 data. The input may be invalid or incomplete.", + recipeConfig: [ + { + op: "AMF Decode", + args: ["AMF3"] + } + ], + }, + { + name: "AMF3 Decode: truncated object input error", + input: "\x0a\x13\x01\x03a\x05\x40\x08", + expectedOutput: "Could not decode AMF3 data. The input may be invalid or incomplete.", + recipeConfig: [ + { + op: "AMF Decode", + args: ["AMF3"] + } + ], + }, + { + name: "AMF3 Decode: truncated array input error", + input: "\x09\x13\x01\x03a\x05\x40\x08", + expectedOutput: "Could not decode AMF3 data. The input may be invalid or incomplete.", + recipeConfig: [ + { + op: "AMF Decode", + args: ["AMF3"] + } + ], + }, +]);