@oh-my-pi/pi-utils 17.4.0 → 17.4.2

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 (82) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +22 -0
  3. package/THIRD-PARTY-NOTICES.txt +22909 -0
  4. package/dist/types/ar/arj.d.ts +5 -0
  5. package/dist/types/ar/asar.d.ts +7 -0
  6. package/dist/types/ar/bytes.d.ts +20 -0
  7. package/dist/types/ar/cab.d.ts +5 -0
  8. package/dist/types/ar/checksums.d.ts +10 -0
  9. package/dist/types/ar/codecs/bzip2.d.ts +4 -0
  10. package/dist/types/ar/codecs/gzip.d.ts +6 -0
  11. package/dist/types/ar/codecs/lzma.d.ts +6 -0
  12. package/dist/types/ar/codecs/lzw.d.ts +4 -0
  13. package/dist/types/ar/codecs/lzx.d.ts +7 -0
  14. package/dist/types/ar/codecs/xz.d.ts +4 -0
  15. package/dist/types/ar/codecs/zstd.d.ts +6 -0
  16. package/dist/types/ar/cpio.d.ts +7 -0
  17. package/dist/types/ar/deb.d.ts +5 -0
  18. package/dist/types/ar/entries.d.ts +22 -0
  19. package/dist/types/ar/error.d.ts +8 -0
  20. package/dist/types/ar/index.d.ts +11 -0
  21. package/dist/types/ar/iso.d.ts +5 -0
  22. package/dist/types/ar/limits.d.ts +33 -0
  23. package/dist/types/ar/lzh.d.ts +7 -0
  24. package/dist/types/ar/open.d.ts +45 -0
  25. package/dist/types/ar/paths.d.ts +18 -0
  26. package/dist/types/ar/rar/rar4-decoder.d.ts +6 -0
  27. package/dist/types/ar/rar/rar5-decoder.d.ts +6 -0
  28. package/dist/types/ar/rar.d.ts +5 -0
  29. package/dist/types/ar/reader.d.ts +28 -0
  30. package/dist/types/ar/registry.d.ts +17 -0
  31. package/dist/types/ar/rpm.d.ts +5 -0
  32. package/dist/types/ar/sevenzip/decode.d.ts +33 -0
  33. package/dist/types/ar/sevenzip.d.ts +5 -0
  34. package/dist/types/ar/source.d.ts +54 -0
  35. package/dist/types/ar/tar.d.ts +9 -0
  36. package/dist/types/ar/types.d.ts +99 -0
  37. package/dist/types/ar/unix-ar.d.ts +7 -0
  38. package/dist/types/ar/write.d.ts +10 -0
  39. package/dist/types/ar/zip.d.ts +10 -0
  40. package/dist/types/postmortem.d.ts +33 -0
  41. package/package.json +9 -3
  42. package/src/ar/arj.ts +314 -0
  43. package/src/ar/asar.ts +480 -0
  44. package/src/ar/bytes.ts +78 -0
  45. package/src/ar/cab.ts +377 -0
  46. package/src/ar/checksums.ts +62 -0
  47. package/src/ar/codecs/bzip2.ts +489 -0
  48. package/src/ar/codecs/gzip.ts +25 -0
  49. package/src/ar/codecs/lzma.ts +437 -0
  50. package/src/ar/codecs/lzw.ts +191 -0
  51. package/src/ar/codecs/lzx.ts +366 -0
  52. package/src/ar/codecs/xz.ts +522 -0
  53. package/src/ar/codecs/zstd.ts +25 -0
  54. package/src/ar/cpio.ts +389 -0
  55. package/src/ar/deb.ts +160 -0
  56. package/src/ar/entries.ts +97 -0
  57. package/src/ar/error.ts +11 -0
  58. package/src/ar/index.ts +16 -0
  59. package/src/ar/iso.ts +712 -0
  60. package/src/ar/limits.ts +80 -0
  61. package/src/ar/lzh.ts +659 -0
  62. package/src/ar/open.ts +225 -0
  63. package/src/ar/paths.ts +70 -0
  64. package/src/ar/rar/rar4-decoder.ts +459 -0
  65. package/src/ar/rar/rar5-decoder.ts +400 -0
  66. package/src/ar/rar.ts +735 -0
  67. package/src/ar/reader.ts +162 -0
  68. package/src/ar/registry.ts +208 -0
  69. package/src/ar/rpm.ts +320 -0
  70. package/src/ar/sevenzip/decode.ts +239 -0
  71. package/src/ar/sevenzip.ts +634 -0
  72. package/src/ar/source.ts +190 -0
  73. package/src/ar/tar.ts +771 -0
  74. package/src/ar/types.ts +131 -0
  75. package/src/ar/unix-ar.ts +312 -0
  76. package/src/ar/write.ts +55 -0
  77. package/src/ar/zip.ts +719 -0
  78. package/src/browsers.ts +2 -149
  79. package/src/docx/converter.ts +9 -9
  80. package/src/postmortem.ts +74 -0
  81. package/dist/types/docx/zip.d.ts +0 -6
  82. package/src/docx/zip.ts +0 -87
package/src/ar/asar.ts ADDED
@@ -0,0 +1,480 @@
1
+ import * as path from "node:path";
2
+ import { readUInt32LE, writeUInt32LE } from "./bytes";
3
+ import { ArchiveError } from "./error";
4
+ import { assertArchiveMemberSize, assertEntryCount, assertIndexSize } from "./limits";
5
+ import {
6
+ assertArchivePathBytes,
7
+ assertArchivePathString,
8
+ formatArchivePathForError,
9
+ normalizeArchiveEntryPath,
10
+ normalizeArchiveLookupPath,
11
+ } from "./paths";
12
+ import type { ByteSource } from "./source";
13
+ import type { ArchiveIndexEntry, FormatReader, FormatReadOptions, MemberSource } from "./types";
14
+
15
+ const ASAR_HEADER_DECODER = new TextDecoder("utf-8", { fatal: true });
16
+ const ASAR_HEADER_ENCODER = new TextEncoder();
17
+ const ASAR_PICKLE_PREFIX_SIZE = 8;
18
+ const ASAR_INNER_PREFIX_SIZE = 8;
19
+ const ASAR_JSON_OFFSET = ASAR_PICKLE_PREFIX_SIZE + ASAR_INNER_PREFIX_SIZE;
20
+ const ASAR_FILE_MODE = 0o100000;
21
+ const SHA256_HEX = /^[0-9a-f]{64}$/i;
22
+
23
+ interface AsarIntegrity {
24
+ algorithm: "SHA256";
25
+ hash: string;
26
+ }
27
+
28
+ interface AsarWorkItem {
29
+ parentPath: string;
30
+ parentPathBytes: number;
31
+ name: string;
32
+ node: unknown;
33
+ }
34
+
35
+ interface AsarFileNode {
36
+ size: number;
37
+ offset: string;
38
+ }
39
+
40
+ interface AsarDirectoryNode {
41
+ files: Record<string, AsarNode>;
42
+ }
43
+
44
+ type AsarNode = AsarFileNode | AsarDirectoryNode;
45
+
46
+ function invalidAsar(message: string): ArchiveError {
47
+ return new ArchiveError(`Invalid ASAR archive: ${message}`);
48
+ }
49
+
50
+ function describeError(error: unknown): string {
51
+ return error instanceof Error && error.message ? error.message : String(error);
52
+ }
53
+
54
+ function alignAsarPayload(size: number): number {
55
+ return size + ((4 - (size % 4)) % 4);
56
+ }
57
+
58
+ function isArchiveRecord(value: unknown): value is Record<string, unknown> {
59
+ return typeof value === "object" && value !== null && !Array.isArray(value);
60
+ }
61
+
62
+ function parseIntegrity(value: unknown, entryPath: string, size: number): AsarIntegrity | undefined {
63
+ if (value === undefined) return undefined;
64
+ const label = formatArchivePathForError(entryPath);
65
+ if (!isArchiveRecord(value)) {
66
+ throw invalidAsar(`file '${label}' has an invalid integrity record`);
67
+ }
68
+ if (value.algorithm !== "SHA256") {
69
+ throw invalidAsar(`file '${label}' uses unsupported integrity algorithm '${String(value.algorithm)}'`);
70
+ }
71
+ if (typeof value.hash !== "string" || !SHA256_HEX.test(value.hash)) {
72
+ throw invalidAsar(`file '${label}' has an invalid integrity hash`);
73
+ }
74
+ if (typeof value.blockSize !== "number" || !Number.isSafeInteger(value.blockSize) || value.blockSize <= 0) {
75
+ throw invalidAsar(`file '${label}' has an invalid integrity block size`);
76
+ }
77
+ if (!Array.isArray(value.blocks)) {
78
+ throw invalidAsar(`file '${label}' has invalid integrity blocks`);
79
+ }
80
+ const expectedBlocks = Math.max(1, Math.ceil(size / value.blockSize));
81
+ if (value.blocks.length !== expectedBlocks) {
82
+ throw invalidAsar(`file '${label}' has an inconsistent integrity block count`);
83
+ }
84
+ for (const block of value.blocks) {
85
+ if (typeof block !== "string" || !SHA256_HEX.test(block)) {
86
+ throw invalidAsar(`file '${label}' has an invalid integrity block hash`);
87
+ }
88
+ }
89
+ return { algorithm: "SHA256", hash: value.hash.toLowerCase() };
90
+ }
91
+
92
+ function verifyIntegrity(bytes: Uint8Array, integrity: AsarIntegrity | undefined, memberPath: string): void {
93
+ if (!integrity) return;
94
+ const actual = new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
95
+ if (actual !== integrity.hash) {
96
+ throw new ArchiveError(
97
+ `ASAR member '${formatArchivePathForError(memberPath)}' failed SHA256 integrity verification`,
98
+ );
99
+ }
100
+ }
101
+
102
+ class PackedAsarMemberSource implements MemberSource {
103
+ readonly #source: ByteSource;
104
+ readonly #offset: number;
105
+ readonly #size: number;
106
+ readonly #integrity?: AsarIntegrity;
107
+
108
+ constructor(source: ByteSource, offset: number, size: number, integrity: AsarIntegrity | undefined) {
109
+ this.#source = source;
110
+ this.#offset = offset;
111
+ this.#size = size;
112
+ this.#integrity = integrity;
113
+ }
114
+
115
+ async read(size: number, memberPath: string): Promise<Uint8Array> {
116
+ if (size !== this.#size) {
117
+ throw new ArchiveError(`ASAR member '${formatArchivePathForError(memberPath)}' has an inconsistent size`);
118
+ }
119
+ let bytes: Uint8Array;
120
+ try {
121
+ bytes = await this.#source.read(this.#offset, this.#offset + this.#size);
122
+ } catch (error) {
123
+ if (error instanceof ArchiveError) throw error;
124
+ throw new ArchiveError(
125
+ `Failed to read ASAR member '${formatArchivePathForError(memberPath)}': ${describeError(error)}`,
126
+ );
127
+ }
128
+ if (bytes.byteLength !== this.#size) {
129
+ throw new ArchiveError(`ASAR member '${formatArchivePathForError(memberPath)}' is truncated`);
130
+ }
131
+ verifyIntegrity(bytes, this.#integrity, memberPath);
132
+ return bytes;
133
+ }
134
+ }
135
+
136
+ class UnpackedAsarMemberSource implements MemberSource {
137
+ readonly #filePath?: string;
138
+ readonly #size: number;
139
+ readonly #integrity?: AsarIntegrity;
140
+
141
+ constructor(filePath: string | undefined, size: number, integrity: AsarIntegrity | undefined) {
142
+ this.#filePath = filePath;
143
+ this.#size = size;
144
+ this.#integrity = integrity;
145
+ }
146
+
147
+ async read(size: number, memberPath: string): Promise<Uint8Array> {
148
+ const label = formatArchivePathForError(memberPath);
149
+ if (size !== this.#size) {
150
+ throw new ArchiveError(`ASAR member '${label}' has an inconsistent size`);
151
+ }
152
+ if (!this.#filePath) {
153
+ throw new ArchiveError(`Archive file '${label}' is unpacked and requires a filesystem-backed ASAR archive`);
154
+ }
155
+ const file = Bun.file(this.#filePath);
156
+ const stat = await file.stat().catch(() => {
157
+ throw new ArchiveError(`Unpacked ASAR file '${label}' was not found`);
158
+ });
159
+ if (stat.isDirectory()) {
160
+ throw new ArchiveError(`Unpacked ASAR file '${label}' is a directory`);
161
+ }
162
+ if (stat.size !== this.#size) {
163
+ throw new ArchiveError(
164
+ `Unpacked ASAR file '${label}' size differs from its archive header (${stat.size} != ${this.#size} bytes)`,
165
+ );
166
+ }
167
+ let bytes: Uint8Array;
168
+ try {
169
+ bytes = await file.bytes();
170
+ } catch (error) {
171
+ throw new ArchiveError(`Failed to read unpacked ASAR file '${label}': ${describeError(error)}`);
172
+ }
173
+ if (bytes.byteLength !== this.#size) {
174
+ throw new ArchiveError(`Unpacked ASAR file '${label}' changed while being read`);
175
+ }
176
+ verifyIntegrity(bytes, this.#integrity, memberPath);
177
+ return bytes;
178
+ }
179
+ }
180
+
181
+ async function readAsarIndex(source: ByteSource, options: FormatReadOptions): Promise<ArchiveIndexEntry[]> {
182
+ if (!Number.isSafeInteger(source.size) || source.size < ASAR_JSON_OFFSET) {
183
+ throw invalidAsar("truncated header");
184
+ }
185
+
186
+ const sizePickle = await source.read(0, ASAR_PICKLE_PREFIX_SIZE);
187
+ if (sizePickle.byteLength !== ASAR_PICKLE_PREFIX_SIZE) {
188
+ throw invalidAsar("truncated size pickle");
189
+ }
190
+ if (readUInt32LE(sizePickle, 0) !== 4) {
191
+ throw invalidAsar("invalid size pickle");
192
+ }
193
+ const headerSize = readUInt32LE(sizePickle, 4);
194
+ if (headerSize < ASAR_INNER_PREFIX_SIZE) {
195
+ throw invalidAsar("header is too small");
196
+ }
197
+ assertIndexSize(headerSize, options.limits, "ASAR header");
198
+ const dataOffset = ASAR_PICKLE_PREFIX_SIZE + headerSize;
199
+ if (!Number.isSafeInteger(dataOffset) || dataOffset > source.size) {
200
+ throw invalidAsar("header extends beyond archive boundary");
201
+ }
202
+
203
+ const headerPickle = await source.read(ASAR_PICKLE_PREFIX_SIZE, dataOffset);
204
+ if (headerPickle.byteLength !== headerSize) {
205
+ throw invalidAsar("truncated header pickle");
206
+ }
207
+ const payloadSize = readUInt32LE(headerPickle, 0);
208
+ const jsonSize = readUInt32LE(headerPickle, 4);
209
+ if (payloadSize + 4 !== headerSize || payloadSize !== 4 + alignAsarPayload(jsonSize)) {
210
+ throw invalidAsar("inconsistent pickle lengths");
211
+ }
212
+ const jsonEnd = ASAR_INNER_PREFIX_SIZE + jsonSize;
213
+ for (let index = jsonEnd; index < headerPickle.byteLength; index++) {
214
+ if (headerPickle[index] !== 0) {
215
+ throw invalidAsar("non-zero pickle string padding");
216
+ }
217
+ }
218
+
219
+ let headerText: string;
220
+ try {
221
+ headerText = ASAR_HEADER_DECODER.decode(headerPickle.subarray(ASAR_INNER_PREFIX_SIZE, jsonEnd));
222
+ } catch {
223
+ throw invalidAsar("header is not valid UTF-8");
224
+ }
225
+ let header: unknown;
226
+ try {
227
+ header = JSON.parse(headerText);
228
+ } catch {
229
+ throw invalidAsar("header is not valid JSON");
230
+ }
231
+ if (!isArchiveRecord(header) || !isArchiveRecord(header.files)) {
232
+ throw invalidAsar("root must contain a files object");
233
+ }
234
+
235
+ const entries: ArchiveIndexEntry[] = [];
236
+ const work: AsarWorkItem[] = [];
237
+ let scheduled = 0;
238
+ const scheduleChildren = (files: Record<string, unknown>, parentPath: string, parentPathBytes: number): void => {
239
+ for (const name in files) {
240
+ assertEntryCount(++scheduled, options.limits);
241
+ work.push({ parentPath, parentPathBytes, name, node: files[name] });
242
+ }
243
+ };
244
+ scheduleChildren(header.files, "", 0);
245
+
246
+ while (work.length > 0) {
247
+ const current = work.pop()!;
248
+ const nameBytes = Buffer.byteLength(current.name, "utf-8");
249
+ assertArchivePathBytes(nameBytes, "entry name", options.limits.maxPathBytes);
250
+ if (
251
+ current.name.length === 0 ||
252
+ current.name === "." ||
253
+ current.name === ".." ||
254
+ current.name.includes("/") ||
255
+ current.name.includes("\\") ||
256
+ current.name.includes("\0")
257
+ ) {
258
+ throw invalidAsar(`invalid entry name '${formatArchivePathForError(current.name)}'`);
259
+ }
260
+ if (!isArchiveRecord(current.node)) {
261
+ throw invalidAsar(`entry '${formatArchivePathForError(current.name)}' must be an object`);
262
+ }
263
+ const entryPathBytes = current.parentPathBytes + (current.parentPath ? 1 : 0) + nameBytes;
264
+ assertArchivePathBytes(entryPathBytes, "member path", options.limits.maxPathBytes);
265
+ const entryPath = current.parentPath ? `${current.parentPath}/${current.name}` : current.name;
266
+ const normalizedPath = normalizeArchiveEntryPath(entryPath);
267
+ if (normalizedPath !== entryPath) {
268
+ throw invalidAsar(`invalid entry path '${formatArchivePathForError(entryPath)}'`);
269
+ }
270
+ const entryLabel = formatArchivePathForError(entryPath);
271
+ if (current.node.unpacked !== undefined && typeof current.node.unpacked !== "boolean") {
272
+ throw invalidAsar(`entry '${entryLabel}' has a non-boolean unpacked flag`);
273
+ }
274
+
275
+ if ("link" in current.node) {
276
+ if (typeof current.node.link !== "string" || current.node.link.length === 0) {
277
+ throw invalidAsar(`entry '${entryLabel}' has an invalid link target`);
278
+ }
279
+ assertArchivePathString(current.node.link, "link target", options.limits.maxPathBytes);
280
+ const portableTarget = current.node.link.replace(/\\/g, "/");
281
+ const targetPath =
282
+ portableTarget.startsWith("/") || /^[A-Za-z]:\//.test(portableTarget)
283
+ ? undefined
284
+ : normalizeArchiveLookupPath(portableTarget);
285
+ if (targetPath === undefined) {
286
+ throw invalidAsar(`entry '${entryLabel}' links outside the archive`);
287
+ }
288
+ entries.push({
289
+ path: entryPath,
290
+ isDirectory: false,
291
+ size: 0,
292
+ storage: { type: "link", targetPath, resolveTarget: true },
293
+ });
294
+ continue;
295
+ }
296
+
297
+ if ("files" in current.node) {
298
+ if (!isArchiveRecord(current.node.files)) {
299
+ throw invalidAsar(`directory '${entryLabel}' must contain a files object`);
300
+ }
301
+ entries.push({ path: entryPath, isDirectory: true, size: 0 });
302
+ scheduleChildren(current.node.files, entryPath, entryPathBytes);
303
+ continue;
304
+ }
305
+
306
+ const size = current.node.size;
307
+ if (typeof size !== "number" || !Number.isSafeInteger(size) || size < 0) {
308
+ throw invalidAsar(`file '${entryLabel}' has an invalid size`);
309
+ }
310
+ assertArchiveMemberSize(size, entryPath, options.limits);
311
+ if (current.node.executable !== undefined && typeof current.node.executable !== "boolean") {
312
+ throw invalidAsar(`file '${entryLabel}' has a non-boolean executable flag`);
313
+ }
314
+ const mode =
315
+ typeof current.node.executable === "boolean"
316
+ ? ASAR_FILE_MODE | (current.node.executable ? 0o755 : 0o644)
317
+ : undefined;
318
+ const integrity = parseIntegrity(current.node.integrity, entryPath, size);
319
+
320
+ if (current.node.unpacked === true) {
321
+ const filePath = options.archivePath
322
+ ? path.join(`${options.archivePath}.unpacked`, ...entryPath.split("/"))
323
+ : undefined;
324
+ entries.push({
325
+ path: entryPath,
326
+ isDirectory: false,
327
+ size,
328
+ mode,
329
+ storage: { type: "member", source: new UnpackedAsarMemberSource(filePath, size, integrity) },
330
+ });
331
+ continue;
332
+ }
333
+
334
+ if (typeof current.node.offset !== "string" || !/^\d+$/.test(current.node.offset)) {
335
+ throw invalidAsar(`file '${entryLabel}' has an invalid offset`);
336
+ }
337
+ const relativeOffset = Number(current.node.offset);
338
+ const memberOffset = dataOffset + relativeOffset;
339
+ const memberEnd = memberOffset + size;
340
+ if (
341
+ !Number.isSafeInteger(relativeOffset) ||
342
+ !Number.isSafeInteger(memberOffset) ||
343
+ !Number.isSafeInteger(memberEnd) ||
344
+ memberEnd > source.size
345
+ ) {
346
+ throw invalidAsar(`file '${entryLabel}' extends beyond archive boundary`);
347
+ }
348
+ entries.push({
349
+ path: entryPath,
350
+ isDirectory: false,
351
+ size,
352
+ mode,
353
+ storage: {
354
+ type: "member",
355
+ source: new PackedAsarMemberSource(source, memberOffset, size, integrity),
356
+ },
357
+ });
358
+ }
359
+ return entries;
360
+ }
361
+
362
+ /** Read an Electron ASAR index while keeping packed and unpacked member payloads lazy. */
363
+ export const readAsar: FormatReader = async (source, options) => {
364
+ try {
365
+ return await readAsarIndex(source, options);
366
+ } catch (error) {
367
+ if (error instanceof ArchiveError) throw error;
368
+ throw new ArchiveError(`Failed to read ASAR archive: ${describeError(error)}`);
369
+ }
370
+ };
371
+
372
+ /** Whether bytes begin with a structurally plausible Electron ASAR Pickle header. */
373
+ export function sniffAsar(bytes: Uint8Array): boolean {
374
+ if (bytes.byteLength < ASAR_JSON_OFFSET + 1) return false;
375
+ const outerPayload = readUInt32LE(bytes, 0);
376
+ const headerSize = readUInt32LE(bytes, 4);
377
+ const innerPayload = readUInt32LE(bytes, 8);
378
+ const jsonSize = readUInt32LE(bytes, 12);
379
+ return (
380
+ outerPayload === 4 &&
381
+ headerSize >= ASAR_INNER_PREFIX_SIZE &&
382
+ headerSize === innerPayload + 4 &&
383
+ innerPayload === 4 + alignAsarPayload(jsonSize) &&
384
+ jsonSize > 0 &&
385
+ bytes[ASAR_JSON_OFFSET] === 0x7b
386
+ );
387
+ }
388
+
389
+ function writerPath(rawPath: string): string {
390
+ const portable = rawPath.replace(/\\/g, "/");
391
+ const normalized = normalizeArchiveEntryPath(portable);
392
+ if (
393
+ normalized === undefined ||
394
+ portable.startsWith("/") ||
395
+ /^[A-Za-z]:\//.test(portable) ||
396
+ portable.includes("\0") ||
397
+ portable.split("/").some(part => !part || part === "." || part === "..")
398
+ ) {
399
+ throw new ArchiveError(`Invalid ASAR member path '${formatArchivePathForError(rawPath)}'`);
400
+ }
401
+ return normalized;
402
+ }
403
+
404
+ /** Encode file members in Electron's Pickle-framed ASAR layout. */
405
+ export async function encodeAsar(members: Iterable<readonly [string, Uint8Array]>): Promise<Uint8Array> {
406
+ try {
407
+ const root: AsarDirectoryNode = { files: Object.create(null) as Record<string, AsarNode> };
408
+ const payloads: Uint8Array[] = [];
409
+ let payloadSize = 0;
410
+ for (const member of members) {
411
+ if (
412
+ !Array.isArray(member) ||
413
+ member.length !== 2 ||
414
+ typeof member[0] !== "string" ||
415
+ !(member[1] instanceof Uint8Array)
416
+ ) {
417
+ throw new ArchiveError("ASAR members must be [path, Uint8Array] pairs");
418
+ }
419
+ const memberPath = writerPath(member[0]);
420
+ const parts = memberPath.split("/");
421
+ let directory = root;
422
+ for (let index = 0; index < parts.length - 1; index++) {
423
+ const part = parts[index]!;
424
+ const existing = directory.files[part];
425
+ if (existing && !("files" in existing)) {
426
+ throw new ArchiveError(
427
+ `ASAR member path '${memberPath}' crosses file '${parts.slice(0, index + 1).join("/")}'`,
428
+ );
429
+ }
430
+ if (!existing) {
431
+ directory.files[part] = { files: Object.create(null) as Record<string, AsarNode> };
432
+ }
433
+ directory = directory.files[part] as AsarDirectoryNode;
434
+ }
435
+ const name = parts.at(-1)!;
436
+ if (directory.files[name]) {
437
+ throw new ArchiveError(`Duplicate or conflicting ASAR member path '${memberPath}'`);
438
+ }
439
+ if (!Number.isSafeInteger(member[1].byteLength) || !Number.isSafeInteger(payloadSize + member[1].byteLength)) {
440
+ throw new ArchiveError("ASAR payload is too large to encode safely");
441
+ }
442
+ directory.files[name] = { size: member[1].byteLength, offset: String(payloadSize) };
443
+ payloads.push(member[1]);
444
+ payloadSize += member[1].byteLength;
445
+ }
446
+
447
+ const jsonBytes = ASAR_HEADER_ENCODER.encode(JSON.stringify(root));
448
+ const paddedJsonSize = alignAsarPayload(jsonBytes.byteLength);
449
+ const innerPayloadSize = 4 + paddedJsonSize;
450
+ const headerSize = 4 + innerPayloadSize;
451
+ if (jsonBytes.byteLength > 0xffffffff || headerSize > 0xffffffff) {
452
+ throw new ArchiveError("ASAR header is too large to encode");
453
+ }
454
+ const dataOffset = ASAR_PICKLE_PREFIX_SIZE + headerSize;
455
+ const archiveSize = dataOffset + payloadSize;
456
+ if (!Number.isSafeInteger(archiveSize)) {
457
+ throw new ArchiveError("ASAR archive is too large to encode safely");
458
+ }
459
+ let output: Uint8Array;
460
+ try {
461
+ output = new Uint8Array(archiveSize);
462
+ } catch {
463
+ throw new ArchiveError("ASAR archive is too large to encode in memory");
464
+ }
465
+ writeUInt32LE(output, 0, 4);
466
+ writeUInt32LE(output, 4, headerSize);
467
+ writeUInt32LE(output, 8, innerPayloadSize);
468
+ writeUInt32LE(output, 12, jsonBytes.byteLength);
469
+ output.set(jsonBytes, ASAR_JSON_OFFSET);
470
+ let offset = dataOffset;
471
+ for (const payload of payloads) {
472
+ output.set(payload, offset);
473
+ offset += payload.byteLength;
474
+ }
475
+ return output;
476
+ } catch (error) {
477
+ if (error instanceof ArchiveError) throw error;
478
+ throw new ArchiveError(`Failed to encode ASAR archive: ${describeError(error)}`);
479
+ }
480
+ }
@@ -0,0 +1,78 @@
1
+ import { ArchiveError } from "./error";
2
+
3
+ /** Shared UTF-8 decoder for archive member names and text payloads. */
4
+ export const UTF8_DECODER = new TextDecoder();
5
+
6
+ export function readUInt16LE(bytes: Uint8Array, offset: number): number {
7
+ return bytes[offset]! | (bytes[offset + 1]! << 8);
8
+ }
9
+
10
+ export function readUInt32LE(bytes: Uint8Array, offset: number): number {
11
+ return (bytes[offset]! | (bytes[offset + 1]! << 8) | (bytes[offset + 2]! << 16) | (bytes[offset + 3]! << 24)) >>> 0;
12
+ }
13
+
14
+ /** Read a u64 as a JS number, rejecting values beyond `Number.MAX_SAFE_INTEGER`. */
15
+ export function readUInt64LE(bytes: Uint8Array, offset: number): number {
16
+ const value = readUInt32LE(bytes, offset) + readUInt32LE(bytes, offset + 4) * 0x100000000;
17
+ if (!Number.isSafeInteger(value)) {
18
+ throw new ArchiveError("Archive uses offsets or sizes too large to read safely");
19
+ }
20
+ return value;
21
+ }
22
+
23
+ export function readUInt16BE(bytes: Uint8Array, offset: number): number {
24
+ return (bytes[offset]! << 8) | bytes[offset + 1]!;
25
+ }
26
+
27
+ export function readUInt32BE(bytes: Uint8Array, offset: number): number {
28
+ return ((bytes[offset]! << 24) | (bytes[offset + 1]! << 16) | (bytes[offset + 2]! << 8) | bytes[offset + 3]!) >>> 0;
29
+ }
30
+
31
+ /** Read a big-endian u64 as a JS number, rejecting unsafe values. */
32
+ export function readUInt64BE(bytes: Uint8Array, offset: number): number {
33
+ const value = readUInt32BE(bytes, offset) * 0x100000000 + readUInt32BE(bytes, offset + 4);
34
+ if (!Number.isSafeInteger(value)) {
35
+ throw new ArchiveError("Archive uses offsets or sizes too large to read safely");
36
+ }
37
+ return value;
38
+ }
39
+
40
+ export function writeUInt16LE(buf: Uint8Array, offset: number, value: number): void {
41
+ buf[offset] = value & 0xff;
42
+ buf[offset + 1] = (value >>> 8) & 0xff;
43
+ }
44
+
45
+ export function writeUInt32LE(buf: Uint8Array, offset: number, value: number): void {
46
+ buf[offset] = value & 0xff;
47
+ buf[offset + 1] = (value >>> 8) & 0xff;
48
+ buf[offset + 2] = (value >>> 16) & 0xff;
49
+ buf[offset + 3] = (value >>> 24) & 0xff;
50
+ }
51
+
52
+ /** Write a safe-integer u64 (values beyond 2^53-1 must be rejected upstream). */
53
+ export function writeUInt64LE(buf: Uint8Array, offset: number, value: number): void {
54
+ writeUInt32LE(buf, offset, value >>> 0);
55
+ writeUInt32LE(buf, offset + 4, Math.floor(value / 0x100000000));
56
+ }
57
+
58
+ /** Whether `bytes` contains the ASCII string `value` at `offset`. */
59
+ export function bytesMatchAscii(bytes: Uint8Array, offset: number, value: string): boolean {
60
+ if (bytes.byteLength < offset + value.length) return false;
61
+ for (let index = 0; index < value.length; index++) {
62
+ if (bytes[offset + index] !== value.charCodeAt(index)) return false;
63
+ }
64
+ return true;
65
+ }
66
+
67
+ /** Whether `bytes` is exactly the ASCII string `value`. */
68
+ export function bytesEqualAscii(bytes: Uint8Array, value: string): boolean {
69
+ return bytes.byteLength === value.length && bytesMatchAscii(bytes, 0, value);
70
+ }
71
+
72
+ /** First index at or after `start` where the ASCII string `value` occurs, or -1. */
73
+ export function indexOfAscii(bytes: Uint8Array, value: string, start: number): number {
74
+ for (let offset = start; offset <= bytes.byteLength - value.length; offset++) {
75
+ if (bytesMatchAscii(bytes, offset, value)) return offset;
76
+ }
77
+ return -1;
78
+ }