Skip to content

Commit f0b8327

Browse files
committed
Add Kaitai Struct Decode operation
1 parent 7c8be12 commit f0b8327

File tree

6 files changed

+293
-1
lines changed

6 files changed

+293
-1
lines changed

package-lock.json

+14
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+2
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,8 @@
144144
"jsonwebtoken": "8.5.1",
145145
"jsqr": "^1.4.0",
146146
"jsrsasign": "^11.1.0",
147+
"kaitai-struct": "^0.11.0-SNAPSHOT.3",
148+
"kaitai-struct-compiler": "^0.11.0-SNAPSHOT20250330.110510.aa10f07",
147149
"kbpgp": "2.1.15",
148150
"libbzip2-wasm": "0.0.4",
149151
"libyara-wasm": "^1.2.1",

src/core/config/Categories.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@
7979
"Rison Decode",
8080
"To Modhex",
8181
"From Modhex",
82-
"MIME Decoding"
82+
"MIME Decoding",
83+
"Kaitai Struct Decode"
8384
]
8485
},
8586
{
+208
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
/**
2+
* @author kendallgoto [[email protected]]
3+
* @copyright Crown Copyright 2025
4+
* @license Apache-2.0
5+
*/
6+
7+
import Operation from "../Operation.mjs";
8+
import OperationError from "../errors/OperationError.mjs";
9+
import KaitaiStructCompiler from "kaitai-struct-compiler";
10+
import { KaitaiStream } from "kaitai-struct";
11+
import YAML from "yaml";
12+
13+
/**
14+
* Kaitai Struct Decode operation
15+
*/
16+
class KaitaiStructDecode extends Operation {
17+
18+
/**
19+
* KaitaiStructDecode constructor
20+
*/
21+
constructor() {
22+
super();
23+
24+
this.name = "Kaitai Struct Decode";
25+
this.module = "Kaitai";
26+
this.description = "Using a Kaitai Struct schema definition, read the provided input binary data into an annotated structure.";
27+
this.infoURL = "https://kaitai.io/";
28+
this.inputType = "ArrayBuffer";
29+
this.outputType = "JSON";
30+
this.presentType = "string";
31+
this.args = [
32+
{
33+
name: "Kaitai definition (.ksy)",
34+
type: "text",
35+
value: "seq:\n- id: value\n type: u2"
36+
},
37+
{
38+
"name": "Ignore errors",
39+
"type": "boolean",
40+
"value": false
41+
},
42+
];
43+
}
44+
45+
/**
46+
* @param {ArrayBuffer} input
47+
* @param {Object[]} args
48+
* @returns {Object}
49+
*/
50+
async run(input, args) {
51+
const [ksyDef, errorsOk] = args;
52+
let ksyDefObj = {};
53+
try {
54+
// apply some default headers to simplify what the user has to provide
55+
ksyDefObj = YAML.parse(ksyDef);
56+
ksyDefObj.meta = Object.assign(
57+
{ "file-extension": "none", "endian": "le", "bit-endian": "be"},
58+
ksyDefObj.meta
59+
);
60+
// ensure id is always 'generated' for deterministic output class / file name
61+
ksyDefObj.meta.id = "generated";
62+
} catch (err) {
63+
throw new OperationError(err);
64+
}
65+
66+
let parsed = {};
67+
try {
68+
const files = await KaitaiStructCompiler.compile("javascript", ksyDefObj, null, true);
69+
const ctx = {
70+
Generated: {},
71+
KaitaiStream: KaitaiStream
72+
};
73+
// for dynamic include, modify the wrapper function to store our generated content in a well-defined context object
74+
eval(files["Generated.js"].replace(/(root, factory) {/g, "(_, factory) { return factory(ctx.Generated, ctx.KaitaiStream);"));
75+
parsed = new ctx.Generated.Generated(new KaitaiStream(input));
76+
parsed._read();
77+
} catch (err) {
78+
if (!errorsOk) {
79+
throw new OperationError(err);
80+
}
81+
}
82+
83+
return this.cleanKaitai(parsed.constructor, parsed);
84+
}
85+
86+
/**
87+
* Given a Kaitai Struct object, clean it up by removing Kaitai internal keys
88+
* while annotating values using the underlying debug data
89+
*
90+
* @param {Object} inp Raw Kaitai Object
91+
* @returns {Object} Cleaned object
92+
*/
93+
cleanKaitai(baseobj, inp, debug=null) {
94+
if (typeof inp !== "object" || !inp) { // Replace primitives with annotated, wrapped objects
95+
let out;
96+
switch (typeof inp) {
97+
case "string": out = new String(inp); break;
98+
case "number": out = new Number(inp); break;
99+
case "boolean": out = new Boolean(inp); break;
100+
}
101+
// values that are assigned to enumerations should receive their enum type and string value as annotations
102+
if (debug && "enumName" in debug) {
103+
let enumParent = baseobj;
104+
const enumPath = debug.enumName.split(".").slice(1);
105+
const enumTypeName = enumPath.pop();
106+
enumPath.forEach(path => enumParent = enumParent[path]);
107+
out._type = enumTypeName;
108+
out._valstr = enumParent[enumTypeName][out];
109+
}
110+
out.start = debug.start;
111+
out.end = debug.end;
112+
return out;
113+
} else if (Array.isArray(inp) || ArrayBuffer.isView(inp)) { // Recursively clean arrays of elements
114+
const out = [];
115+
for (let i = 0; i < inp.length; i++) {
116+
let elementDebug = {};
117+
if ("arr" in debug) {
118+
elementDebug = debug.arr[i];
119+
} else if (ArrayBuffer.isView(inp)) {
120+
// for ArrayBuffers, Kaitai doesn't add debug arguments since all elements are fixed-size
121+
// instead, we can look at the ArrayBuffer parameters
122+
elementDebug = {
123+
start: debug.start + (i * inp.BYTES_PER_ELEMENT),
124+
end: debug.start + (i * inp.BYTES_PER_ELEMENT) + inp.BYTES_PER_ELEMENT
125+
};
126+
}
127+
out.push(this.cleanKaitai(baseobj, inp[i], elementDebug));
128+
}
129+
Object.defineProperty(out, "start", {
130+
value: debug.start,
131+
enumerable: false
132+
});
133+
Object.defineProperty(out, "end", {
134+
value: debug.end,
135+
enumerable: false
136+
});
137+
return out;
138+
} else { // Recursively clean each key in objects
139+
let out = {};
140+
Object.defineProperty(out, "_type", {
141+
value: inp.constructor.name,
142+
enumerable: false
143+
});
144+
if (debug) {
145+
Object.defineProperty(out, "start", {
146+
value: debug.start,
147+
enumerable: false
148+
});
149+
Object.defineProperty(out, "end", {
150+
value: debug.end,
151+
enumerable: false
152+
});
153+
}
154+
for (const [key, value] of Object.entries(inp)) {
155+
// debug structure contains all real keys; ignoring Kaitai internal objects or type parametrization values
156+
if (!(key in inp._debug)) continue;
157+
out[key] = this.cleanKaitai(baseobj, value, inp._debug[key]);
158+
}
159+
return out;
160+
}
161+
}
162+
163+
/**
164+
* Given a Kaitai Struct object, walk the structure to provide printout with type annotations
165+
*
166+
* @param {Object} inp Raw Kaitai Object
167+
* @param {Number} indent Current depth in printout for prefixed whitespace
168+
* @returns {string} Formatted printout text
169+
*/
170+
printKaitai(inp, indent=0) {
171+
if (typeof inp !== "object") {
172+
return "";
173+
} else {
174+
let out = "";
175+
for (const [key, value] of Object.entries(inp)) {
176+
if (value.toString() !== "[object Object]" && !Array.isArray(value)) {
177+
if ("_valstr" in value)
178+
out += `${"\t".repeat(indent)}${key}[${value.start}:${value.end ?? ""}]: ${value._valstr} (${value.valueOf()})\n`;
179+
else
180+
out += `${"\t".repeat(indent)}${key}[${value.start}:${value.end ?? ""}]: ${value.valueOf()}\n`;
181+
} else {
182+
if ("_type" in value)
183+
out += `${"\t".repeat(indent)}${key}[${value.start}:${value.end ?? ""}]: [${value._type}]\n`;
184+
else if ("start" in value)
185+
out += `${"\t".repeat(indent)}${key}[${value.start}:${value.end ?? ""}]:\n`;
186+
else
187+
out += `${"\t".repeat(indent)}${key}:\n`;
188+
out += this.printKaitai(value, indent+1);
189+
}
190+
}
191+
return out;
192+
}
193+
}
194+
195+
/**
196+
* Creates an annotated tree of a Kaitai object by walking the structure and expanding debug
197+
* annotations including type hints, binary offsets, and enum strings
198+
*
199+
* @param {Object} o Kaitai result object with debug annotations applied
200+
* @returns {string} Annotated tree of the Kaitai structure
201+
*/
202+
present(o) {
203+
return this.printKaitai(o, 0);
204+
}
205+
206+
}
207+
208+
export default KaitaiStructDecode;

tests/operations/index.mjs

+1
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ import "./tests/JWK.mjs";
9797
import "./tests/JWTDecode.mjs";
9898
import "./tests/JWTSign.mjs";
9999
import "./tests/JWTVerify.mjs";
100+
import "./tests/KaitaiStructDecode.mjs";
100101
import "./tests/LevenshteinDistance.mjs";
101102
import "./tests/Lorenz.mjs";
102103
import "./tests/LS47.mjs";
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* @author kendallgoto [[email protected]]
3+
* @copyright Crown Copyright 2025
4+
* @license Apache-2.0
5+
*/
6+
7+
import TestRegister from "../../lib/TestRegister.mjs";
8+
TestRegister.addTests([
9+
{
10+
"name": "Kaitai Struct Decode: Gif Decode",
11+
"input": "R0lGODdhIAA0APABAP",
12+
"expectedOutput": "[71,73,70]",
13+
"recipeConfig": [
14+
{
15+
"op": "From Base64",
16+
"args": ["A-Za-z0-9+/=", true]
17+
},
18+
{
19+
"op": "Kaitai Struct Decode",
20+
"args": [
21+
// https://kaitai.io/#quick-start
22+
"meta:\n id: gif\n file-extension: gif\n endian: le\nseq:\n - id: header\n type: header\n - id: logical_screen\n type: logical_screen\ntypes:\n header:\n seq:\n - id: magic\n contents: 'GIF'\n - id: version\n size: 3\n logical_screen:\n seq:\n - id: image_width\n type: u2\n - id: image_height\n type: u2\n - id: flags\n type: u1\n - id: bg_color_index\n type: u1\n - id: pixel_aspect_ratio\n type: u1",
23+
],
24+
},
25+
{
26+
"op": "Jq",
27+
"args": [
28+
".header.magic",
29+
],
30+
},
31+
],
32+
},
33+
{
34+
"name": "Kaitai Struct Decode: Incomplete Error",
35+
"input": "",
36+
"expectedOutput": "EOFError: requested 1 bytes, but only 0 bytes available",
37+
"recipeConfig": [
38+
{
39+
"op": "Kaitai Struct Decode",
40+
"args": [
41+
"seq:\n- id: entry\n type: u1\n repeat: expr\n repeat-expr: 10", // read 10 uint8s, one by one
42+
],
43+
}
44+
],
45+
},
46+
{
47+
"name": "Kaitai Struct Decode: Incomplete Error (ignored)",
48+
"input": "\x00\x01\x02\x03\x04",
49+
"expectedOutput": "[0,1,2,3,4]",
50+
"recipeConfig": [
51+
{
52+
"op": "Kaitai Struct Decode",
53+
"args": [
54+
"seq:\n- id: entry\n type: u1\n repeat: expr\n repeat-expr: 10", // read 10 uint8s, one by one
55+
true
56+
],
57+
},
58+
{
59+
"op": "Jq",
60+
"args": [
61+
".entry",
62+
],
63+
},
64+
],
65+
}
66+
]);

0 commit comments

Comments
 (0)