@awak-app/simy-cli 0.2.3 → 0.4.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.
@@ -0,0 +1,738 @@
1
+ import { createHash } from "node:crypto";
2
+ import path from "node:path";
3
+ import { inflateRawSync } from "node:zlib";
4
+
5
+ const MIB = 1024 * 1024;
6
+ const SHA256_PATTERN = /^[a-f0-9]{64}$/i;
7
+ const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/;
8
+ const TEXT_DECODER = new TextDecoder("utf-8", { fatal: true });
9
+ const ZIP_MAX_ENTRIES = 1_000;
10
+ const ZIP_MAX_DECLARED_UNCOMPRESSED_BYTES = 200 * MIB;
11
+ const OOXML_CONTENT_TYPES_MAX_BYTES = 2 * MIB;
12
+
13
+ export const LOCAL_TASK_FILE_CAPABILITY_VERSION =
14
+ "local_task_file_capabilities.v1";
15
+ export const LOCAL_TASK_ATTACHMENT_REF_VERSION =
16
+ "local_task_attachment_ref.v1";
17
+
18
+ const RECOVERY_ACTIONS = deepFreeze({
19
+ choose_supported_file: {
20
+ id: "choose_supported_file",
21
+ label: "Choose another file",
22
+ description: "Choose a file type listed as supported by SIMY.",
23
+ },
24
+ reduce_file_size: {
25
+ id: "reduce_file_size",
26
+ label: "Use a smaller file",
27
+ description: "Compress, shorten, or split the file before trying again.",
28
+ },
29
+ remove_extra_files: {
30
+ id: "remove_extra_files",
31
+ label: "Use fewer files",
32
+ description: "Remove some files or combine them into a smaller supported file.",
33
+ },
34
+ reattach_file: {
35
+ id: "reattach_file",
36
+ label: "Attach the files again",
37
+ description: "Attach the original files again so SIMY can verify a fresh copy.",
38
+ },
39
+ update_cli: {
40
+ id: "update_cli",
41
+ label: "Update SIMY CLI",
42
+ description: "Update SIMY CLI, restart it, and attach the files again.",
43
+ },
44
+ });
45
+
46
+ const CATEGORY_DEFINITIONS = [
47
+ category("text", {
48
+ label: "Text",
49
+ description: "Plain text, Markdown, JSON, and YAML documents.",
50
+ inputMaxBytes: 10 * MIB,
51
+ outputMaxBytes: 50 * MIB,
52
+ safety: {
53
+ active_content: false,
54
+ provider_treat_as_untrusted_data: true,
55
+ browser_presentation: "text_preview_or_download",
56
+ },
57
+ formats: [
58
+ format([".txt"], "text/plain", [], "utf8_text"),
59
+ format([".md", ".markdown"], "text/markdown", ["text/plain"], "utf8_text"),
60
+ format([".json"], "application/json", ["text/json", "text/plain"], "json"),
61
+ format(
62
+ [".yaml", ".yml"],
63
+ "application/yaml",
64
+ ["application/x-yaml", "text/yaml", "text/plain"],
65
+ "utf8_text",
66
+ ),
67
+ ],
68
+ }),
69
+ category("office", {
70
+ label: "Office document",
71
+ description: "Word, PowerPoint, and Rich Text documents.",
72
+ inputMaxBytes: 20 * MIB,
73
+ outputMaxBytes: 50 * MIB,
74
+ safety: {
75
+ active_content: "container_may_include_relationships",
76
+ provider_treat_as_untrusted_data: true,
77
+ browser_presentation: "download",
78
+ },
79
+ formats: [
80
+ format(
81
+ [".docx"],
82
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
83
+ ["application/octet-stream", "application/zip"],
84
+ "ooxml_word",
85
+ ),
86
+ format(
87
+ [".pptx"],
88
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
89
+ ["application/octet-stream", "application/zip"],
90
+ "ooxml_presentation",
91
+ ),
92
+ format([".rtf"], "application/rtf", ["text/rtf"], "rtf"),
93
+ ],
94
+ }),
95
+ category("pdf", {
96
+ label: "PDF",
97
+ description: "Portable Document Format documents.",
98
+ inputMaxBytes: 20 * MIB,
99
+ outputMaxBytes: 50 * MIB,
100
+ safety: {
101
+ active_content: "may_contain_actions_or_links",
102
+ provider_treat_as_untrusted_data: true,
103
+ browser_presentation: "pdf_preview_or_download",
104
+ },
105
+ formats: [format([".pdf"], "application/pdf", [], "pdf")],
106
+ }),
107
+ category("spreadsheet", {
108
+ label: "Spreadsheet",
109
+ description: "Excel, CSV, and tab-separated data.",
110
+ inputMaxBytes: 20 * MIB,
111
+ outputMaxBytes: 50 * MIB,
112
+ safety: {
113
+ active_content: "formulas_are_untrusted",
114
+ provider_treat_as_untrusted_data: true,
115
+ browser_presentation: "table_preview_or_download",
116
+ },
117
+ formats: [
118
+ format(
119
+ [".xlsx"],
120
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
121
+ ["application/octet-stream", "application/zip"],
122
+ "ooxml_spreadsheet",
123
+ ),
124
+ format(
125
+ [".csv"],
126
+ "text/csv",
127
+ ["application/csv", "application/vnd.ms-excel", "text/plain"],
128
+ "utf8_text",
129
+ ),
130
+ format([".tsv"], "text/tab-separated-values", ["text/plain"], "utf8_text"),
131
+ ],
132
+ }),
133
+ category("image", {
134
+ label: "Image",
135
+ description: "PNG, JPEG, GIF, WebP, and SVG images.",
136
+ inputMaxBytes: 20 * MIB,
137
+ outputMaxBytes: 50 * MIB,
138
+ safety: {
139
+ active_content: "svg_is_never_executed_or_inlined",
140
+ provider_treat_as_untrusted_data: true,
141
+ browser_presentation: "raster_preview_svg_download",
142
+ },
143
+ formats: [
144
+ format([".png"], "image/png", [], "png"),
145
+ format([".jpg", ".jpeg"], "image/jpeg", ["image/jpg"], "jpeg"),
146
+ format([".gif"], "image/gif", [], "gif"),
147
+ format([".webp"], "image/webp", [], "webp"),
148
+ format(
149
+ [".svg"],
150
+ "image/svg+xml",
151
+ ["application/xml", "text/xml"],
152
+ "svg",
153
+ ),
154
+ ],
155
+ }),
156
+ category("archive", {
157
+ label: "Archive",
158
+ description: "ZIP archives. Archive contents remain untrusted.",
159
+ inputMaxBytes: 20 * MIB,
160
+ outputMaxBytes: 50 * MIB,
161
+ safety: {
162
+ active_content: "contents_not_executed",
163
+ provider_treat_as_untrusted_data: true,
164
+ browser_presentation: "download",
165
+ },
166
+ formats: [
167
+ format(
168
+ [".zip"],
169
+ "application/zip",
170
+ ["application/x-zip-compressed", "multipart/x-zip"],
171
+ "zip",
172
+ ),
173
+ ],
174
+ }),
175
+ category("audio", {
176
+ label: "Audio",
177
+ description: "MP3, WAV, M4A, AAC, Ogg, and FLAC audio.",
178
+ inputMaxBytes: 50 * MIB,
179
+ outputMaxBytes: 50 * MIB,
180
+ safety: {
181
+ active_content: false,
182
+ provider_treat_as_untrusted_data: true,
183
+ browser_presentation: "audio_player_or_download",
184
+ },
185
+ formats: [
186
+ format([".mp3"], "audio/mpeg", ["audio/mp3"], "mp3"),
187
+ format([".wav"], "audio/wav", ["audio/x-wav"], "wav"),
188
+ format([".m4a"], "audio/mp4", ["audio/x-m4a"], "iso_bmff"),
189
+ format([".aac"], "audio/aac", [], "aac"),
190
+ format([".ogg"], "audio/ogg", ["application/ogg"], "ogg"),
191
+ format([".flac"], "audio/flac", ["audio/x-flac"], "flac"),
192
+ ],
193
+ }),
194
+ category("video", {
195
+ label: "Video",
196
+ description: "MP4, QuickTime, and WebM video.",
197
+ inputMaxBytes: 50 * MIB,
198
+ outputMaxBytes: 50 * MIB,
199
+ safety: {
200
+ active_content: false,
201
+ provider_treat_as_untrusted_data: true,
202
+ browser_presentation: "video_player_or_download",
203
+ },
204
+ formats: [
205
+ format([".mp4"], "video/mp4", [], "iso_bmff"),
206
+ format([".mov"], "video/quicktime", ["video/mov"], "iso_bmff"),
207
+ format([".webm"], "video/webm", [], "webm"),
208
+ ],
209
+ }),
210
+ ];
211
+
212
+ export const LOCAL_TASK_FILE_CAPABILITY_REGISTRY = deepFreeze({
213
+ schema_version: LOCAL_TASK_FILE_CAPABILITY_VERSION,
214
+ input: {
215
+ max_files: 5,
216
+ max_total_bytes: 50 * MIB,
217
+ max_filename_chars: 180,
218
+ reference_schema_version: LOCAL_TASK_ATTACHMENT_REF_VERSION,
219
+ require_sha256: true,
220
+ require_magic_match: true,
221
+ device_bound: true,
222
+ persistence: "cli_state_managed_copy",
223
+ },
224
+ output: {
225
+ max_files: 50,
226
+ max_total_bytes: 100 * MIB,
227
+ max_filename_chars: 512,
228
+ max_manifest_bytes: 250_000,
229
+ require_sha256: true,
230
+ require_magic_match: true,
231
+ local_paths_public: false,
232
+ },
233
+ presentation: {
234
+ default: "download",
235
+ never_inline_active_content: true,
236
+ content_disposition: "attachment",
237
+ nosniff: true,
238
+ },
239
+ recovery_actions: RECOVERY_ACTIONS,
240
+ safety: {
241
+ filenames_are_normalized: true,
242
+ extension_and_mime_must_match: true,
243
+ magic_bytes_must_match: true,
244
+ hashes_are_rechecked_at_handoff: true,
245
+ file_content_is_untrusted_data: true,
246
+ executables_and_html_are_not_supported: true,
247
+ },
248
+ categories: CATEGORY_DEFINITIONS,
249
+ });
250
+
251
+ const FORMATS_BY_EXTENSION = new Map();
252
+ for (const categoryValue of CATEGORY_DEFINITIONS) {
253
+ for (const formatValue of categoryValue.formats) {
254
+ for (const extension of formatValue.extensions) {
255
+ FORMATS_BY_EXTENSION.set(extension, {
256
+ category: categoryValue,
257
+ format: formatValue,
258
+ });
259
+ }
260
+ }
261
+ }
262
+
263
+ export class LocalTaskFileCapabilityError extends Error {
264
+ constructor(message, {
265
+ code = "local_task_file_invalid",
266
+ recoveryAction = RECOVERY_ACTIONS.choose_supported_file,
267
+ field = null,
268
+ } = {}) {
269
+ super(message);
270
+ this.name = "LocalTaskFileCapabilityError";
271
+ this.code = code;
272
+ this.recovery = recoveryAction.id;
273
+ this.recovery_action = recoveryAction;
274
+ this.field = field;
275
+ }
276
+ }
277
+
278
+ export function localTaskFileCapabilityMetadata() {
279
+ return structuredClone(LOCAL_TASK_FILE_CAPABILITY_REGISTRY);
280
+ }
281
+
282
+ export function normalizeLocalTaskMimeType(value) {
283
+ return String(value || "application/octet-stream")
284
+ .split(";", 1)[0]
285
+ .trim()
286
+ .toLowerCase();
287
+ }
288
+
289
+ export function canonicalMimeTypeForFilename(filename) {
290
+ return resolveFormat(filename).format.canonical_mime;
291
+ }
292
+
293
+ export function validateLocalTaskFile(input, {
294
+ direction = "input",
295
+ requireBytes = false,
296
+ } = {}) {
297
+ const profile = LOCAL_TASK_FILE_CAPABILITY_REGISTRY[direction];
298
+ if (!profile || !["input", "output"].includes(direction)) {
299
+ throw new TypeError("direction must be input or output");
300
+ }
301
+ const name = safeFilename(input?.name, profile.max_filename_chars);
302
+ const { extension, category, format: fileFormat } = resolveFormat(name);
303
+ const suppliedMime = normalizeLocalTaskMimeType(
304
+ input?.mime_type ?? input?.mimeType ?? input?.type,
305
+ );
306
+ const acceptedMimes = new Set([
307
+ fileFormat.canonical_mime,
308
+ ...fileFormat.mime_aliases,
309
+ ]);
310
+ if (!acceptedMimes.has(suppliedMime)) {
311
+ throw fileError(
312
+ `The file type reported for ${name} does not match ${extension}.`,
313
+ "local_task_file_mime_mismatch",
314
+ "choose_supported_file",
315
+ "mime_type",
316
+ );
317
+ }
318
+
319
+ const sizeBytes = Number(input?.size_bytes ?? input?.sizeBytes);
320
+ const maxBytes =
321
+ direction === "input" ? category.input_max_bytes : category.output_max_bytes;
322
+ if (!Number.isSafeInteger(sizeBytes) || sizeBytes < 1) {
323
+ throw fileError(
324
+ `${name} has an invalid file size.`,
325
+ "local_task_file_size_invalid",
326
+ "reattach_file",
327
+ "size_bytes",
328
+ );
329
+ }
330
+ if (sizeBytes > maxBytes) {
331
+ throw fileError(
332
+ `${name} is larger than the ${formatBytes(maxBytes)} ${category.label.toLowerCase()} limit.`,
333
+ "local_task_file_too_large",
334
+ "reduce_file_size",
335
+ "size_bytes",
336
+ );
337
+ }
338
+
339
+ const bytes = input?.bytes === undefined ? null : Buffer.from(input.bytes);
340
+ if (requireBytes && !bytes) {
341
+ throw fileError(
342
+ `${name} is no longer available on this desktop.`,
343
+ "local_task_file_not_found",
344
+ "reattach_file",
345
+ );
346
+ }
347
+ if (bytes) {
348
+ if (bytes.byteLength !== sizeBytes) {
349
+ throw fileError(
350
+ `${name} changed after it was attached.`,
351
+ "local_task_file_size_mismatch",
352
+ "reattach_file",
353
+ "size_bytes",
354
+ );
355
+ }
356
+ if (!matchesMagic(fileFormat.magic, bytes)) {
357
+ throw fileError(
358
+ `${name} does not contain the expected ${category.label.toLowerCase()} format.`,
359
+ "local_task_file_magic_mismatch",
360
+ "choose_supported_file",
361
+ );
362
+ }
363
+ }
364
+
365
+ const suppliedSha = input?.sha256 == null
366
+ ? null
367
+ : String(input.sha256).trim().toLowerCase();
368
+ if (suppliedSha !== null && !SHA256_PATTERN.test(suppliedSha)) {
369
+ throw fileError(
370
+ `${name} has an invalid integrity digest.`,
371
+ "local_task_file_hash_invalid",
372
+ "reattach_file",
373
+ "sha256",
374
+ );
375
+ }
376
+ if (!bytes && suppliedSha === null) {
377
+ throw fileError(
378
+ `${name} is missing its integrity digest.`,
379
+ "local_task_file_hash_invalid",
380
+ "reattach_file",
381
+ "sha256",
382
+ );
383
+ }
384
+ const actualSha = bytes
385
+ ? createHash("sha256").update(bytes).digest("hex")
386
+ : suppliedSha;
387
+ if (bytes && suppliedSha && actualSha !== suppliedSha) {
388
+ throw fileError(
389
+ `${name} changed after it was attached.`,
390
+ "local_task_file_hash_mismatch",
391
+ "reattach_file",
392
+ "sha256",
393
+ );
394
+ }
395
+
396
+ return {
397
+ name,
398
+ extension,
399
+ category: category.id,
400
+ mime_type: fileFormat.canonical_mime,
401
+ size_bytes: sizeBytes,
402
+ sha256: actualSha,
403
+ capability_version: LOCAL_TASK_FILE_CAPABILITY_VERSION,
404
+ };
405
+ }
406
+
407
+ export function validateLocalTaskFileCollection(files, {
408
+ direction = "input",
409
+ requireBytes = false,
410
+ } = {}) {
411
+ const values = Array.isArray(files) ? files : [];
412
+ const profile = LOCAL_TASK_FILE_CAPABILITY_REGISTRY[direction];
413
+ if (!profile) throw new TypeError("direction must be input or output");
414
+ if (values.length > profile.max_files) {
415
+ throw fileError(
416
+ `SIMY supports at most ${profile.max_files} ${direction} files.`,
417
+ "local_task_files_count_exceeded",
418
+ "remove_extra_files",
419
+ );
420
+ }
421
+ const validated = values.map((value) =>
422
+ validateLocalTaskFile(value, { direction, requireBytes }));
423
+ const totalBytes = validated.reduce((sum, value) => sum + value.size_bytes, 0);
424
+ if (totalBytes > profile.max_total_bytes) {
425
+ throw fileError(
426
+ `The ${direction} files are larger than the ${formatBytes(profile.max_total_bytes)} total limit.`,
427
+ "local_task_files_total_size_exceeded",
428
+ "remove_extra_files",
429
+ );
430
+ }
431
+ return validated;
432
+ }
433
+
434
+ export function localTaskFileErrorPayload(error) {
435
+ if (!(error instanceof LocalTaskFileCapabilityError)) {
436
+ return {
437
+ error: error instanceof Error ? error.message : String(error),
438
+ code: "local_task_file_invalid",
439
+ recovery: RECOVERY_ACTIONS.choose_supported_file.id,
440
+ recovery_action: RECOVERY_ACTIONS.choose_supported_file,
441
+ };
442
+ }
443
+ return {
444
+ error: error.message,
445
+ code: error.code,
446
+ recovery: error.recovery_action.id,
447
+ recovery_action: error.recovery_action,
448
+ ...(error.field ? { field: error.field } : {}),
449
+ };
450
+ }
451
+
452
+ function category(id, {
453
+ label,
454
+ description,
455
+ inputMaxBytes,
456
+ outputMaxBytes,
457
+ safety,
458
+ formats,
459
+ }) {
460
+ return {
461
+ id,
462
+ label,
463
+ description,
464
+ input_max_bytes: inputMaxBytes,
465
+ output_max_bytes: outputMaxBytes,
466
+ presentation: safety.browser_presentation,
467
+ recovery_action_ids: ["choose_supported_file", "reduce_file_size"],
468
+ safety,
469
+ formats,
470
+ };
471
+ }
472
+
473
+ function format(extensions, canonicalMime, aliases, magic) {
474
+ return {
475
+ extensions,
476
+ canonical_mime: canonicalMime,
477
+ mime_aliases: aliases,
478
+ magic: {
479
+ policy: "required",
480
+ detector: magic,
481
+ },
482
+ };
483
+ }
484
+
485
+ function safeFilename(value, maxChars) {
486
+ const name = String(value || "").normalize("NFKC").trim();
487
+ if (
488
+ !name ||
489
+ name !== path.basename(name) ||
490
+ name === "." ||
491
+ name === ".." ||
492
+ CONTROL_CHARACTER_PATTERN.test(name)
493
+ ) {
494
+ throw fileError(
495
+ "The attachment has an invalid file name.",
496
+ "local_task_file_name_invalid",
497
+ "choose_supported_file",
498
+ "name",
499
+ );
500
+ }
501
+ if (name.length > maxChars) {
502
+ throw fileError(
503
+ `The file name is longer than ${maxChars} characters.`,
504
+ "local_task_file_name_too_long",
505
+ "choose_supported_file",
506
+ "name",
507
+ );
508
+ }
509
+ return name;
510
+ }
511
+
512
+ function resolveFormat(filename) {
513
+ const extension = path.extname(filename).toLowerCase();
514
+ const resolved = FORMATS_BY_EXTENSION.get(extension);
515
+ if (!resolved) {
516
+ throw fileError(
517
+ `${filename} is not a supported Local Task file type.`,
518
+ "local_task_file_extension_unsupported",
519
+ "choose_supported_file",
520
+ "name",
521
+ );
522
+ }
523
+ return { extension, ...resolved };
524
+ }
525
+
526
+ function matchesMagic(magicContract, bytes) {
527
+ const detector = magicContract.detector;
528
+ if (detector === "pdf") return startsWithAscii(bytes, "%PDF-");
529
+ if (detector === "png") {
530
+ return startsWithBytes(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
531
+ }
532
+ if (detector === "jpeg") return startsWithBytes(bytes, [0xff, 0xd8, 0xff]);
533
+ if (detector === "gif") {
534
+ return startsWithAscii(bytes, "GIF87a") || startsWithAscii(bytes, "GIF89a");
535
+ }
536
+ if (detector === "webp") {
537
+ return startsWithAscii(bytes, "RIFF") && asciiAt(bytes, 8, 4) === "WEBP";
538
+ }
539
+ if (detector === "svg") return safeText(bytes, (text) => /<svg(?:\s|>)/i.test(text));
540
+ if (detector === "rtf") return startsWithAscii(bytes, "{\\rtf");
541
+ if (detector === "zip") return isZip(bytes);
542
+ if (detector.startsWith("ooxml_")) {
543
+ const required = {
544
+ ooxml_word: {
545
+ root: "word/document.xml",
546
+ contentType:
547
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml",
548
+ },
549
+ ooxml_presentation: {
550
+ root: "ppt/presentation.xml",
551
+ contentType:
552
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml",
553
+ },
554
+ ooxml_spreadsheet: {
555
+ root: "xl/workbook.xml",
556
+ contentType:
557
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml",
558
+ },
559
+ }[detector];
560
+ return matchesOoxmlPackage(bytes, required);
561
+ }
562
+ if (detector === "mp3") {
563
+ return startsWithAscii(bytes, "ID3") ||
564
+ (bytes.length >= 2 && bytes[0] === 0xff && (bytes[1] & 0xe0) === 0xe0);
565
+ }
566
+ if (detector === "wav") {
567
+ return startsWithAscii(bytes, "RIFF") && asciiAt(bytes, 8, 4) === "WAVE";
568
+ }
569
+ if (detector === "iso_bmff") return asciiAt(bytes, 4, 4) === "ftyp";
570
+ if (detector === "aac") {
571
+ return bytes.length >= 2 && bytes[0] === 0xff && (bytes[1] & 0xf6) === 0xf0;
572
+ }
573
+ if (detector === "ogg") return startsWithAscii(bytes, "OggS");
574
+ if (detector === "flac") return startsWithAscii(bytes, "fLaC");
575
+ if (detector === "webm") return startsWithBytes(bytes, [0x1a, 0x45, 0xdf, 0xa3]);
576
+ if (detector === "json") {
577
+ return safeText(bytes, (text) => {
578
+ try {
579
+ JSON.parse(text);
580
+ return true;
581
+ } catch {
582
+ return false;
583
+ }
584
+ });
585
+ }
586
+ return detector === "utf8_text" && safeText(bytes, () => true);
587
+ }
588
+
589
+ function safeText(bytes, predicate) {
590
+ if (bytes.includes(0)) return false;
591
+ try {
592
+ return predicate(TEXT_DECODER.decode(bytes));
593
+ } catch {
594
+ return false;
595
+ }
596
+ }
597
+
598
+ function isZip(bytes) {
599
+ return (
600
+ startsWithBytes(bytes, [0x50, 0x4b, 0x03, 0x04]) ||
601
+ startsWithBytes(bytes, [0x50, 0x4b, 0x05, 0x06]) ||
602
+ startsWithBytes(bytes, [0x50, 0x4b, 0x07, 0x08])
603
+ );
604
+ }
605
+
606
+ function matchesOoxmlPackage(bytes, required) {
607
+ if (!required || !isZip(bytes)) return false;
608
+ try {
609
+ const entries = readZipCentralDirectory(bytes);
610
+ const contentTypesEntry = entries.get("[Content_Types].xml");
611
+ if (!contentTypesEntry || !entries.has(required.root)) return false;
612
+ const contentTypes = readZipEntry(bytes, contentTypesEntry, {
613
+ maxBytes: OOXML_CONTENT_TYPES_MAX_BYTES,
614
+ }).toString("utf8");
615
+ return contentTypes.includes(required.contentType);
616
+ } catch {
617
+ return false;
618
+ }
619
+ }
620
+
621
+ function readZipCentralDirectory(bytes) {
622
+ const minimumEocdSize = 22;
623
+ if (bytes.length < minimumEocdSize) throw new Error("invalid zip");
624
+ const searchStart = Math.max(0, bytes.length - (0xffff + minimumEocdSize));
625
+ let eocd = -1;
626
+ for (let offset = bytes.length - minimumEocdSize; offset >= searchStart; offset -= 1) {
627
+ if (bytes.readUInt32LE(offset) === 0x06054b50) {
628
+ eocd = offset;
629
+ break;
630
+ }
631
+ }
632
+ if (eocd < 0) throw new Error("zip end record not found");
633
+ const entryCount = bytes.readUInt16LE(eocd + 10);
634
+ const centralSize = bytes.readUInt32LE(eocd + 12);
635
+ const centralOffset = bytes.readUInt32LE(eocd + 16);
636
+ if (
637
+ entryCount === 0xffff ||
638
+ centralSize === 0xffffffff ||
639
+ centralOffset === 0xffffffff ||
640
+ entryCount > ZIP_MAX_ENTRIES ||
641
+ centralOffset + centralSize > eocd
642
+ ) {
643
+ throw new Error("unsupported zip directory");
644
+ }
645
+
646
+ const entries = new Map();
647
+ let offset = centralOffset;
648
+ let declaredUncompressedBytes = 0;
649
+ for (let index = 0; index < entryCount; index += 1) {
650
+ if (offset + 46 > bytes.length || bytes.readUInt32LE(offset) !== 0x02014b50) {
651
+ throw new Error("invalid zip directory entry");
652
+ }
653
+ const compressionMethod = bytes.readUInt16LE(offset + 10);
654
+ const compressedSize = bytes.readUInt32LE(offset + 20);
655
+ const uncompressedSize = bytes.readUInt32LE(offset + 24);
656
+ const filenameLength = bytes.readUInt16LE(offset + 28);
657
+ const extraLength = bytes.readUInt16LE(offset + 30);
658
+ const commentLength = bytes.readUInt16LE(offset + 32);
659
+ const localHeaderOffset = bytes.readUInt32LE(offset + 42);
660
+ const end = offset + 46 + filenameLength + extraLength + commentLength;
661
+ if (end > bytes.length) throw new Error("invalid zip entry bounds");
662
+ const name = bytes.subarray(offset + 46, offset + 46 + filenameLength).toString("utf8");
663
+ if (!name || name.includes("\0") || entries.has(name)) {
664
+ throw new Error("invalid zip entry name");
665
+ }
666
+ declaredUncompressedBytes += uncompressedSize;
667
+ if (declaredUncompressedBytes > ZIP_MAX_DECLARED_UNCOMPRESSED_BYTES) {
668
+ throw new Error("zip expansion limit exceeded");
669
+ }
670
+ entries.set(name, {
671
+ compressionMethod,
672
+ compressedSize,
673
+ uncompressedSize,
674
+ localHeaderOffset,
675
+ });
676
+ offset = end;
677
+ }
678
+ if (offset !== centralOffset + centralSize) throw new Error("invalid zip directory size");
679
+ return entries;
680
+ }
681
+
682
+ function readZipEntry(bytes, entry, { maxBytes }) {
683
+ if (
684
+ entry.uncompressedSize > maxBytes ||
685
+ entry.localHeaderOffset + 30 > bytes.length ||
686
+ bytes.readUInt32LE(entry.localHeaderOffset) !== 0x04034b50
687
+ ) {
688
+ throw new Error("invalid zip local entry");
689
+ }
690
+ const filenameLength = bytes.readUInt16LE(entry.localHeaderOffset + 26);
691
+ const extraLength = bytes.readUInt16LE(entry.localHeaderOffset + 28);
692
+ const dataOffset = entry.localHeaderOffset + 30 + filenameLength + extraLength;
693
+ const dataEnd = dataOffset + entry.compressedSize;
694
+ if (dataEnd > bytes.length) throw new Error("invalid zip data bounds");
695
+ const compressed = bytes.subarray(dataOffset, dataEnd);
696
+ const decoded =
697
+ entry.compressionMethod === 0
698
+ ? Buffer.from(compressed)
699
+ : entry.compressionMethod === 8
700
+ ? inflateRawSync(compressed, { maxOutputLength: maxBytes })
701
+ : null;
702
+ if (!decoded || decoded.length !== entry.uncompressedSize || decoded.length > maxBytes) {
703
+ throw new Error("invalid zip entry payload");
704
+ }
705
+ return decoded;
706
+ }
707
+
708
+ function startsWithAscii(bytes, value) {
709
+ return bytes.subarray(0, Buffer.byteLength(value)).equals(Buffer.from(value));
710
+ }
711
+
712
+ function asciiAt(bytes, offset, length) {
713
+ return bytes.subarray(offset, offset + length).toString("ascii");
714
+ }
715
+
716
+ function startsWithBytes(bytes, values) {
717
+ return bytes.length >= values.length &&
718
+ values.every((value, index) => bytes[index] === value);
719
+ }
720
+
721
+ function fileError(message, code, actionId, field = null) {
722
+ return new LocalTaskFileCapabilityError(message, {
723
+ code,
724
+ recoveryAction: RECOVERY_ACTIONS[actionId],
725
+ field,
726
+ });
727
+ }
728
+
729
+ function formatBytes(value) {
730
+ return value % MIB === 0 ? `${value / MIB} MiB` : `${value} bytes`;
731
+ }
732
+
733
+ function deepFreeze(value) {
734
+ if (!value || typeof value !== "object" || Object.isFrozen(value)) return value;
735
+ Object.freeze(value);
736
+ for (const child of Object.values(value)) deepFreeze(child);
737
+ return value;
738
+ }