diff --git a/package-lock.json b/package-lock.json index 12be0199..13f80c2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@alexaltea/capstone-js": "^3.0.5", "@astronautlabs/amf": "^0.0.6", "@blu3r4y/lzma": "^2.3.3", + "@bokuweb/zstd-wasm": "^0.0.27", "@noble/hashes": "2.2.0", "@wavesenterprise/crypto-gost-js": "^2.1.0-RC1", "@xmldom/xmldom": "^0.8.13", @@ -1847,6 +1848,12 @@ "lzma.js": "bin/lzma.js" } }, + "node_modules/@bokuweb/zstd-wasm": { + "version": "0.0.27", + "resolved": "https://registry.npmjs.org/@bokuweb/zstd-wasm/-/zstd-wasm-0.0.27.tgz", + "integrity": "sha512-GDm2uOTK3ESjnYmSeLQifJnBsRCWajKLvN32D2ZcQaaCIJI/Hse9s74f7APXjHit95S10UImsRGkTsbwHmrtmg==", + "license": "MIT" + }, "node_modules/@codemirror/commands": { "version": "6.10.3", "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz", diff --git a/package.json b/package.json index e8c648cb..bbe7789e 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,7 @@ "@astronautlabs/amf": "^0.0.6", "@blu3r4y/lzma": "^2.3.3", "@noble/hashes": "2.2.0", + "@bokuweb/zstd-wasm": "^0.0.27", "@wavesenterprise/crypto-gost-js": "^2.1.0-RC1", "@xmldom/xmldom": "^0.8.13", "argon2-browser": "^1.18.0", diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json index d3e7648a..37b7639e 100644 --- a/src/core/config/Categories.json +++ b/src/core/config/Categories.json @@ -417,7 +417,9 @@ "LZMA Compress", "LZ4 Decompress", "LZ4 Compress", - "LZNT1 Decompress" + "LZNT1 Decompress", + "Zstd Compress", + "Zstd Decompress" ] }, { diff --git a/src/core/lib/FileSignatures.mjs b/src/core/lib/FileSignatures.mjs index 22132823..75bb33b0 100644 --- a/src/core/lib/FileSignatures.mjs +++ b/src/core/lib/FileSignatures.mjs @@ -1533,6 +1533,32 @@ export const FILE_SIGNATURES = { }, extractor: extractZlib }, + { + name: "Zstandard (zstd)", + extension: "zst", + mime: "application/zstd", + description: "", + signature: { + 0: 0x28, + 1: 0xb5, + 2: 0x2f, + 3: 0xfd + }, + extractor: null + }, + { + name: "Zstandard skippable frame (zstd)", + extension: "zst", + mime: "application/zstd", + description: "", + signature: { + 0: [0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a, 0x5b, 0x5c, 0x5d, 0x5e, 0x5f], + 1: 0x2a, + 2: 0x4d, + 3: 0x18 + }, + extractor: null + }, { name: "xz compression", extension: "xz", diff --git a/src/core/lib/Zstd.mjs b/src/core/lib/Zstd.mjs new file mode 100644 index 00000000..a8dc279e --- /dev/null +++ b/src/core/lib/Zstd.mjs @@ -0,0 +1,33 @@ +/** + * Zstd shared initialisation. + * + * Both ZstdCompress and ZstdDecompress import from here so that + * WebAssembly.instantiate is called exactly once, regardless of how many + * operations use it or in what order they run. + * + * @author Leon Zandman [leon@wirwar.com] + * @copyright Crown Copyright 2026 + * @license Apache-2.0 + */ + +import { init, compress, decompress } from "@bokuweb/zstd-wasm"; + +let initPromise = null; + +/** + * Returns a promise that resolves once the Zstd WASM module is ready. + * Safe to call multiple times — the module is only instantiated once. + * + * @returns {Promise} + */ +export function zstdInit() { + if (!initPromise) { + const wasmUrl = typeof self !== "undefined" && self.docURL + ? `${self.docURL}/assets/zstd.wasm` + : undefined; + initPromise = init(wasmUrl); + } + return initPromise; +} + +export { compress, decompress }; diff --git a/src/core/operations/ZstdCompress.mjs b/src/core/operations/ZstdCompress.mjs new file mode 100644 index 00000000..e10cd02d --- /dev/null +++ b/src/core/operations/ZstdCompress.mjs @@ -0,0 +1,64 @@ +/** + * @author Leon Zandman [leon@wirwar.com] + * @copyright Crown Copyright 2026 + * @license Apache-2.0 + */ + +import Operation from "../Operation.mjs"; +import OperationError from "../errors/OperationError.mjs"; +import { isWorkerEnvironment } from "../Utils.mjs"; +import { zstdInit, compress } from "../lib/Zstd.mjs"; + +/** + * Zstd Compress operation + */ +class ZstdCompress extends Operation { + + /** + * ZstdCompress constructor + */ + constructor() { + super(); + + this.name = "Zstd Compress"; + this.module = "Compression"; + this.description = "Compresses data using the Zstandard (Zstd) algorithm. Zstd offers high compression ratios at fast speeds and is widely used in Linux, databases, container images, and network protocols. Higher compression levels result in better compression but slower speed."; + this.infoURL = "https://wikipedia.org/wiki/Zstandard"; + this.inputType = "ArrayBuffer"; + this.outputType = "ArrayBuffer"; + this.args = [ + { + name: "Compression level", + type: "option", + value: [ + "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", + "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", + "21", "22" + ], + defaultIndex: 2 + } + ]; + } + + /** + * @param {ArrayBuffer} input + * @param {Object[]} args + * @returns {ArrayBuffer} + */ + async run(input, args) { + const level = parseInt(args[0], 10); + if (input.byteLength === 0) throw new OperationError("Please provide an input."); + if (isWorkerEnvironment()) self.sendStatusMessage("Loading Zstd..."); + await zstdInit(); + if (isWorkerEnvironment()) self.sendStatusMessage("Compressing data..."); + try { + const result = compress(new Uint8Array(input), level); + return result.buffer.slice(result.byteOffset, result.byteOffset + result.byteLength); + } catch (err) { + throw new OperationError(`Failed to compress: ${err.message}`); + } + } + +} + +export default ZstdCompress; diff --git a/src/core/operations/ZstdDecompress.mjs b/src/core/operations/ZstdDecompress.mjs new file mode 100644 index 00000000..784940ee --- /dev/null +++ b/src/core/operations/ZstdDecompress.mjs @@ -0,0 +1,59 @@ +/** + * @author Leon Zandman [leon@wirwar.com] + * @copyright Crown Copyright 2026 + * @license Apache-2.0 + */ + +import Operation from "../Operation.mjs"; +import OperationError from "../errors/OperationError.mjs"; +import { isWorkerEnvironment } from "../Utils.mjs"; +import { zstdInit, decompress } from "../lib/Zstd.mjs"; + +/** + * Zstd Decompress operation + */ +class ZstdDecompress extends Operation { + + /** + * ZstdDecompress constructor + */ + constructor() { + super(); + + this.name = "Zstd Decompress"; + this.module = "Compression"; + this.description = "Decompresses data compressed with the Zstandard (Zstd) algorithm."; + this.infoURL = "https://wikipedia.org/wiki/Zstandard"; + this.inputType = "ArrayBuffer"; + this.outputType = "ArrayBuffer"; + this.args = []; + this.checks = [ + { + pattern: "^\\x28\\xb5\\x2f\\xfd", + flags: "", + args: [] + } + ]; + } + + /** + * @param {ArrayBuffer} input + * @param {Object[]} args + * @returns {ArrayBuffer} + */ + async run(input, args) { + if (input.byteLength === 0) throw new OperationError("Please provide an input."); + if (isWorkerEnvironment()) self.sendStatusMessage("Loading Zstd..."); + await zstdInit(); + if (isWorkerEnvironment()) self.sendStatusMessage("Decompressing data..."); + try { + const result = decompress(new Uint8Array(input)); + return result.buffer.slice(result.byteOffset, result.byteOffset + result.byteLength); + } catch (err) { + throw new OperationError(`Failed to decompress: ${err.message}`); + } + } + +} + +export default ZstdDecompress; diff --git a/tests/operations/index.mjs b/tests/operations/index.mjs index 44792560..e1160b79 100644 --- a/tests/operations/index.mjs +++ b/tests/operations/index.mjs @@ -199,6 +199,7 @@ import "./tests/JSONtoYAML.mjs"; import "./tests/YARA.mjs"; import "./tests/ParseCSR.mjs"; import "./tests/XXTEA.mjs"; +import "./tests/Zstd.mjs"; const testStatus = { allTestsPassing: true, diff --git a/tests/operations/tests/Zstd.mjs b/tests/operations/tests/Zstd.mjs new file mode 100644 index 00000000..eb3abed5 --- /dev/null +++ b/tests/operations/tests/Zstd.mjs @@ -0,0 +1,110 @@ +/** + * Zstd tests. + * + * @author Leon Zandman [leon@wirwar.com] + * @copyright Crown Copyright 2026 + * @license Apache-2.0 + */ +import TestRegister from "../../lib/TestRegister.mjs"; + +TestRegister.addTests([ + { + name: "Zstd compress & decompress: string", + input: "The cat sat on the mat.", + expectedOutput: "The cat sat on the mat.", + recipeConfig: [ + { + op: "Zstd Compress", + args: ["3"] + }, + { + op: "Zstd Decompress", + args: [] + } + ] + }, + { + // Generated using: node --input-type=module -e "import {init,compress} from '@bokuweb/zstd-wasm'; await init(); const r=compress(new TextEncoder().encode('The cat sat on the mat.'),3); console.log(Buffer.from(r).toString('hex'));" + name: "Zstd compress: level 3", + input: "The cat sat on the mat.", + expectedOutput: "28b52ffd2017b900005468652063617420736174206f6e20746865206d61742e", + recipeConfig: [ + { + op: "Zstd Compress", + args: ["3"] + }, + { + op: "To Hex", + args: ["None", 0] + } + ] + }, + { + // Generated using: node --input-type=module -e "import {init,compress} from '@bokuweb/zstd-wasm'; await init(); const r=compress(new TextEncoder().encode('The cat sat on the mat.'),3); console.log(Buffer.from(r).toString('hex'));" + name: "Zstd decompress: known vector", + input: "28b52ffd2017b900005468652063617420736174206f6e20746865206d61742e", + expectedOutput: "The cat sat on the mat.", + recipeConfig: [ + { + op: "From Hex", + args: ["None"] + }, + { + op: "Zstd Decompress", + args: [] + } + ] + }, + { + name: "Zstd compress & decompress: level 1", + input: "The cat sat on the mat.", + expectedOutput: "The cat sat on the mat.", + recipeConfig: [ + { + op: "Zstd Compress", + args: ["1"] + }, + { + op: "Zstd Decompress", + args: [] + } + ] + }, + { + name: "Zstd compress & decompress: level 22", + input: "The cat sat on the mat.", + expectedOutput: "The cat sat on the mat.", + recipeConfig: [ + { + op: "Zstd Compress", + args: ["22"] + }, + { + op: "Zstd Decompress", + args: [] + } + ] + }, + { + name: "Zstd compress: empty input error", + input: "", + expectedOutput: "Please provide an input.", + recipeConfig: [ + { + op: "Zstd Compress", + args: ["3"] + } + ] + }, + { + name: "Zstd decompress: empty input error", + input: "", + expectedOutput: "Please provide an input.", + recipeConfig: [ + { + op: "Zstd Decompress", + args: [] + } + ] + } +]); diff --git a/webpack.config.js b/webpack.config.js index 4c6c00ba..564040f0 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -99,6 +99,10 @@ module.exports = { context: "node_modules/node-forge/dist", from: "prime.worker.min.js", to: "assets/forge/" + }, { + context: "node_modules/@bokuweb/zstd-wasm/dist/web/", + from: "zstd.wasm", + to: "assets/" } ] }), @@ -110,6 +114,17 @@ module.exports = { operations: [ new ReplaceOperation("once", "if (pixelSize < elementMinSize)", "if (false)") ] + }, + { + // @bokuweb/zstd-wasm's init() unconditionally evaluates + // `new URL("./zstd.wasm", import.meta.url)` before checking + // whether a path was passed in. Webpack's `publicPath: ""` + // makes that base URL invalid and the constructor throws. + // Neuter the offending line; we always pass a path from Zstd.mjs. + test: /@bokuweb[\/\\]zstd-wasm[\/\\]dist[\/\\]web[\/\\]index\.web\.js$/, + operations: [ + new ReplaceOperation("once", "new URL(`./zstd.wasm`, import.meta.url).href", "''") + ] } ] })