diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json index d3e7648a..767bfea0 100644 --- a/src/core/config/Categories.json +++ b/src/core/config/Categories.json @@ -417,6 +417,7 @@ "LZMA Compress", "LZ4 Decompress", "LZ4 Compress", + "LZNT1 Compress", "LZNT1 Decompress" ] }, diff --git a/src/core/lib/LZNT1.mjs b/src/core/lib/LZNT1.mjs index 9a1c7fab..da48a2d2 100644 --- a/src/core/lib/LZNT1.mjs +++ b/src/core/lib/LZNT1.mjs @@ -1,6 +1,6 @@ /** * - * LZNT1 Decompress. + * LZNT1 compression and decompression. * * @author 0xThiebaut [thiebaut.dev] * @copyright Crown Copyright 2023 @@ -13,6 +13,8 @@ import Utils from "../Utils.mjs"; import OperationError from "../errors/OperationError.mjs"; const COMPRESSED_MASK = 1 << 15, + SIGNATURE_MASK = 3 << 12, + BLOCK_SIZE = 4096, SIZE_MASK = (1 << 12) - 1; /** @@ -28,6 +30,116 @@ function getDisplacement(offset) { return result; } +/** + * @param {byteArray} output + * @param {number} header + */ +function appendBlockHeader(output, header) { + output.push(...Utils.intToByteArray(header, 2, "little")); +} + +/** + * @param {byteArray} block + * @param {number} pos + * @returns {{offset: number, length: number, lengthBits: number}|null} + */ +function findBestMatch(block, pos) { + const displacement = getDisplacement(pos - 1), + lengthBits = 12 - displacement, + maxOffset = Math.min(pos, 1 << (4 + displacement)), + maxLength = Math.min(block.length - pos, (0xFFF >> displacement) + 3); + + let bestOffset = 0, + bestLength = 0; + + for (let offset = 1; offset <= maxOffset; offset++) { + let length = 0; + while ( + length < maxLength && + block[pos + length] === block[pos - offset + length] + ) { + length++; + } + + if (length > bestLength) { + bestOffset = offset; + bestLength = length; + if (bestLength === maxLength) break; + } + } + + if (bestLength < 3) return null; + return { + offset: bestOffset, + length: bestLength, + lengthBits, + }; +} + +/** + * @param {byteArray} block + * @returns {byteArray} + */ +function compressBlock(block) { + const compressed = []; + let pos = 0; + + while (pos < block.length) { + const headerIndex = compressed.length; + let header = 0, + bit = 1, + tokens = 0; + + compressed.push(0); + + while (tokens < 8 && pos < block.length) { + const match = findBestMatch(block, pos); + + if (match) { + const pointer = ((match.offset - 1) << match.lengthBits) | (match.length - 3); + compressed.push(...Utils.intToByteArray(pointer, 2, "little")); + header |= bit; + pos += match.length; + } else { + compressed.push(block[pos++]); + } + + bit <<= 1; + tokens++; + } + + compressed[headerIndex] = header; + } + + return compressed; +} + +/** + * @param {byteArray} uncompressed + * @returns {byteArray} + */ +export function compress(uncompressed) { + const compressed = []; + + for (let offset = 0; offset < uncompressed.length; offset += BLOCK_SIZE) { + const block = uncompressed.slice(offset, offset + BLOCK_SIZE), + compressedBlock = compressBlock(block); + + if (block.length === 1 || compressedBlock.length < block.length) { + appendBlockHeader( + compressed, + SIGNATURE_MASK | COMPRESSED_MASK | (compressedBlock.length - 1) + ); + compressed.push(...compressedBlock); + } else { + appendBlockHeader(compressed, SIGNATURE_MASK | (block.length - 1)); + compressed.push(...block); + } + } + + return compressed; +} + /** * @param {byteArray} compressed * @returns {byteArray} diff --git a/src/core/operations/LZNT1Compress.mjs b/src/core/operations/LZNT1Compress.mjs new file mode 100644 index 00000000..b2a3805f --- /dev/null +++ b/src/core/operations/LZNT1Compress.mjs @@ -0,0 +1,41 @@ +/** + * @author skyswordw + * @copyright Crown Copyright 2026 + * @license Apache-2.0 + */ + +import Operation from "../Operation.mjs"; +import {compress} from "../lib/LZNT1.mjs"; + +/** + * LZNT1 Compress operation + */ +class LZNT1Compress extends Operation { + + /** + * LZNT1 Compress constructor + */ + constructor() { + super(); + + this.name = "LZNT1 Compress"; + this.module = "Compression"; + this.description = "Compresses data using the LZNT1 algorithm.

Similar to the Windows API RtlCompressBuffer."; + this.infoURL = "https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-xca/5655f4a3-6ba4-489b-959f-e1f407c52f15"; + this.inputType = "byteArray"; + this.outputType = "byteArray"; + this.args = []; + } + + /** + * @param {byteArray} input + * @param {Object[]} args + * @returns {byteArray} + */ + run(input, args) { + return compress(input); + } + +} + +export default LZNT1Compress; diff --git a/tests/node/tests/operations.mjs b/tests/node/tests/operations.mjs index 6cf85718..6edb2e94 100644 --- a/tests/node/tests/operations.mjs +++ b/tests/node/tests/operations.mjs @@ -681,6 +681,10 @@ WWFkYSBZYWRh\r assert.strictEqual(chef.LZNT1Decompress("\x1a\xb0\x00compress\x00edtestda\x04ta\x07\x88alot").toString(), "compressedtestdatacompressedalot"); }), + it("LZNT1 Compress", () => { + assert.strictEqual(chef.LZNT1Decompress(chef.LZNT1Compress("compressedtestdatacompressedalot")).toString(), "compressedtestdatacompressedalot"); + }), + it("MD6", () => { assert.strictEqual(chef.MD6("Head Over Heels", {key: "arty"}).toString(), "d8f7fe4931fbaa37316f76283d5f615f50ddd54afdc794b61da522556aee99ad"); }), @@ -1178,4 +1182,3 @@ ExifImageHeight: 57`); ]); - diff --git a/tests/operations/tests/LZNT1Decompress.mjs b/tests/operations/tests/LZNT1Decompress.mjs index dcfad01a..8cab2f65 100644 --- a/tests/operations/tests/LZNT1Decompress.mjs +++ b/tests/operations/tests/LZNT1Decompress.mjs @@ -1,5 +1,5 @@ /** - * LZNT1 Decompress tests. + * LZNT1 tests. * * @author 0xThiebaut [thiebaut.dev] * @copyright Crown Copyright 2023 @@ -8,6 +8,21 @@ import TestRegister from "../../lib/TestRegister.mjs"; TestRegister.addTests([ + { + name: "LZNT1 Compress: repeated input", + input: "compressedtestdatacompressedalot", + expectedOutput: "1a b0 00 63 6f 6d 70 72 65 73 73 00 65 64 74 65 73 74 64 61 04 74 61 07 88 61 6c 6f 74", + recipeConfig: [ + { + op: "LZNT1 Compress", + args: [] + }, + { + op: "To Hex", + args: ["Space", 0] + } + ], + }, { name: "LZNT1 Decompress", input: "\x1a\xb0\x00compress\x00edtestda\x04ta\x07\x88alot", @@ -18,5 +33,66 @@ TestRegister.addTests([ args: [] } ], + }, + { + name: "LZNT1 Compress: incompressible input", + input: "abcdefghijklmnopqrstuvwxyz", + expectedOutput: "19 30 61 62 63 64 65 66 67 68 69 6a 6b 6c 6d 6e 6f 70 71 72 73 74 75 76 77 78 79 7a", + recipeConfig: [ + { + op: "LZNT1 Compress", + args: [] + }, + { + op: "To Hex", + args: ["Space", 0] + } + ], + }, + { + name: "LZNT1 Compress/Decompress: binary", + input: "00 01 02 03 00 01 02 03 00 01 02 03 ff ff ff ff 00", + expectedOutput: "00 01 02 03 00 01 02 03 00 01 02 03 ff ff ff ff 00", + recipeConfig: [ + { + op: "From Hex", + args: ["Space"] + }, + { + op: "LZNT1 Compress", + args: [] + }, + { + op: "LZNT1 Decompress", + args: [] + }, + { + op: "To Hex", + args: ["Space", 0] + } + ], + }, + { + name: "LZNT1 Compress/Decompress: single byte", + input: "41", + expectedOutput: "41", + recipeConfig: [ + { + op: "From Hex", + args: ["None"] + }, + { + op: "LZNT1 Compress", + args: [] + }, + { + op: "LZNT1 Decompress", + args: [] + }, + { + op: "To Hex", + args: ["None", 0] + } + ], } ]);