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