diff --git a/package-lock.json b/package-lock.json index 89e5b3ecf7..14c7cd1506 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 d7d1b48a03..09d1d2aca7 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 3404dc6dda..145cd130d8 100644 --- a/src/core/config/Categories.json +++ b/src/core/config/Categories.json @@ -415,7 +415,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 221328234c..75bb33b05a 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 0000000000..a8dc279e01 --- /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 0000000000..e10cd02d5a --- /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 0000000000..784940ee39 --- /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 c12c271098..196a41c3c9 100644 --- a/tests/operations/index.mjs +++ b/tests/operations/index.mjs @@ -197,6 +197,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 0000000000..eb3abed582 --- /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 4c6c00ba29..564040f0ff 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", "''") + ] } ] })