diff --git a/package-lock.json b/package-lock.json index b374df4b..af3f31e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5406,6 +5406,15 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -5476,6 +5485,24 @@ "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", "license": "MIT" }, + "node_modules/bencode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/bencode/-/bencode-4.0.0.tgz", + "integrity": "sha512-AERXw18df0pF3ziGOCyUjqKZBVNH8HV3lBxnx5w0qtgMIk4a1wb9BkcCQbkp9Zstfrn/dzRwl7MmUHHocX3sRQ==", + "license": "MIT", + "dependencies": { + "uint8-util": "^2.2.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/bencodec": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/bencodec/-/bencodec-3.0.1.tgz", + "integrity": "sha512-5Ntc3E7R1vSnBcOddG65L9kObEmZGI0Vool6z/7apwO5Hc9OlziwK0LyxvaTK5Il+nSWNxlVSuh2zJM+TN9O3g==", + "license": "MIT" + }, "node_modules/big.js": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", @@ -17895,6 +17922,15 @@ "integrity": "sha512-w+VZSp8hSZ/xWZfZNMppWNF6iqY+dcMYtG5CpwRDgxi94HIE6ematSdkzHGzVC4SDEaTsG65zrajN+oKoWG6ew==", "license": "MIT" }, + "node_modules/uint8-util": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/uint8-util/-/uint8-util-2.2.5.tgz", + "integrity": "sha512-/QxVQD7CttWpVUKVPz9znO+3Dd4BdTSnFQ7pv/4drVhC9m4BaL2LFHTkJn6EsYoxT79VDq/2Gg8L0H22PrzyMw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/ultron": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ultron/-/ultron-1.1.1.tgz", diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json index 434c8bb6..a4dd111a 100644 --- a/src/core/config/Categories.json +++ b/src/core/config/Categories.json @@ -162,7 +162,9 @@ "Typex", "Lorenz", "Colossus", - "SIGABA" + "SIGABA", + "Bencode Encode", + "Bencode Decode" ] }, { diff --git a/src/core/operations/BencodeDecode.mjs b/src/core/operations/BencodeDecode.mjs new file mode 100644 index 00000000..7b76820f --- /dev/null +++ b/src/core/operations/BencodeDecode.mjs @@ -0,0 +1,204 @@ +/** + * @author jg42526 + * @copyright Crown Copyright 2016 + * @license Apache-2.0 + */ + +import Operation from "../Operation.mjs"; + +/** + * URL Decode operation + */ +class BencodeDecode extends Operation { + + /** + * URL Decode constructor + */ + constructor() { + super(); + + this.name = "Bencode Decode"; + this.module = "Encodings"; + this.description = "Decodes a Bencoded string.

e.g. 7:bencode becomes bencode"; + this.infoURL = "https://en.wikipedia.org/wiki/Bencode"; + this.inputType = "string"; + this.outputType = "string"; + this.args = []; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + if (input) { + const decoder = new BencodeDecoder(input, {stringify: true}).decode(); + return toStringRepresentation(decoder); + } + return ""; + } + +} + +export default BencodeDecode; + +/** + * Returns string representation of object + */ +function toStringRepresentation(value) { + if (typeof value === "string") { + return value; + } + + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + + if (Array.isArray(value) || (value !== null && typeof value === "object")) { + // For arrays and objects, output JSON string + return JSON.stringify(value); + } + + // For other types (undefined, null), handle as you see fit, e.g.: + return String(value); +} + +const FLAG = { + INTEGER: 0x69, // 'i' + STR_DELIMITER: 0x3a, // ':' + LIST: 0x6c, // 'l' + DICTIONARY: 0x64, // 'd' + END: 0x65, // 'e' + MINUS: 0x2d, // '-' + PLUS: 0x2b, // '+' + DOT: 0x2e, // '.' +}; + +/** + * Class for decoding data from the Bencode format. + * Credit to @isolomak: + * https://github.com/isolomak/bencodec + */ +class BencodeDecoder { + /** + * Creates an instance of BencodeDecoder. + * @param {Buffer|string} data - The bencoded data to decode. + * @param {Object} [options={}] - Optional decoding options. + * @param {boolean} [options.stringify=false] - Whether to return strings instead of Buffers. + */ + constructor(data, options = {}) { + if (!data) throw new Error("Nothing to decode"); + this._index = 0; + this._options = options; + this._buffer = typeof data === "string" ? Buffer.from(data) : data; + } + + /** + * Checks if a character code represents a digit (0–9). + * @param {number} char - The character code to check. + * @returns {boolean} - True if the character is a digit. + */ + static _isInteger(char) { + return char >= 0x30 && char <= 0x39; + } + + /** + * Returns the current character code in the buffer. + * @returns {number} - The current character code. + */ + _currentChar() { + return this._buffer[this._index]; + } + + /** + * Returns the next character code in the buffer and advances the index. + * @returns {number} - The next character code. + */ + _next() { + return this._buffer[this._index++]; + } + + /** + * Decodes the bencoded data. + * @returns {*} - The decoded value (string, number, list, or dictionary). + */ + decode() { + const char = this._currentChar(); + if (BencodeDecoder._isInteger(char)) return this._decodeString(); + if (char === FLAG.INTEGER) return this._decodeInteger(); + if (char === FLAG.LIST) return this._decodeList(); + if (char === FLAG.DICTIONARY) return this._decodeDictionary(); + throw new Error("Invalid bencode data"); + } + + /** + * Decodes a bencoded string. + * @returns {Buffer|string} - The decoded string or Buffer. + */ + _decodeString() { + const length = this._decodeInteger(); + const acc = []; + for (let i = 0; i < length; i++) acc.push(this._next()); + const result = Buffer.from(acc); + return this._options.stringify ? result.toString("utf8") : result; + } + + /** + * Decodes a bencoded integer. + * @returns {number} - The decoded integer. + */ + _decodeInteger() { + let sign = 1; + let integer = 0; + + if (this._currentChar() === FLAG.INTEGER) this._index++; + if (this._currentChar() === FLAG.PLUS) this._index++; + if (this._currentChar() === FLAG.MINUS) { + this._index++; + sign = -1; + } + + while (BencodeDecoder._isInteger(this._currentChar()) || this._currentChar() === FLAG.DOT) { + if (this._currentChar() === FLAG.DOT) { + this._index++; // Skip dot (float not supported) + } else { + integer = integer * 10 + (this._next() - 0x30); + } + } + + if (this._currentChar() === FLAG.END) this._index++; + if (this._currentChar() === FLAG.STR_DELIMITER) this._index++; + + return integer * sign; + } + + /** + * Decodes a bencoded list. + * @returns {Array} - The decoded list. + */ + _decodeList() { + const acc = []; + this._next(); // Skip 'l' + while (this._currentChar() !== FLAG.END) { + acc.push(this.decode()); + } + this._next(); // Skip 'e' + return acc; + } + + /** + * Decodes a bencoded dictionary. + * @returns {Object} - The decoded dictionary. + */ + _decodeDictionary() { + const acc = {}; + this._next(); // Skip 'd' + while (this._currentChar() !== FLAG.END) { + const key = this._decodeString(); + acc[key.toString()] = this.decode(); + } + this._next(); // Skip 'e' + return acc; + } +} diff --git a/src/core/operations/BencodeEncode.mjs b/src/core/operations/BencodeEncode.mjs new file mode 100644 index 00000000..7e952ef2 --- /dev/null +++ b/src/core/operations/BencodeEncode.mjs @@ -0,0 +1,190 @@ +/** + * @author jg42526 + * @copyright Crown Copyright 2016 + * @license Apache-2.0 + */ + +import Operation from "../Operation.mjs"; + +/** + * URL Decode operation + */ +class BencodeEncode extends Operation { + + /** + * URLDecode constructor + */ + constructor() { + super(); + + this.name = "Bencode Encode"; + this.module = "Encodings"; + this.description = "Bencodes a string.

e.g. bencode becomes 7:bencode"; + this.infoURL = "https://en.wikipedia.org/wiki/Bencode"; + this.inputType = "string"; + this.outputType = "string"; + this.args = []; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const encoder = new BencodeEncoder({ stringify: true }); + return encoder.encode(parseValue(input)); + } + +} + +export default BencodeEncode; + +/** + * Parses string, returns appropriate data structure + */ +function parseValue(str) { + const trimmed = str.trim(); + try { + // Attempt to parse with JSON.parse + return JSON.parse(trimmed); + } catch (e) { + // If JSON.parse fails, treat input as a plain string (assuming it's unquoted) + return trimmed; + } +} + +const FLAG = { + INTEGER: 0x69, // 'i' + STR_DELIMITER: 0x3a, // ':' + LIST: 0x6c, // 'l' + DICTIONARY: 0x64, // 'd' + END: 0x65, // 'e' +}; + +/** + * BencodeEncoder class for encoding data into bencode format. + * Credit to @isolomak: + * https://github.com/isolomak/bencodec + */ +class BencodeEncoder { + /** + * + */ + constructor(options = {}) { + this._integerIdentifier = Buffer.from([FLAG.INTEGER]); + this._stringDelimiterIdentifier = Buffer.from([FLAG.STR_DELIMITER]); + this._listIdentifier = Buffer.from([FLAG.LIST]); + this._dictionaryIdentifier = Buffer.from([FLAG.DICTIONARY]); + this._endIdentifier = Buffer.from([FLAG.END]); + this._buffer = []; + this._options = options; + } + + /** + * Encodes the given data into bencode format. + * @param {*} data - The data to encode. + * @returns {Buffer|string} - The encoded data as a Buffer or string. + */ + encode(data) { + this._encodeType(data); + const result = Buffer.concat(this._buffer); + return this._options.stringify ? result.toString("utf8") : result; + } + + /** + * Determines the type of data and encodes it accordingly. + * @param {*} data - The data to encode. + */ + _encodeType(data) { + if (Buffer.isBuffer(data)) { + return this._encodeBuffer(data); + } + if (Array.isArray(data)) { + return this._encodeList(data); + } + if (ArrayBuffer.isView(data)) { + return this._encodeBuffer(Buffer.from(data.buffer, data.byteOffset, data.byteLength)); + } + if (data instanceof ArrayBuffer) { + return this._encodeBuffer(Buffer.from(data)); + } + if (typeof data === "boolean") { + return this._encodeInteger(data ? 1 : 0); + } + if (typeof data === "number") { + return this._encodeInteger(data); + } + if (typeof data === "string") { + return this._encodeString(data); + } + if (typeof data === "object") { + return this._encodeDictionary(data); + } + throw new Error(`${typeof data} is unsupported type.`); + } + + /** + * Buffer into bencode format. + * @param {Buffer} data - The buffer to encode. + */ + _encodeBuffer(data) { + this._buffer.push( + Buffer.from(String(data.length)), + this._stringDelimiterIdentifier, + data + ); + } + + /** + * Encodes a string into bencode format. + * @param {string} data - The string to encode. + */ + _encodeString(data) { + this._buffer.push( + Buffer.from(String(Buffer.byteLength(data))), + this._stringDelimiterIdentifier, + Buffer.from(data) + ); + } + + /** + * Encodes an integer into bencode format. + * @param {number} data - The integer to encode. + */ + _encodeInteger(data) { + this._buffer.push( + this._integerIdentifier, + Buffer.from(String(Math.round(data))), + this._endIdentifier + ); + } + + /** + * Encodes a list (array) into bencode format. + * @param {Array} data - The list to encode. + */ + _encodeList(data) { + this._buffer.push(this._listIdentifier); + for (const item of data) { + if (item === null || item === undefined) continue; + this._encodeType(item); + } + this._buffer.push(this._endIdentifier); + } + + /** + * Encodes a dictionary (object) into bencode format. + * @param {Object} data - The dictionary to encode. + */ + _encodeDictionary(data) { + this._buffer.push(this._dictionaryIdentifier); + const keys = Object.keys(data).sort(); + for (const key of keys) { + if (data[key] === null || data[key] === undefined) continue; + this._encodeString(key); + this._encodeType(data[key]); + } + this._buffer.push(this._endIdentifier); + } +} diff --git a/tests/operations/index.mjs b/tests/operations/index.mjs index f147e9e7..3577f0f2 100644 --- a/tests/operations/index.mjs +++ b/tests/operations/index.mjs @@ -26,6 +26,8 @@ import "./tests/Base64.mjs"; import "./tests/Base85.mjs"; import "./tests/Base92.mjs"; import "./tests/BCD.mjs"; +import "./tests/BencodeEncode.mjs"; +import "./tests/BencodeDecode.mjs"; import "./tests/BitwiseOp.mjs"; import "./tests/BLAKE2b.mjs"; import "./tests/BLAKE2s.mjs"; diff --git a/tests/operations/tests/BencodeDecode.mjs b/tests/operations/tests/BencodeDecode.mjs new file mode 100644 index 00000000..c0297d8b --- /dev/null +++ b/tests/operations/tests/BencodeDecode.mjs @@ -0,0 +1,66 @@ +/** + * Bencode Encode tests. + * + * @author jg42526 + * @copyright Crown Copyright 2018 + * @license Apache-2.0 + */ +import TestRegister from "../../lib/TestRegister.mjs"; + +TestRegister.addTests([ + { + name: "Bencode Decode: nothing", + input: "", + expectedOutput: "", + recipeConfig: [ + { + "op": "Bencode Decode", + "args": [] + } + ] + }, + { + name: "Bencode Decode: integer", + input: "i42e", + expectedOutput: "42", + recipeConfig: [ + { + "op": "Bencode Decode", + "args": [] + } + ] + }, + { + name: "Bencode Decode: byte string", + input: "7:bencode", + expectedOutput: "bencode", + recipeConfig: [ + { + "op": "Bencode Decode", + "args": [] + } + ] + }, + { + name: "Bencode Decode: list", + input: "l7:bencodei-20ee", + expectedOutput: `["bencode",-20]`, + recipeConfig: [ + { + "op": "Bencode Decode", + "args": [] + } + ] + }, + { + name: "Bencode Decode: dictionary", + input: "d7:meaningi42e4:wiki7:bencodee", + expectedOutput: `{"meaning":42,"wiki":"bencode"}`, + recipeConfig: [ + { + "op": "Bencode Decode", + "args": [] + } + ] + }, +]); diff --git a/tests/operations/tests/BencodeEncode.mjs b/tests/operations/tests/BencodeEncode.mjs new file mode 100644 index 00000000..51b96826 --- /dev/null +++ b/tests/operations/tests/BencodeEncode.mjs @@ -0,0 +1,72 @@ +/** + * Bencode Encode tests. + * + * @author jg42526 + * @copyright Crown Copyright 2018 + * @license Apache-2.0 + */ +import TestRegister from "../../lib/TestRegister.mjs"; + +TestRegister.addTests([ + { + name: "Bencode Encode: nothing", + input: "", + expectedOutput: "0:", + recipeConfig: [ + { + "op": "Bencode Encode", + "args": [] + } + ] + }, + { + name: "Bencode Encode: integer", + input: "42", + expectedOutput: "i42e", + recipeConfig: [ + { + "op": "Bencode Encode", + "args": [] + } + ] + }, + { + name: "Bencode Encode: byte string", + input: "bencode", + expectedOutput: "7:bencode", + recipeConfig: [ + { + "op": "Bencode Encode", + "args": [] + } + ] + }, + { + name: "Bencode Encode: list", + input: `[ + "bencode", + -20 + ]`, + expectedOutput: "l7:bencodei-20ee", + recipeConfig: [ + { + "op": "Bencode Encode", + "args": [] + } + ] + }, + { + name: "Bencode Encode: dictionary", + input: `{ + "meaning": 42, + "wiki": "bencode" + }`, + expectedOutput: "d7:meaningi42e4:wiki7:bencodee", + recipeConfig: [ + { + "op": "Bencode Encode", + "args": [] + } + ] + }, +]);