Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion src/core/config/Categories.json
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,9 @@
"LZMA Compress",
"LZ4 Decompress",
"LZ4 Compress",
"LZNT1 Decompress"
"LZNT1 Decompress",
"Zstd Compress",
"Zstd Decompress"
]
},
{
Expand Down
26 changes: 26 additions & 0 deletions src/core/lib/FileSignatures.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
33 changes: 33 additions & 0 deletions src/core/lib/Zstd.mjs
Original file line number Diff line number Diff line change
@@ -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<void>}
*/
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 };
64 changes: 64 additions & 0 deletions src/core/operations/ZstdCompress.mjs
Original file line number Diff line number Diff line change
@@ -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;
59 changes: 59 additions & 0 deletions src/core/operations/ZstdDecompress.mjs
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions tests/operations/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
110 changes: 110 additions & 0 deletions tests/operations/tests/Zstd.mjs
Original file line number Diff line number Diff line change
@@ -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: []
}
]
}
]);
15 changes: 15 additions & 0 deletions webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
}
]
}),
Expand All @@ -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", "''")
]
}
]
})
Expand Down