diff --git a/package-lock.json b/package-lock.json index a6cd3a5a..b04602ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@astronautlabs/amf": "^0.0.6", "@babel/polyfill": "^7.12.1", "@blu3r4y/lzma": "^2.3.3", + "@bokuweb/zstd-wasm": "^0.0.27", "@wavesenterprise/crypto-gost-js": "^2.1.0-RC1", "@xmldom/xmldom": "^0.8.11", "argon2-browser": "^1.18.0", @@ -1838,6 +1839,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.2", "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.2.tgz", diff --git a/package.json b/package.json index b3d695ea..676894cd 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "@astronautlabs/amf": "^0.0.6", "@babel/polyfill": "^7.12.1", "@blu3r4y/lzma": "^2.3.3", + "@bokuweb/zstd-wasm": "^0.0.27", "@wavesenterprise/crypto-gost-js": "^2.1.0-RC1", "@xmldom/xmldom": "^0.8.11", "argon2-browser": "^1.18.0", diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json index aac00ca1..0daab37c 100644 --- a/src/core/config/Categories.json +++ b/src/core/config/Categories.json @@ -402,7 +402,9 @@ "LZMA Compress", "LZ4 Decompress", "LZ4 Compress", - "LZNT1 Decompress" + "LZNT1 Decompress", + "Zstd Compress", + "Zstd Decompress" ] }, { diff --git a/src/core/lib/Zstd.mjs b/src/core/lib/Zstd.mjs new file mode 100644 index 00000000..a7e013be --- /dev/null +++ b/src/core/lib/Zstd.mjs @@ -0,0 +1,30 @@ +/** + * 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 n1474335 [n1474335@gmail.com] + * @copyright Crown Copyright 2024 + * @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) { + initPromise = init(); + } + 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..909f0eb7 --- /dev/null +++ b/src/core/operations/ZstdCompress.mjs @@ -0,0 +1,61 @@ +/** + * @author Leon Zandman [leon@wirwar.com] + * @copyright Crown Copyright 2027 + * @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."; + this.infoURL = "https://wikipedia.org/wiki/Zstandard"; + this.inputType = "ArrayBuffer"; + this.outputType = "ArrayBuffer"; + this.args = [ + { + name: "Compression level", + type: "number", + value: 3, + min: 1, + max: 22 + } + ]; + } + + /** + * @param {ArrayBuffer} input + * @param {Object[]} args + * @returns {ArrayBuffer} + */ + async run(input, args) { + const [level] = args; + 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..3e6dd2bc --- /dev/null +++ b/src/core/operations/ZstdDecompress.mjs @@ -0,0 +1,59 @@ +/** + * @author Leon Zandman [leon@wirwar.com] + * @copyright Crown Copyright 2027 + * @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 9fccd051..41a95c05 100644 --- a/tests/operations/index.mjs +++ b/tests/operations/index.mjs @@ -182,6 +182,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..da9577d5 --- /dev/null +++ b/tests/operations/tests/Zstd.mjs @@ -0,0 +1,80 @@ +/** + * Zstd tests. + * + * @author n1474335 [n1474335@gmail.com] + * @copyright Crown Copyright 2024 + * @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: 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: [] + } + ] + } +]);