cyberchef 10.22.1 → 10.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/CHANGELOG.md +131 -0
  2. package/CONTRIBUTING.md +37 -0
  3. package/Dockerfile +2 -0
  4. package/Gruntfile.js +0 -12
  5. package/README.md +1 -1
  6. package/babel.config.js +0 -6
  7. package/package.json +63 -62
  8. package/src/core/Chef.mjs +8 -1
  9. package/src/core/Ingredient.mjs +5 -2
  10. package/src/core/Operation.mjs +6 -1
  11. package/src/core/Recipe.mjs +10 -5
  12. package/src/core/config/Categories.json +18 -3
  13. package/src/core/config/OperationConfig.json +496 -23
  14. package/src/core/config/modules/Ciphers.mjs +6 -0
  15. package/src/core/config/modules/Crypto.mjs +6 -0
  16. package/src/core/config/modules/Default.mjs +6 -0
  17. package/src/core/config/modules/Shellcode.mjs +2 -0
  18. package/src/core/lib/AudioBytes.mjs +103 -0
  19. package/src/core/lib/AudioMetaSchema.mjs +82 -0
  20. package/src/core/lib/AudioParsers.mjs +630 -0
  21. package/src/core/lib/BigIntUtils.mjs +73 -0
  22. package/src/core/lib/Modhex.mjs +2 -0
  23. package/src/core/lib/QRCode.mjs +30 -10
  24. package/src/core/lib/RC6.mjs +625 -0
  25. package/src/core/operations/A1Z26CipherDecode.mjs +1 -1
  26. package/src/core/operations/AddTextToImage.mjs +116 -64
  27. package/src/core/operations/BlurImage.mjs +10 -12
  28. package/src/core/operations/ContainImage.mjs +50 -40
  29. package/src/core/operations/ConvertImageFormat.mjs +33 -39
  30. package/src/core/operations/CoverImage.mjs +39 -37
  31. package/src/core/operations/CropImage.mjs +35 -21
  32. package/src/core/operations/DisassembleARM.mjs +193 -0
  33. package/src/core/operations/DitherImage.mjs +8 -8
  34. package/src/core/operations/EscapeUnicodeCharacters.mjs +0 -17
  35. package/src/core/operations/ExtractAudioMetadata.mjs +175 -0
  36. package/src/core/operations/ExtractLSB.mjs +17 -11
  37. package/src/core/operations/ExtractRGBA.mjs +12 -10
  38. package/src/core/operations/FlaskSessionDecode.mjs +80 -0
  39. package/src/core/operations/FlaskSessionSign.mjs +89 -0
  40. package/src/core/operations/FlaskSessionVerify.mjs +136 -0
  41. package/src/core/operations/FlipImage.mjs +14 -10
  42. package/src/core/operations/GenerateImage.mjs +39 -32
  43. package/src/core/operations/ImageBrightnessContrast.mjs +10 -10
  44. package/src/core/operations/ImageFilter.mjs +14 -13
  45. package/src/core/operations/ImageHueSaturationLightness.mjs +22 -20
  46. package/src/core/operations/ImageOpacity.mjs +6 -8
  47. package/src/core/operations/InvertImage.mjs +4 -6
  48. package/src/core/operations/Jq.mjs +12 -4
  49. package/src/core/operations/NormaliseImage.mjs +5 -7
  50. package/src/core/operations/OffsetChecker.mjs +1 -1
  51. package/src/core/operations/ParseEthernetFrame.mjs +112 -0
  52. package/src/core/operations/ParseIPv4Header.mjs +23 -6
  53. package/src/core/operations/ParseQRCode.mjs +13 -13
  54. package/src/core/operations/PseudoRandomIntegerGenerator.mjs +164 -0
  55. package/src/core/operations/RC6Decrypt.mjs +119 -0
  56. package/src/core/operations/RC6Encrypt.mjs +119 -0
  57. package/src/core/operations/RandomizeColourPalette.mjs +11 -11
  58. package/src/core/operations/ResizeImage.mjs +30 -23
  59. package/src/core/operations/RotateImage.mjs +8 -9
  60. package/src/core/operations/SQLBeautify.mjs +21 -3
  61. package/src/core/operations/SharpenImage.mjs +94 -62
  62. package/src/core/operations/SplitColourChannels.mjs +47 -21
  63. package/src/core/operations/TextIntegerConverter.mjs +123 -0
  64. package/src/core/operations/UnescapeUnicodeCharacters.mjs +17 -0
  65. package/src/core/operations/ViewBitPlane.mjs +16 -20
  66. package/src/core/operations/index.mjs +20 -0
  67. package/src/node/index.mjs +50 -0
  68. package/src/web/HTMLIngredient.mjs +24 -43
  69. package/src/web/Manager.mjs +1 -0
  70. package/src/web/html/index.html +6 -6
  71. package/src/web/static/fonts/bmfonts/Roboto72White.fnt +491 -485
  72. package/src/web/static/fonts/bmfonts/RobotoBlack72White.fnt +494 -488
  73. package/src/web/static/fonts/bmfonts/RobotoMono72White.fnt +110 -103
  74. package/src/web/static/fonts/bmfonts/RobotoSlab72White.fnt +498 -492
  75. package/src/web/stylesheets/layout/_banner.css +30 -0
  76. package/src/web/stylesheets/layout/_modals.css +5 -0
  77. package/src/web/stylesheets/utils/_overrides.css +7 -0
  78. package/src/web/waiters/ControlsWaiter.mjs +82 -0
  79. package/src/web/waiters/InputWaiter.mjs +12 -6
  80. package/src/web/waiters/RecipeWaiter.mjs +2 -2
  81. package/tests/browser/02_ops.js +23 -3
  82. package/tests/node/index.mjs +1 -0
  83. package/tests/node/tests/lib/BigIntUtils.mjs +150 -0
  84. package/tests/node/tests/operations.mjs +9 -7
  85. package/tests/operations/index.mjs +8 -0
  86. package/tests/operations/tests/A1Z26CipherDecode.mjs +33 -0
  87. package/tests/operations/tests/DisassembleARM.mjs +377 -0
  88. package/tests/operations/tests/ExtractAudioMetadata.mjs +287 -0
  89. package/tests/operations/tests/FlaskSession.mjs +246 -0
  90. package/tests/operations/tests/GenerateQRCode.mjs +67 -0
  91. package/tests/operations/tests/JWTSign.mjs +83 -8
  92. package/tests/operations/tests/Jq.mjs +32 -0
  93. package/tests/operations/tests/Modhex.mjs +20 -0
  94. package/tests/operations/tests/ParseEthernetFrame.mjs +45 -0
  95. package/tests/operations/tests/RC6.mjs +487 -0
  96. package/tests/operations/tests/SQLBeautify.mjs +54 -0
  97. package/tests/operations/tests/TextIntegerConverter.mjs +199 -0
  98. package/tests/samples/Audio.mjs +73 -0
  99. package/tests/samples/Images.mjs +0 -12
  100. package/webpack.config.js +10 -7
  101. package/src/core/lib/ImageManipulation.mjs +0 -251
@@ -0,0 +1,630 @@
1
+ /**
2
+ * Format-specific audio metadata parsers.
3
+ *
4
+ * @author d0s1nt [d0s1nt@cyberchefaudio]
5
+ * @copyright Crown Copyright 2025
6
+ * @license Apache-2.0
7
+ */
8
+
9
+ /* eslint-disable camelcase */
10
+
11
+ import {
12
+ ascii4, indexOfAscii,
13
+ u32be, u32le, u16le, u64le, synchsafeToInt,
14
+ decodeUtf16LE, readNullTerminated, decodeText,
15
+ safeUtf8, decodeLatin1Trim,
16
+ } from "./AudioBytes.mjs";
17
+
18
+ /** Parses MP3 metadata: ID3v2 frames, ID3v1 footer, APEv2 tags. */
19
+ export function parseMp3(b, report) {
20
+ processId3v2(b, report);
21
+ processId3v1(b, report);
22
+
23
+ const ape = parseApeV2BestEffort(b);
24
+ if (ape) {
25
+ report.detections.metadata_systems.push("apev2");
26
+ report.tags.raw.apev2 = ape;
27
+ }
28
+ }
29
+
30
+ /** Iterates ID3v2 frames and populates the report. */
31
+ function processId3v2(b, report) {
32
+ report.detections.metadata_systems.push("id3v2");
33
+
34
+ const id3 = parseId3v2(b);
35
+ report.tags.raw.id3v2 = id3 ? { header: id3.header, frames: [] } : null;
36
+
37
+ if (id3) {
38
+ for (const f of id3.frames) {
39
+ const entry = { id: f.id, size: f.size, description: ID3_FRAME_DESCRIPTIONS[f.id] || null };
40
+
41
+ if (f.id[0] === "T" && f.id !== "TXXX") {
42
+ const text = f.data?.length >= 1 ?
43
+ decodeText(f.data.slice(1), f.data[0]).replace(/\u0000/g, "").trim() :
44
+ "";
45
+ entry.decoded = text;
46
+ if (f.id === "TLEN") {
47
+ const ms = normalizeTlen(text);
48
+ if (ms !== null) entry.normalized_ms = ms;
49
+ }
50
+ mapCommonId3(report, f.id, text);
51
+ } else if (f.id === "TXXX") {
52
+ const txxx = decodeTxxx(f.data);
53
+ entry.decoded = txxx;
54
+ if (!report.tags.raw.id3v2.txxx) report.tags.raw.id3v2.txxx = [];
55
+ report.tags.raw.id3v2.txxx.push(txxx);
56
+ } else if (f.id === "COMM") {
57
+ const comm = decodeCommFrame(f.data);
58
+ entry.decoded = comm;
59
+ if (comm?.text && !report.tags.common.comment) report.tags.common.comment = comm.text;
60
+ } else if (f.id === "GEOB") {
61
+ processGeobFrame(f, entry, report);
62
+ }
63
+
64
+ report.tags.raw.id3v2.frames.push(entry);
65
+ }
66
+ } else {
67
+ report.detections.metadata_systems = report.detections.metadata_systems.filter((x) => x !== "id3v2");
68
+ }
69
+ }
70
+
71
+ /** Parses GEOB frame contents, populates entry, embedded objects, and C2PA provenance. */
72
+ function processGeobFrame(f, entry, report) {
73
+ const d = f.data, enc = d[0];
74
+ let off = 1;
75
+ const mime = readNullTerminated(d, off, 0);
76
+ const mimeType = decodeLatin1Trim(mime.valueBytes);
77
+ off = mime.next;
78
+ const file = readNullTerminated(d, off, enc);
79
+ const filename = decodeText(file.valueBytes, enc).replace(/\u0000/g, "").trim();
80
+ off = file.next;
81
+ const desc = readNullTerminated(d, off, enc);
82
+ const description = decodeText(desc.valueBytes, enc).replace(/\u0000/g, "").trim();
83
+ off = desc.next;
84
+ const objLen = d.length - off;
85
+
86
+ entry.geob = { mimeType, filename, description, object_bytes: objLen };
87
+ const geobId = `geob_${report.embedded.filter((x) => x.source === "id3v2:GEOB").length}`;
88
+ report.embedded.push({
89
+ id: geobId, source: "id3v2:GEOB",
90
+ content_type: mimeType || null, byte_length: objLen,
91
+ description: description || null, filename: filename || null,
92
+ });
93
+
94
+ const mt = (mimeType || "").toLowerCase();
95
+ if (mt.includes("c2pa") || mt.includes("jumbf") || mt.includes("application/x-c2pa-manifest-store")) {
96
+ report.provenance.c2pa.present = true;
97
+ report.provenance.c2pa.embedding.push({
98
+ carrier: "id3v2:GEOB", content_type: mimeType || null, byte_length: objLen,
99
+ });
100
+ }
101
+ }
102
+
103
+ /** Processes the 128-byte ID3v1 footer tag. */
104
+ function processId3v1(b, report) {
105
+ const id3v1 = parseId3v1(b);
106
+ if (!id3v1) return;
107
+
108
+ report.detections.metadata_systems.push("id3v1");
109
+ report.tags.raw.id3v1 = id3v1;
110
+ mapCommon(report, id3v1, ID3V1_TO_COMMON);
111
+ }
112
+
113
+ /** Parses WAV/BWF/BW64 RIFF chunks: LIST/INFO, bext, iXML, axml, ds64. */
114
+ export function parseRiffWave(b, report, maxTextBytes) {
115
+ report.detections.metadata_systems.push("riff_info");
116
+
117
+ const chunks = enumerateChunks(b, 12, b.length, 50000);
118
+ const riff = { chunks: [], info: null, bext: null, ixml: null, axml: null, ds64: null };
119
+
120
+ const info = {};
121
+ for (const c of chunks) {
122
+ riff.chunks.push({ id: c.id, size: c.size, offset: c.dataOff });
123
+ processRiffChunk(b, c, riff, info, report, maxTextBytes);
124
+ }
125
+
126
+ riff.info = Object.keys(info).length ? info : null;
127
+ report.tags.raw.riff = riff;
128
+ if (riff.info) mapCommon(report, riff.info, RIFF_TO_COMMON);
129
+ }
130
+
131
+ /** Processes a single RIFF chunk, updating riff state and the report. */
132
+ function processRiffChunk(b, c, riff, info, report, maxTextBytes) {
133
+ if (c.id === "ds64") {
134
+ riff.ds64 = { present: true, size: c.size };
135
+ if (!report.detections.metadata_systems.includes("bw64_ds64")) report.detections.metadata_systems.push("bw64_ds64");
136
+ if (report.artifact.container.type === "wav") report.artifact.container.type = "bw64";
137
+ }
138
+
139
+ if (c.id === "LIST" && ascii4(b, c.dataOff) === "INFO") {
140
+ for (const s of enumerateChunks(b, c.dataOff + 4, c.dataOff + c.size, 10000))
141
+ info[s.id] = decodeLatin1Trim(b.slice(s.dataOff, s.dataOff + s.size));
142
+ }
143
+
144
+ if (c.id === "bext") {
145
+ if (!report.detections.metadata_systems.includes("bwf_bext")) report.detections.metadata_systems.push("bwf_bext");
146
+ riff.bext = parseBext(b, c.dataOff, c.size);
147
+ }
148
+
149
+ if (c.id === "iXML" || c.id === "axml") {
150
+ const key = c.id === "iXML" ? "ixml" : "axml";
151
+ if (!report.detections.metadata_systems.includes(key)) report.detections.metadata_systems.push(key);
152
+ const payload = b.slice(c.dataOff, c.dataOff + c.size);
153
+ riff[key] = { xml: safeUtf8(payload.slice(0, Math.min(payload.length, maxTextBytes))), truncated: payload.length > maxTextBytes };
154
+ report.embedded.push({
155
+ id: `${key}_0`, source: `riff:${c.id}`, content_type: "application/xml",
156
+ byte_length: payload.length, description: `${c.id} chunk`, filename: null,
157
+ });
158
+ }
159
+ }
160
+
161
+ /** Parses FLAC metablocks: STREAMINFO, Vorbis Comment, PICTURE. */
162
+ export function parseFlac(b, report, maxTextBytes) {
163
+ report.detections.metadata_systems.push("flac_metablocks");
164
+
165
+ const blocks = parseFlacMetaBlocks(b);
166
+ report.tags.raw.flac = { blocks: [] };
167
+
168
+ for (const blk of blocks) {
169
+ report.tags.raw.flac.blocks.push({ type: blk.typeName, length: blk.length });
170
+
171
+ if (blk.typeName === "VORBIS_COMMENT") {
172
+ if (!report.detections.metadata_systems.includes("vorbis_comments")) report.detections.metadata_systems.push("vorbis_comments");
173
+ const vc = parseVorbisComment(blk.data);
174
+ report.tags.raw.vorbis_comments = vc;
175
+ mapVorbisCommon(report, vc);
176
+ } else if (blk.typeName === "PICTURE") {
177
+ const pic = parseFlacPicture(blk.data, maxTextBytes);
178
+ report.embedded.push({
179
+ id: `cover_art_${report.embedded.filter((x) => x.id.startsWith("cover_art_")).length}`,
180
+ source: "flac:PICTURE", content_type: pic.mime || null,
181
+ byte_length: pic.dataLength, description: pic.description || null, filename: null,
182
+ });
183
+ }
184
+ }
185
+ }
186
+
187
+ /** Parses OGG/Opus Vorbis comments. */
188
+ export function parseOgg(b, report) {
189
+ if (!report.detections.metadata_systems.includes("ogg_opus_tags")) report.detections.metadata_systems.push("ogg_opus_tags");
190
+
191
+ const scanEnd = Math.min(b.length, 1024 * 1024);
192
+ let tags = null;
193
+ const opusTagsIdx = indexOfAscii(b, "OpusTags", 0, scanEnd);
194
+ if (opusTagsIdx >= 0) {
195
+ report.artifact.container.type = "opus";
196
+ tags = parseVorbisComment(b.slice(opusTagsIdx + 8, scanEnd));
197
+ } else {
198
+ const vorbisIdx = indexOfAscii(b, "\x03vorbis", 0, scanEnd);
199
+ if (vorbisIdx >= 0) tags = parseVorbisComment(b.slice(vorbisIdx + 7, scanEnd));
200
+ }
201
+
202
+ report.tags.raw.ogg = { has_opustags: opusTagsIdx >= 0, has_vorbis_comment: !!tags };
203
+
204
+ if (tags) {
205
+ if (!report.detections.metadata_systems.includes("vorbis_comments")) report.detections.metadata_systems.push("vorbis_comments");
206
+ report.tags.raw.vorbis_comments = tags;
207
+ mapVorbisCommon(report, tags);
208
+ }
209
+ }
210
+
211
+ /** Best-effort top-level atom scan for MP4/M4A. */
212
+ export function parseMp4BestEffort(b, report) {
213
+ report.detections.metadata_systems.push("mp4_atoms");
214
+ const atoms = [];
215
+
216
+ let off = 0;
217
+ while (off + 8 <= b.length && atoms.length < 2000) {
218
+ const size = u32be(b, off);
219
+ const type = ascii4(b, off + 4);
220
+ if (size < 8) break;
221
+ atoms.push({ type, size, offset: off });
222
+ off += size;
223
+ }
224
+
225
+ report.tags.raw.mp4 = {
226
+ top_level_atoms: atoms.slice(0, 200),
227
+ hints: {
228
+ hasMoov: atoms.some((a) => a.type === "moov"),
229
+ hasUdta: atoms.some((a) => a.type === "udta"),
230
+ hasMeta: atoms.some((a) => a.type === "meta"),
231
+ hasIlst: atoms.some((a) => a.type === "ilst"),
232
+ },
233
+ };
234
+ }
235
+
236
+ /** Best-effort AIFF/AIFC chunk scanning for NAME, AUTH, ANNO. */
237
+ export function parseAiffBestEffort(b, report, maxTextBytes) {
238
+ report.detections.metadata_systems.push("aiff_chunks");
239
+ let off = 12;
240
+ const chunks = [];
241
+ while (off + 8 <= b.length && chunks.length < 2000) {
242
+ const id = ascii4(b, off);
243
+ const size = u32be(b, off + 4);
244
+ const dataOff = off + 8;
245
+ chunks.push({ id, size, offset: off });
246
+
247
+ if (["NAME", "AUTH", "ANNO", "(c) "].includes(id)) {
248
+ const txt = safeUtf8(b.slice(dataOff, dataOff + Math.min(size, maxTextBytes)));
249
+ if (!report.tags.raw.aiff) report.tags.raw.aiff = { chunks: [] };
250
+ report.tags.raw.aiff.chunks.push({ id, value: txt, truncated: size > maxTextBytes });
251
+ }
252
+
253
+ off = dataOff + size + (size % 2);
254
+ }
255
+
256
+ if (!report.tags.raw.aiff) report.tags.raw.aiff = {};
257
+ report.tags.raw.aiff.chunk_index = chunks.slice(0, 500);
258
+
259
+ const nameChunk = report.tags.raw.aiff?.chunks?.find((ch) => ch.id === "NAME")?.value;
260
+ if (nameChunk) report.tags.common.title = report.tags.common.title || nameChunk;
261
+ }
262
+
263
+ const AAC_SAMPLE_RATES = [96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350];
264
+ const AAC_PROFILES = ["Main", "LC", "SSR", "LTP"];
265
+ const AAC_CHANNELS = ["defined in AOT", "mono", "stereo", "3.0", "4.0", "5.0", "5.1", "7.1"];
266
+
267
+ /** Parses AAC ADTS frame header for audio parameters. */
268
+ export function parseAacAdts(b, report) {
269
+ report.detections.metadata_systems.push("adts_header");
270
+ if (b.length < 7) return;
271
+
272
+ const id = (b[1] >> 3) & 0x01;
273
+ const profile = (b[2] >> 6) & 0x03;
274
+ const freqIdx = (b[2] >> 2) & 0x0f;
275
+ const chanCfg = ((b[2] & 0x01) << 2) | ((b[3] >> 6) & 0x03);
276
+
277
+ report.tags.raw.aac = {
278
+ mpeg_version: id === 1 ? "MPEG-2" : "MPEG-4",
279
+ profile: AAC_PROFILES[profile] || `Profile ${profile}`,
280
+ sample_rate: AAC_SAMPLE_RATES[freqIdx] || null,
281
+ sample_rate_index: freqIdx,
282
+ channel_configuration: chanCfg,
283
+ channel_description: AAC_CHANNELS[chanCfg] || null,
284
+ };
285
+ }
286
+
287
+ const AC3_SAMPLE_RATES = [48000, 44100, 32000];
288
+ const AC3_BITRATES = [32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, 448, 512, 576, 640];
289
+ const AC3_ACMODES = [
290
+ "2.0 (Ch1+Ch2)", "1.0 (C)", "2.0 (L R)", "3.0 (L C R)",
291
+ "2.1 (L R S)", "3.1 (L C R S)", "2.2 (L R SL SR)", "3.2 (L C R SL SR)",
292
+ ];
293
+
294
+ /** Parses AC3 (Dolby Digital) bitstream info. */
295
+ export function parseAc3(b, report) {
296
+ report.detections.metadata_systems.push("ac3_bsi");
297
+ if (b.length < 8) return;
298
+
299
+ const fscod = (b[4] >> 6) & 0x03;
300
+ const frmsizecod = b[4] & 0x3f;
301
+ const bsid = (b[5] >> 3) & 0x1f;
302
+ const bsmod = b[5] & 0x07;
303
+ const acmod = (b[6] >> 5) & 0x07;
304
+
305
+ report.tags.raw.ac3 = {
306
+ sample_rate: AC3_SAMPLE_RATES[fscod] || null,
307
+ fscod,
308
+ bitrate_kbps: AC3_BITRATES[frmsizecod >> 1] || null,
309
+ frmsizecod, bsid, bsmod, acmod,
310
+ channel_layout: AC3_ACMODES[acmod] || null,
311
+ };
312
+ }
313
+
314
+ /** Parses WMA files (ASF container) for content description metadata. */
315
+ export function parseWmaAsf(b, report) {
316
+ report.detections.metadata_systems.push("asf_header");
317
+ if (b.length < 30) return;
318
+
319
+ const headerSize = Number(u64le(b, 16));
320
+ const numObjects = u32le(b, 24);
321
+ const headerEnd = Math.min(b.length, headerSize);
322
+
323
+ const objects = [];
324
+ let off = 30;
325
+
326
+ for (let i = 0; i < numObjects && off + 24 <= headerEnd; i++) {
327
+ const guid4 = [b[off], b[off + 1], b[off + 2], b[off + 3]];
328
+ const objSize = Number(u64le(b, off + 16));
329
+ if (objSize < 24 || off + objSize > headerEnd) break;
330
+
331
+ const dataOff = off + 24;
332
+ const dataLen = objSize - 24;
333
+
334
+ if (guid4[0] === 0x33 && guid4[1] === 0x26 && guid4[2] === 0xb2 && guid4[3] === 0x75 && dataLen >= 10) {
335
+ const cd = parseAsfContentDescription(b, dataOff);
336
+ if (!report.detections.metadata_systems.includes("asf_content_desc"))
337
+ report.detections.metadata_systems.push("asf_content_desc");
338
+ if (!report.tags.raw.asf) report.tags.raw.asf = {};
339
+ report.tags.raw.asf.content_description = cd;
340
+ mapCommon(report, cd, ASF_CD_TO_COMMON);
341
+ }
342
+
343
+ if (guid4[0] === 0x40 && guid4[1] === 0xa4 && guid4[2] === 0xd0 && guid4[3] === 0xd2 && dataLen >= 2) {
344
+ const ext = parseAsfExtContentDescription(b, dataOff, dataOff + dataLen);
345
+ if (!report.detections.metadata_systems.includes("asf_ext_content_desc"))
346
+ report.detections.metadata_systems.push("asf_ext_content_desc");
347
+ if (!report.tags.raw.asf) report.tags.raw.asf = {};
348
+ report.tags.raw.asf.extended_content = ext;
349
+
350
+ const c = report.tags.common;
351
+ for (const d of ext) {
352
+ const field = WMA_TO_COMMON[(d.name || "").toUpperCase()];
353
+ if (field && d.value) c[field] = c[field] || d.value;
354
+ }
355
+ }
356
+
357
+ objects.push({ guid_prefix: guid4.map(x => x.toString(16).padStart(2, "0")).join(""), size: objSize });
358
+ off += objSize;
359
+ }
360
+
361
+ if (!report.tags.raw.asf) report.tags.raw.asf = {};
362
+ report.tags.raw.asf.header_objects = objects;
363
+ }
364
+
365
+ const ID3_FRAME_DESCRIPTIONS = {
366
+ TIT2: "Title/songname/content description", TPE1: "Lead performer(s)/Soloist(s)",
367
+ TRCK: "Track number/Position in set", TALB: "Album/Movie/Show title",
368
+ TDRC: "Recording time", TYER: "Year", TCON: "Content type",
369
+ TPE2: "Band/orchestra/accompaniment", TLEN: "Length (ms)", TCOM: "Composer",
370
+ COMM: "Comments", APIC: "Attached picture", GEOB: "General encapsulated object",
371
+ TXXX: "User defined text information frame", UFID: "Unique file identifier", PRIV: "Private frame",
372
+ };
373
+
374
+ const ID3_TO_COMMON = {
375
+ TIT2: "title", TPE1: "artist", TALB: "album", TDRC: "date", TYER: "date",
376
+ TRCK: "track", TCON: "genre", COMM: "comment", TCOM: "composer", TCOP: "copyright", TLAN: "language",
377
+ };
378
+ const VORBIS_TO_COMMON = {
379
+ TITLE: "title", ARTIST: "artist", ALBUM: "album", DATE: "date",
380
+ TRACKNUMBER: "track", GENRE: "genre", COMMENT: "comment", COMPOSER: "composer", LANGUAGE: "language",
381
+ };
382
+ const WMA_TO_COMMON = {
383
+ "WM/ALBUMTITLE": "album", "WM/GENRE": "genre", "WM/YEAR": "date",
384
+ "WM/TRACKNUMBER": "track", "WM/COMPOSER": "composer", "WM/LANGUAGE": "language",
385
+ };
386
+ const ID3V1_TO_COMMON = { title: "title", artist: "artist", album: "album", year: "date", comment: "comment", genre: "genre", track: "track" };
387
+ const RIFF_TO_COMMON = { INAM: "title", IART: "artist", ICMT: "comment", IGNR: "genre", ICRD: "date", ICOP: "copyright" };
388
+ const ASF_CD_TO_COMMON = { title: "title", author: "artist", copyright: "copyright", description: "comment" };
389
+
390
+ /** Maps source object fields to the common tags layer via a mapping table. */
391
+ function mapCommon(report, source, mapping) {
392
+ const c = report.tags.common;
393
+ for (const [sk, ck] of Object.entries(mapping))
394
+ c[ck] = c[ck] || source[sk] || null;
395
+ }
396
+
397
+ /** Maps an ID3v2 frame value to the common tags layer. */
398
+ function mapCommonId3(report, frameId, text) {
399
+ const field = ID3_TO_COMMON[frameId];
400
+ if (field) report.tags.common[field] = report.tags.common[field] || text || null;
401
+ }
402
+
403
+ /** Decodes an ID3v2 COMM (Comments) frame. */
404
+ function decodeCommFrame(data) {
405
+ if (!data || data.length < 5) return null;
406
+ const enc = data[0];
407
+ const language = String.fromCharCode(data[1], data[2], data[3]);
408
+ const { valueBytes: descBytes, next } = readNullTerminated(data, 4, enc);
409
+ const short_description = decodeText(descBytes, enc).replace(/\u0000/g, "").trim() || null;
410
+ const text = decodeText(data.slice(next), enc).replace(/\u0000/g, "").trim() || null;
411
+ return { language, short_description, text };
412
+ }
413
+
414
+ /** Normalizes TLEN to integer milliseconds. */
415
+ function normalizeTlen(s) {
416
+ if (!s) return null;
417
+ if (/^\s*\d+\s*$/.test(s)) return parseInt(s.trim(), 10);
418
+ const f = Number(s);
419
+ if (Number.isFinite(f) && f > 0 && f < 100000) return Math.round(f * 1000);
420
+ return null;
421
+ }
422
+
423
+ /** Parses the ID3v2 tag header and frames. */
424
+ function parseId3v2(mp3) {
425
+ if (mp3.length < 10 || mp3[0] !== 0x49 || mp3[1] !== 0x44 || mp3[2] !== 0x33) return null;
426
+
427
+ const major = mp3[3], minor = mp3[4], flags = mp3[5];
428
+ const tagSize = synchsafeToInt(mp3[6], mp3[7], mp3[8], mp3[9]);
429
+ let offset = 10;
430
+ const end = 10 + tagSize;
431
+
432
+ const frames = [];
433
+ while (offset + 10 <= end) {
434
+ const id = String.fromCharCode(mp3[offset], mp3[offset + 1], mp3[offset + 2], mp3[offset + 3]);
435
+ if (!/^[A-Z0-9]{4}$/.test(id)) break;
436
+ const size = major === 4 ?
437
+ synchsafeToInt(mp3[offset + 4], mp3[offset + 5], mp3[offset + 6], mp3[offset + 7]) :
438
+ u32be(mp3, offset + 4);
439
+ offset += 10;
440
+ if (size <= 0 || offset + size > mp3.length) break;
441
+ frames.push({ id, size, data: mp3.slice(offset, offset + size) });
442
+ offset += size;
443
+ }
444
+
445
+ return { header: { version: `${major}.${minor}`, flags, tag_size: tagSize }, frames };
446
+ }
447
+
448
+ /** Parses the 128-byte ID3v1 tag at the end of the file. */
449
+ function parseId3v1(b) {
450
+ if (b.length < 128) return null;
451
+ const off = b.length - 128;
452
+ if (b[off] !== 0x54 || b[off + 1] !== 0x41 || b[off + 2] !== 0x47) return null;
453
+
454
+ let track = null;
455
+ if (b[off + 125] === 0x00 && b[off + 126] !== 0x00) track = String(b[off + 126]);
456
+
457
+ return {
458
+ title: decodeLatin1Trim(b.slice(off + 3, off + 33)),
459
+ artist: decodeLatin1Trim(b.slice(off + 33, off + 63)),
460
+ album: decodeLatin1Trim(b.slice(off + 63, off + 93)),
461
+ year: decodeLatin1Trim(b.slice(off + 93, off + 97)),
462
+ comment: decodeLatin1Trim(b.slice(off + 97, off + 127)),
463
+ track, genre: String(b[off + 127]),
464
+ };
465
+ }
466
+
467
+ /** Decodes an ID3v2 TXXX (user-defined text) frame. */
468
+ function decodeTxxx(data) {
469
+ if (!data || data.length < 2) return null;
470
+ const enc = data[0];
471
+ const { valueBytes: descBytes, next } = readNullTerminated(data, 1, enc);
472
+ const desc = decodeText(descBytes, enc).replace(/\u0000/g, "").trim();
473
+ const val = decodeText(data.slice(next), enc).replace(/\u0000/g, "").trim();
474
+ return { description: desc || null, value: val || null };
475
+ }
476
+
477
+ /** Best-effort APEv2 tag parser scanning the last 32 KB. */
478
+ function parseApeV2BestEffort(b) {
479
+ const scanStart = Math.max(0, b.length - 32768);
480
+ const idx = indexOfAscii(b, "APETAGEX", scanStart, b.length);
481
+ if (idx < 0) return null;
482
+ if (idx + 32 > b.length) return { present: true, warning: "APETAGEX found but footer truncated." };
483
+
484
+ const ver = u32le(b, idx + 8), size = u32le(b, idx + 12);
485
+ const count = u32le(b, idx + 16), flags = u32le(b, idx + 20);
486
+
487
+ const tagStart = idx + 32 - size;
488
+ if (tagStart < 0 || tagStart >= b.length)
489
+ return { present: true, version: ver, size, count, flags, warning: "APEv2 bounds invalid (non-standard placement)." };
490
+
491
+ const items = [];
492
+ let off = tagStart + 32;
493
+ const end = Math.min(b.length, idx);
494
+ while (off + 8 < end && items.length < 5000) {
495
+ const valueSize = u32le(b, off), itemFlags = u32le(b, off + 4);
496
+ off += 8;
497
+ let keyEnd = off;
498
+ while (keyEnd < end && b[keyEnd] !== 0x00) keyEnd++;
499
+ const key = decodeLatin1Trim(b.slice(off, keyEnd));
500
+ off = keyEnd + 1;
501
+ if (!key || off + valueSize > end) break;
502
+ const value = safeUtf8(b.slice(off, off + valueSize)).replace(/\u0000/g, "").trim();
503
+ off += valueSize;
504
+ items.push({ key, value, flags: itemFlags });
505
+ }
506
+
507
+ return { present: true, version: ver, size, count, flags, items };
508
+ }
509
+
510
+ /** Enumerates RIFF-style chunks (id + LE32 size) within a byte range, padding to even. */
511
+ function enumerateChunks(b, start, end, maxCount) {
512
+ const chunks = [];
513
+ let off = start;
514
+ while (off + 8 <= end && chunks.length < maxCount) {
515
+ const id = ascii4(b, off);
516
+ const size = u32le(b, off + 4);
517
+ const dataOff = off + 8;
518
+ if (dataOff + size > end) break;
519
+ chunks.push({ id, size, dataOff });
520
+ off = dataOff + size + (size % 2);
521
+ }
522
+ return chunks;
523
+ }
524
+
525
+ /** Parses a BWF bext chunk. */
526
+ function parseBext(b, off, size) {
527
+ const slice = b.slice(off, off + size);
528
+ const timeRefLow = u32le(slice, 338), timeRefHigh = u32le(slice, 342);
529
+ return {
530
+ description: decodeLatin1Trim(slice.slice(0, 256)) || null,
531
+ originator: decodeLatin1Trim(slice.slice(256, 288)) || null,
532
+ originator_reference: decodeLatin1Trim(slice.slice(288, 320)) || null,
533
+ origination_date: decodeLatin1Trim(slice.slice(320, 330)) || null,
534
+ origination_time: decodeLatin1Trim(slice.slice(330, 338)) || null,
535
+ time_reference_samples: ((BigInt(timeRefHigh) << 32n) | BigInt(timeRefLow)).toString(),
536
+ };
537
+ }
538
+
539
+ const FLAC_TYPE_NAMES = { 0: "STREAMINFO", 1: "PADDING", 2: "APPLICATION", 3: "SEEKTABLE", 4: "VORBIS_COMMENT", 5: "CUESHEET", 6: "PICTURE" };
540
+
541
+ /** Parses FLAC metadata blocks following the "fLaC" marker. */
542
+ function parseFlacMetaBlocks(b) {
543
+ const blocks = [];
544
+ let off = 4;
545
+ while (off + 4 <= b.length && blocks.length < 10000) {
546
+ const header = b[off];
547
+ const isLast = (header & 0x80) !== 0;
548
+ const type = header & 0x7f;
549
+ const len = (b[off + 1] << 16) | (b[off + 2] << 8) | b[off + 3];
550
+ off += 4;
551
+ if (off + len > b.length) break;
552
+ blocks.push({ type, typeName: FLAC_TYPE_NAMES[type] || `TYPE_${type}`, length: len, data: b.slice(off, off + len) });
553
+ off += len;
554
+ if (isLast) break;
555
+ }
556
+ return blocks;
557
+ }
558
+
559
+ /** Parses a Vorbis Comment block (used by FLAC and OGG). */
560
+ function parseVorbisComment(buf) {
561
+ let off = 0;
562
+ const vendorLen = u32le(buf, off); off += 4;
563
+ if (off + vendorLen > buf.length) return { vendor: null, comments: [], warning: "vendor_len out of bounds" };
564
+ const vendor = safeUtf8(buf.slice(off, off + vendorLen)); off += vendorLen;
565
+ const count = u32le(buf, off); off += 4;
566
+
567
+ const comments = [];
568
+ for (let i = 0; i < count && off + 4 <= buf.length && comments.length < 20000; i++) {
569
+ const l = u32le(buf, off); off += 4;
570
+ if (off + l > buf.length) break;
571
+ const s = safeUtf8(buf.slice(off, off + l)); off += l;
572
+ const eq = s.indexOf("=");
573
+ if (eq > 0) comments.push({ key: s.slice(0, eq).toUpperCase(), value: s.slice(eq + 1) });
574
+ }
575
+ return { vendor, comments };
576
+ }
577
+
578
+ /** Maps Vorbis Comment fields to the common tags layer. */
579
+ function mapVorbisCommon(report, vc) {
580
+ const c = report.tags.common;
581
+ for (const [vk, ck] of Object.entries(VORBIS_TO_COMMON))
582
+ c[ck] = c[ck] || vc.comments?.find((x) => x.key === vk)?.value || null;
583
+ }
584
+
585
+ /** Parses a FLAC PICTURE metadata block (extracts mime, description, data length). */
586
+ function parseFlacPicture(data, maxTextBytes) {
587
+ let off = 4;
588
+ const mimeLen = u32be(data, off); off += 4;
589
+ const mime = safeUtf8(data.slice(off, off + Math.min(mimeLen, maxTextBytes))); off += mimeLen;
590
+ const descLen = u32be(data, off); off += 4;
591
+ const description = safeUtf8(data.slice(off, off + Math.min(descLen, maxTextBytes))); off += descLen + 16;
592
+ return { mime, description, dataLength: u32be(data, off) };
593
+ }
594
+
595
+ /** Parses the ASF Content Description Object fields. */
596
+ function parseAsfContentDescription(b, off) {
597
+ const titleLen = u16le(b, off), authorLen = u16le(b, off + 2);
598
+ const copyrightLen = u16le(b, off + 4), descLen = u16le(b, off + 6), ratingLen = u16le(b, off + 8);
599
+ let pos = off + 10;
600
+ const title = decodeUtf16LE(b, pos, titleLen); pos += titleLen;
601
+ const author = decodeUtf16LE(b, pos, authorLen); pos += authorLen;
602
+ const copyright = decodeUtf16LE(b, pos, copyrightLen); pos += copyrightLen;
603
+ const description = decodeUtf16LE(b, pos, descLen); pos += descLen;
604
+ const rating = decodeUtf16LE(b, pos, ratingLen);
605
+ return { title, author, copyright, description, rating };
606
+ }
607
+
608
+ /** Parses the ASF Extended Content Description Object descriptors. */
609
+ function parseAsfExtContentDescription(b, off, end) {
610
+ const count = u16le(b, off);
611
+ let pos = off + 2;
612
+ const descriptors = [];
613
+ for (let i = 0; i < count && pos + 6 <= end && descriptors.length < 5000; i++) {
614
+ const nameLen = u16le(b, pos); pos += 2;
615
+ if (pos + nameLen > end) break;
616
+ const name = decodeUtf16LE(b, pos, nameLen); pos += nameLen;
617
+ const valueType = u16le(b, pos); pos += 2;
618
+ const valueLen = u16le(b, pos); pos += 2;
619
+ if (pos + valueLen > end) break;
620
+ let value;
621
+ if (valueType === 0) value = decodeUtf16LE(b, pos, valueLen);
622
+ else if (valueType === 3) value = u32le(b, pos);
623
+ else if (valueType === 5) value = u16le(b, pos);
624
+ else if (valueType === 2) value = u32le(b, pos) !== 0;
625
+ else value = `(${valueLen} bytes, type ${valueType})`;
626
+ pos += valueLen;
627
+ descriptors.push({ name, value_type: valueType, value });
628
+ }
629
+ return descriptors;
630
+ }