@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/iso.ts ADDED
@@ -0,0 +1,712 @@
1
+ import * as path from "node:path";
2
+ import { bytesMatchAscii, readUInt16BE, readUInt16LE, readUInt32BE, readUInt32LE } from "./bytes";
3
+ import { upsertArchiveEntry } from "./entries";
4
+ import { ArchiveError } from "./error";
5
+ import { type ArchiveLimits, assertArchiveMemberSize, assertEntryCount, assertIndexSize } from "./limits";
6
+ import { assertArchivePathBytes, normalizeArchiveEntryPath, normalizeArchiveLookupPath } from "./paths";
7
+ import type { ByteSource } from "./source";
8
+ import type { ArchiveIndexEntry, FormatReader, MemberSource } from "./types";
9
+
10
+ const ISO_SECTOR_SIZE = 2048;
11
+ const VOLUME_DESCRIPTOR_START = 16 * ISO_SECTOR_SIZE;
12
+ const MAX_DIRECTORY_DEPTH = 256;
13
+ const MAX_SUSP_CONTINUATIONS = 32;
14
+ const UTF8_DECODER = new TextDecoder();
15
+
16
+ interface IsoVolume {
17
+ blockSize: number;
18
+ root: IsoRecord;
19
+ joliet: boolean;
20
+ rockRidgeRoot?: IsoRecord;
21
+ }
22
+
23
+ interface IsoRecord {
24
+ extent: number;
25
+ size: number;
26
+ extendedAttributeBlocks: number;
27
+ mtimeMs?: number;
28
+ flags: number;
29
+ fileUnitSize: number;
30
+ interleaveGapSize: number;
31
+ identifier: Uint8Array;
32
+ systemUse: Uint8Array;
33
+ }
34
+
35
+ interface IsoExtent {
36
+ start: number;
37
+ size: number;
38
+ fileUnitSize: number;
39
+ interleaveGapSize: number;
40
+ }
41
+
42
+ interface DirectoryWork {
43
+ record: IsoRecord;
44
+ parentPath: string;
45
+ depth: number;
46
+ ancestor: DirectoryAncestor;
47
+ }
48
+
49
+ interface DirectoryAncestor {
50
+ key: string;
51
+ parent?: DirectoryAncestor;
52
+ }
53
+
54
+ interface SuspData {
55
+ name?: string;
56
+ mode?: number;
57
+ symlink?: string;
58
+ relocation?: "RE" | "CL" | "PL";
59
+ }
60
+
61
+ class MetadataBudget {
62
+ #readBytes = 0;
63
+ readonly #limits: ArchiveLimits;
64
+
65
+ constructor(limits: ArchiveLimits) {
66
+ this.#limits = limits;
67
+ }
68
+
69
+ async read(source: ByteSource, start: number, end: number, what: string): Promise<Uint8Array> {
70
+ assertSourceRange(source, start, end, what);
71
+ this.#readBytes += end - start;
72
+ assertIndexSize(this.#readBytes, this.#limits, "ISO metadata");
73
+ const bytes = await source.read(start, end);
74
+ if (bytes.byteLength !== end - start) throw invalidIso(`truncated ${what}`);
75
+ return bytes;
76
+ }
77
+ }
78
+
79
+ class IsoMemberSource implements MemberSource {
80
+ readonly #source: ByteSource;
81
+ readonly #blockSize: number;
82
+ readonly #extents: readonly IsoExtent[];
83
+
84
+ constructor(source: ByteSource, blockSize: number, extents: readonly IsoExtent[]) {
85
+ this.#source = source;
86
+ this.#blockSize = blockSize;
87
+ this.#extents = extents;
88
+ }
89
+
90
+ async read(size: number, memberPath: string): Promise<Uint8Array> {
91
+ try {
92
+ const output = new Uint8Array(size);
93
+ let outputOffset = 0;
94
+ for (const extent of this.#extents) {
95
+ if (outputOffset + extent.size > size) throw invalidIso(`invalid multi-extent size for '${memberPath}'`);
96
+ const unitBytes = extent.fileUnitSize * this.#blockSize;
97
+ const gapBytes = extent.interleaveGapSize * this.#blockSize;
98
+ if (unitBytes === 0) {
99
+ const bytes = await readPayload(this.#source, extent.start, extent.size, memberPath);
100
+ output.set(bytes, outputOffset);
101
+ outputOffset += bytes.byteLength;
102
+ continue;
103
+ }
104
+ let remaining = extent.size;
105
+ let position = extent.start;
106
+ while (remaining > 0) {
107
+ const length = Math.min(unitBytes, remaining);
108
+ const bytes = await readPayload(this.#source, position, length, memberPath);
109
+ output.set(bytes, outputOffset);
110
+ outputOffset += length;
111
+ remaining -= length;
112
+ position = safeAdd(position, unitBytes + gapBytes, "interleaved file offset");
113
+ }
114
+ }
115
+ if (outputOffset !== size) {
116
+ throw invalidIso(`member '${memberPath}' produced ${outputOffset} bytes, expected ${size}`);
117
+ }
118
+ return output;
119
+ } catch (error) {
120
+ if (error instanceof ArchiveError) throw error;
121
+ throw invalidIso(`could not extract member '${memberPath}'`);
122
+ }
123
+ }
124
+ }
125
+
126
+ function invalidIso(detail: string): ArchiveError {
127
+ return new ArchiveError(`Invalid ISO 9660 archive: ${detail}`);
128
+ }
129
+
130
+ function safeMultiply(left: number, right: number, what: string): number {
131
+ const value = left * right;
132
+ if (!Number.isSafeInteger(value) || value < 0) throw invalidIso(`${what} is too large`);
133
+ return value;
134
+ }
135
+
136
+ function safeAdd(left: number, right: number, what: string): number {
137
+ const value = left + right;
138
+ if (!Number.isSafeInteger(value) || value < 0) throw invalidIso(`${what} is too large`);
139
+ return value;
140
+ }
141
+
142
+ function assertSourceRange(source: ByteSource, start: number, end: number, what: string): void {
143
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || end > source.size) {
144
+ throw invalidIso(`truncated or invalid ${what}`);
145
+ }
146
+ }
147
+
148
+ async function readPayload(source: ByteSource, start: number, size: number, memberPath: string): Promise<Uint8Array> {
149
+ const end = safeAdd(start, size, "member extent");
150
+ assertSourceRange(source, start, end, `extent for '${memberPath}'`);
151
+ const bytes = await source.read(start, end);
152
+ if (bytes.byteLength !== size) throw invalidIso(`truncated extent for '${memberPath}'`);
153
+ return bytes;
154
+ }
155
+
156
+ function bothEndian16(bytes: Uint8Array, offset: number, what: string): number {
157
+ const little = readUInt16LE(bytes, offset);
158
+ if (little !== readUInt16BE(bytes, offset + 2)) throw invalidIso(`${what} has mismatched byte orders`);
159
+ return little;
160
+ }
161
+
162
+ function bothEndian32(bytes: Uint8Array, offset: number, what: string): number {
163
+ const little = readUInt32LE(bytes, offset);
164
+ if (little !== readUInt32BE(bytes, offset + 4)) throw invalidIso(`${what} has mismatched byte orders`);
165
+ return little;
166
+ }
167
+
168
+ function recordingTime(bytes: Uint8Array, offset: number): number | undefined {
169
+ const year = bytes[offset]! + 1900;
170
+ const month = bytes[offset + 1]!;
171
+ const day = bytes[offset + 2]!;
172
+ const hour = bytes[offset + 3]!;
173
+ const minute = bytes[offset + 4]!;
174
+ const second = bytes[offset + 5]!;
175
+ const zoneByte = bytes[offset + 6]!;
176
+ const zone = zoneByte >= 128 ? zoneByte - 256 : zoneByte;
177
+ if (
178
+ month < 1 ||
179
+ month > 12 ||
180
+ day < 1 ||
181
+ day > 31 ||
182
+ hour > 23 ||
183
+ minute > 59 ||
184
+ second > 60 ||
185
+ zone < -48 ||
186
+ zone > 52
187
+ ) {
188
+ return undefined;
189
+ }
190
+ const utc = Date.UTC(year, month - 1, day, hour, minute, Math.min(second, 59));
191
+ if (!Number.isFinite(utc)) return undefined;
192
+ return utc - zone * 15 * 60_000;
193
+ }
194
+
195
+ function parseRecord(bytes: Uint8Array, offset: number, available: number, label: string): IsoRecord {
196
+ if (available < 34) throw invalidIso(`${label} directory record is too short`);
197
+ const length = bytes[offset]!;
198
+ if (length < 34 || length > available) throw invalidIso(`${label} directory record has an invalid length`);
199
+ const identifierLength = bytes[offset + 32]!;
200
+ const padding = identifierLength % 2 === 0 ? 1 : 0;
201
+ const systemUseOffset = 33 + identifierLength + padding;
202
+ if (identifierLength === 0 || systemUseOffset > length)
203
+ throw invalidIso(`${label} directory record has an invalid identifier`);
204
+ const extent = bothEndian32(bytes, offset + 2, `${label} extent location`);
205
+ const size = bothEndian32(bytes, offset + 10, `${label} data length`);
206
+ bothEndian16(bytes, offset + 28, `${label} volume sequence number`);
207
+ const fileUnitSize = bytes[offset + 26]!;
208
+ const interleaveGapSize = bytes[offset + 27]!;
209
+ if ((fileUnitSize === 0) !== (interleaveGapSize === 0)) {
210
+ throw invalidIso(`${label} has an invalid interleave configuration`);
211
+ }
212
+ return {
213
+ extent,
214
+ size,
215
+ extendedAttributeBlocks: bytes[offset + 1]!,
216
+ mtimeMs: recordingTime(bytes, offset + 18),
217
+ flags: bytes[offset + 25]!,
218
+ fileUnitSize,
219
+ interleaveGapSize,
220
+ identifier: bytes.subarray(offset + 33, offset + 33 + identifierLength),
221
+ systemUse: bytes.subarray(offset + systemUseOffset, offset + length),
222
+ };
223
+ }
224
+
225
+ function parseVolumeDescriptor(descriptor: Uint8Array, joliet: boolean): IsoVolume {
226
+ bothEndian32(descriptor, 80, "volume space size");
227
+ bothEndian16(descriptor, 120, "volume set size");
228
+ bothEndian16(descriptor, 124, "volume sequence number");
229
+ const blockSize = bothEndian16(descriptor, 128, "logical block size");
230
+ bothEndian32(descriptor, 132, "path table size");
231
+ if (blockSize !== ISO_SECTOR_SIZE) {
232
+ throw new ArchiveError(`Unsupported ISO 9660 logical block size ${blockSize} (expected 2048)`);
233
+ }
234
+ const root = parseRecord(descriptor, 156, descriptor.byteLength - 156, "root");
235
+ if ((root.flags & 0x02) === 0 || root.size === 0 || root.identifier.byteLength !== 1 || root.identifier[0] !== 0) {
236
+ throw invalidIso("invalid root directory record");
237
+ }
238
+ return { blockSize, root, joliet };
239
+ }
240
+
241
+ async function readVolume(source: ByteSource, budget: MetadataBudget, limits: ArchiveLimits): Promise<IsoVolume> {
242
+ if (source.size < VOLUME_DESCRIPTOR_START + ISO_SECTOR_SIZE) throw invalidIso("truncated volume descriptor set");
243
+ let position = VOLUME_DESCRIPTOR_START;
244
+ let chunkSectors = 16;
245
+ let pending: Uint8Array = new Uint8Array(0);
246
+ let pendingOffset = 0;
247
+ let primary: IsoVolume | undefined;
248
+ let joliet: IsoVolume | undefined;
249
+ let sawUdf = false;
250
+ let sawHighSierra = false;
251
+ let terminated = false;
252
+ let scanned = 0;
253
+
254
+ while (!terminated && position < source.size) {
255
+ const wanted = safeMultiply(chunkSectors, ISO_SECTOR_SIZE, "volume descriptor scan");
256
+ const remainingBudget = limits.maxIndexSize - scanned;
257
+ if (remainingBudget < ISO_SECTOR_SIZE) throw invalidIso("volume descriptor set exceeds metadata limit");
258
+ const availableSectors = Math.floor(Math.min(wanted, remainingBudget, source.size - position) / ISO_SECTOR_SIZE);
259
+ if (availableSectors < 1) throw invalidIso("truncated volume descriptor");
260
+ const length = availableSectors * ISO_SECTOR_SIZE;
261
+ pending = await budget.read(source, position, position + length, "volume descriptor set");
262
+ pendingOffset = 0;
263
+ while (pendingOffset + ISO_SECTOR_SIZE <= pending.byteLength) {
264
+ const descriptor = pending.subarray(pendingOffset, pendingOffset + ISO_SECTOR_SIZE);
265
+ const type = descriptor[0]!;
266
+ if (bytesMatchAscii(descriptor, 1, "CDROM") || bytesMatchAscii(descriptor, 9, "CDROM")) {
267
+ sawHighSierra = true;
268
+ }
269
+ if (
270
+ bytesMatchAscii(descriptor, 1, "BEA01") ||
271
+ bytesMatchAscii(descriptor, 1, "NSR02") ||
272
+ bytesMatchAscii(descriptor, 1, "NSR03") ||
273
+ bytesMatchAscii(descriptor, 1, "TEA01")
274
+ )
275
+ sawUdf = true;
276
+ if (bytesMatchAscii(descriptor, 1, "CD001")) {
277
+ if (descriptor[6] !== 1 && descriptor[6] !== 2) throw invalidIso("unsupported volume descriptor version");
278
+ if (type === 1) primary = parseVolumeDescriptor(descriptor, false);
279
+ if (
280
+ type === 2 &&
281
+ descriptor[88] === 0x25 &&
282
+ descriptor[89] === 0x2f &&
283
+ [0x40, 0x43, 0x45].includes(descriptor[90]!)
284
+ ) {
285
+ joliet = parseVolumeDescriptor(descriptor, true);
286
+ }
287
+ if (type === 255) {
288
+ terminated = true;
289
+ break;
290
+ }
291
+ }
292
+ pendingOffset += ISO_SECTOR_SIZE;
293
+ scanned += ISO_SECTOR_SIZE;
294
+ }
295
+ position += pending.byteLength;
296
+ chunkSectors = Math.min(chunkSectors * 2, Math.ceil(limits.maxIndexSize / ISO_SECTOR_SIZE));
297
+ }
298
+ if (!primary && !joliet) {
299
+ if (sawHighSierra) throw new ArchiveError("Unsupported High Sierra CD-ROM filesystem (not ISO 9660)");
300
+ if (sawUdf) throw new ArchiveError("Unsupported UDF-only image (no ISO 9660 volume descriptor)");
301
+ throw invalidIso("primary volume descriptor not found");
302
+ }
303
+ if (!terminated) throw invalidIso("volume descriptor terminator not found");
304
+ if (joliet) return { ...joliet, rockRidgeRoot: primary?.root };
305
+ return primary!;
306
+ }
307
+
308
+ function decodeIdentifier(identifier: Uint8Array, joliet: boolean): string {
309
+ let name: string;
310
+ if (joliet) {
311
+ if (identifier.byteLength % 2 !== 0) throw invalidIso("Joliet identifier has an odd byte length");
312
+ const codeUnits = new Uint16Array(identifier.byteLength / 2);
313
+ for (let index = 0; index < codeUnits.length; index++) codeUnits[index] = readUInt16BE(identifier, index * 2);
314
+ const chunks: string[] = [];
315
+ for (let index = 0; index < codeUnits.length; index += 4096) {
316
+ chunks.push(String.fromCharCode(...codeUnits.subarray(index, index + 4096)));
317
+ }
318
+ name = chunks.join("");
319
+ } else {
320
+ name = UTF8_DECODER.decode(identifier);
321
+ }
322
+ return name.endsWith(";1") ? name.slice(0, -2) : name;
323
+ }
324
+
325
+ function findSuspSkip(record: IsoRecord): number | undefined {
326
+ const bytes = record.systemUse;
327
+ for (let offset = 0; offset + 7 <= bytes.byteLength; ) {
328
+ const length = bytes[offset + 2]!;
329
+ if (length < 4 || offset + length > bytes.byteLength) return undefined;
330
+ if (
331
+ bytes[offset] === 0x53 &&
332
+ bytes[offset + 1] === 0x50 &&
333
+ length === 7 &&
334
+ bytes[offset + 4] === 0xbe &&
335
+ bytes[offset + 5] === 0xef
336
+ ) {
337
+ return bytes[offset + 6]!;
338
+ }
339
+ offset += length;
340
+ }
341
+ return undefined;
342
+ }
343
+
344
+ async function parseSusp(
345
+ record: IsoRecord,
346
+ skip: number | undefined,
347
+ source: ByteSource,
348
+ blockSize: number,
349
+ budget: MetadataBudget,
350
+ limits: ArchiveLimits,
351
+ ): Promise<SuspData> {
352
+ if (skip === undefined || skip > record.systemUse.byteLength) return {};
353
+ const queue: Uint8Array[] = [record.systemUse.subarray(skip)];
354
+ const continuationRanges = new Set<string>();
355
+ const nameParts: string[] = [];
356
+ const linkParts: string[] = [];
357
+ let linkComponentContinues = false;
358
+ let mode: number | undefined;
359
+ let relocation: SuspData["relocation"];
360
+ for (let queueIndex = 0; queueIndex < queue.length; queueIndex++) {
361
+ const area = queue[queueIndex]!;
362
+ for (let offset = 0; offset + 4 <= area.byteLength; ) {
363
+ const signature = String.fromCharCode(area[offset]!, area[offset + 1]!);
364
+ const length = area[offset + 2]!;
365
+ const version = area[offset + 3]!;
366
+ if (length < 4 || offset + length > area.byteLength) throw invalidIso("malformed SUSP record");
367
+ if (
368
+ version !== 1 &&
369
+ (signature === "CE" ||
370
+ signature === "NM" ||
371
+ signature === "PX" ||
372
+ signature === "RE" ||
373
+ signature === "CL" ||
374
+ signature === "PL" ||
375
+ signature === "SL" ||
376
+ signature === "ST")
377
+ ) {
378
+ throw invalidIso(`unsupported SUSP ${signature} version ${version}`);
379
+ }
380
+ const data = area.subarray(offset + 4, offset + length);
381
+ if (signature === "ST") break;
382
+ if (signature === "CE") {
383
+ if (data.byteLength < 24) throw invalidIso("short SUSP CE record");
384
+ const block = bothEndian32(data, 0, "SUSP continuation block");
385
+ const blockOffset = bothEndian32(data, 8, "SUSP continuation offset");
386
+ const continuationLength = bothEndian32(data, 16, "SUSP continuation length");
387
+ assertIndexSize(continuationLength, limits, "ISO SUSP continuation");
388
+ const start = safeAdd(
389
+ safeMultiply(block, blockSize, "SUSP continuation location"),
390
+ blockOffset,
391
+ "SUSP continuation location",
392
+ );
393
+ const end = safeAdd(start, continuationLength, "SUSP continuation range");
394
+ const key = `${start}:${end}`;
395
+ if (continuationRanges.has(key)) throw invalidIso("cyclic SUSP continuation");
396
+ if (continuationRanges.size >= MAX_SUSP_CONTINUATIONS) throw invalidIso("too many SUSP continuations");
397
+ continuationRanges.add(key);
398
+ queue.push(await budget.read(source, start, end, "SUSP continuation"));
399
+ }
400
+ if (signature === "NM" && data.byteLength >= 1) {
401
+ const flags = data[0]!;
402
+ if ((flags & 0x06) === 0) nameParts.push(UTF8_DECODER.decode(data.subarray(1)));
403
+ else if (flags & 0x02) nameParts.push(".");
404
+ else if (flags & 0x04) nameParts.push("..");
405
+ }
406
+ if (signature === "PX" && data.byteLength >= 8) mode = bothEndian32(data, 0, "Rock Ridge PX mode");
407
+ if (signature === "RE" || signature === "CL" || signature === "PL") relocation = signature;
408
+ if (signature === "SL" && data.byteLength >= 1) {
409
+ let componentOffset = 1;
410
+ while (componentOffset < data.byteLength) {
411
+ if (componentOffset + 2 > data.byteLength) throw invalidIso("malformed Rock Ridge SL component");
412
+ const flags = data[componentOffset]!;
413
+ const componentLength = data[componentOffset + 1]!;
414
+ componentOffset += 2;
415
+ if (componentOffset + componentLength > data.byteLength)
416
+ throw invalidIso("malformed Rock Ridge SL component");
417
+ let component: string;
418
+ if (flags & 0x08) component = "";
419
+ else if (flags & 0x04) component = "..";
420
+ else if (flags & 0x02) component = ".";
421
+ else component = UTF8_DECODER.decode(data.subarray(componentOffset, componentOffset + componentLength));
422
+ if (linkComponentContinues && linkParts.length > 0) linkParts[linkParts.length - 1] += component;
423
+ else linkParts.push(component);
424
+ linkComponentContinues = (flags & 0x01) !== 0;
425
+ componentOffset += componentLength;
426
+ }
427
+ }
428
+ offset += length;
429
+ }
430
+ }
431
+ const name = nameParts.length > 0 ? nameParts.join("") : undefined;
432
+ let symlink: string | undefined;
433
+ if (linkParts.length > 0) symlink = linkParts[0] === "" ? `/${linkParts.slice(1).join("/")}` : linkParts.join("/");
434
+ return { name, mode, symlink, relocation };
435
+ }
436
+
437
+ function directoryContainsAncestor(ancestor: DirectoryAncestor, key: string): boolean {
438
+ for (let current: DirectoryAncestor | undefined = ancestor; current; current = current.parent) {
439
+ if (current.key === key) return true;
440
+ }
441
+ return false;
442
+ }
443
+
444
+ function extentForRecord(record: IsoRecord, blockSize: number): IsoExtent {
445
+ const dataBlock = safeAdd(record.extent, record.extendedAttributeBlocks, "file extent block");
446
+ return {
447
+ start: safeMultiply(dataBlock, blockSize, "file extent offset"),
448
+ size: record.size,
449
+ fileUnitSize: record.fileUnitSize,
450
+ interleaveGapSize: record.interleaveGapSize,
451
+ };
452
+ }
453
+
454
+ async function readDirectoryRecords(
455
+ source: ByteSource,
456
+ record: IsoRecord,
457
+ blockSize: number,
458
+ budget: MetadataBudget,
459
+ limits: ArchiveLimits,
460
+ ): Promise<IsoRecord[]> {
461
+ const start = safeMultiply(
462
+ safeAdd(record.extent, record.extendedAttributeBlocks, "directory extent block"),
463
+ blockSize,
464
+ "directory extent offset",
465
+ );
466
+ const end = safeAdd(start, record.size, "directory extent range");
467
+ const bytes = await budget.read(source, start, end, "directory extent");
468
+ const records: IsoRecord[] = [];
469
+ for (let offset = 0; offset < bytes.byteLength; ) {
470
+ const sectorRemaining = blockSize - (offset % blockSize);
471
+ const length = bytes[offset]!;
472
+ if (length === 0) {
473
+ offset += sectorRemaining;
474
+ continue;
475
+ }
476
+ if (length > sectorRemaining || length > bytes.byteLength - offset)
477
+ throw invalidIso("directory record crosses a logical block");
478
+ records.push(parseRecord(bytes, offset, length, "member"));
479
+ assertEntryCount(records.length, limits);
480
+ offset += length;
481
+ }
482
+ return records;
483
+ }
484
+
485
+ async function mergeRockRidgeMetadata(
486
+ source: ByteSource,
487
+ root: IsoRecord,
488
+ blockSize: number,
489
+ budget: MetadataBudget,
490
+ limits: ArchiveLimits,
491
+ entries: Map<string, ArchiveIndexEntry>,
492
+ ): Promise<void> {
493
+ const rootKey = `${root.extent}:${root.size}`;
494
+ const work: DirectoryWork[] = [
495
+ {
496
+ record: root,
497
+ parentPath: "",
498
+ depth: 0,
499
+ ancestor: { key: rootKey },
500
+ },
501
+ ];
502
+ let suspSkip: number | undefined;
503
+ while (work.length > 0) {
504
+ const directory = work.pop()!;
505
+ if (directory.depth > MAX_DIRECTORY_DEPTH) {
506
+ throw invalidIso(`directory hierarchy exceeds ${MAX_DIRECTORY_DEPTH} levels`);
507
+ }
508
+ const records = await readDirectoryRecords(source, directory.record, blockSize, budget, limits);
509
+ if (
510
+ directory.depth === 0 &&
511
+ records.length > 0 &&
512
+ records[0]!.identifier.byteLength === 1 &&
513
+ records[0]!.identifier[0] === 0
514
+ ) {
515
+ suspSkip = findSuspSkip(records[0]!);
516
+ }
517
+ for (const record of records) {
518
+ if (record.identifier.byteLength === 1 && (record.identifier[0] === 0 || record.identifier[0] === 1)) continue;
519
+ const susp = await parseSusp(record, suspSkip, source, blockSize, budget, limits);
520
+ if (susp.relocation) {
521
+ throw new ArchiveError(`Unsupported Rock Ridge relocated directory (${susp.relocation})`);
522
+ }
523
+ const rawName = susp.name ?? decodeIdentifier(record.identifier, false);
524
+ assertArchivePathBytes(Buffer.byteLength(rawName, "utf-8"), "member name", limits.maxPathBytes);
525
+ const rawPath = directory.parentPath ? `${directory.parentPath}/${rawName}` : rawName;
526
+ const normalizedPath = normalizeArchiveEntryPath(rawPath);
527
+ if (!normalizedPath) continue;
528
+ assertArchivePathBytes(Buffer.byteLength(normalizedPath, "utf-8"), "member path", limits.maxPathBytes);
529
+ if (susp.symlink !== undefined) {
530
+ assertArchivePathBytes(Buffer.byteLength(susp.symlink, "utf-8"), "link target", limits.maxPathBytes);
531
+ const targetPath = path.posix.isAbsolute(susp.symlink)
532
+ ? undefined
533
+ : normalizeArchiveLookupPath(path.posix.join(path.posix.dirname(normalizedPath), susp.symlink));
534
+ upsertArchiveEntry(entries, {
535
+ path: normalizedPath,
536
+ isDirectory: false,
537
+ size: 0,
538
+ mtimeMs: record.mtimeMs,
539
+ mode: susp.mode,
540
+ storage: {
541
+ type: "link",
542
+ targetPath: targetPath ?? susp.symlink,
543
+ resolveTarget: targetPath !== undefined,
544
+ },
545
+ });
546
+ assertEntryCount(entries.size, limits);
547
+ continue;
548
+ }
549
+ const existing = entries.get(normalizedPath);
550
+ if (existing && susp.mode !== undefined) entries.set(normalizedPath, { ...existing, mode: susp.mode });
551
+ if ((record.flags & 0x02) === 0) continue;
552
+ const childKey = `${record.extent}:${record.size}`;
553
+ if (directoryContainsAncestor(directory.ancestor, childKey)) {
554
+ throw invalidIso(`cyclic directory at '${normalizedPath}'`);
555
+ }
556
+ work.push({
557
+ record,
558
+ parentPath: normalizedPath,
559
+ depth: directory.depth + 1,
560
+ ancestor: { key: childKey, parent: directory.ancestor },
561
+ });
562
+ }
563
+ }
564
+ }
565
+
566
+ async function readIsoImpl(source: ByteSource, options: Parameters<FormatReader>[1]): Promise<ArchiveIndexEntry[]> {
567
+ if (!Number.isSafeInteger(source.size) || source.size < 0) throw invalidIso("invalid source size");
568
+ const budget = new MetadataBudget(options.limits);
569
+ const volume = await readVolume(source, budget, options.limits);
570
+ const rootKey = `${volume.root.extent}:${volume.root.size}`;
571
+ const rootAncestor: DirectoryAncestor = { key: rootKey };
572
+ const work: DirectoryWork[] = [{ record: volume.root, parentPath: "", depth: 0, ancestor: rootAncestor }];
573
+ const entries = new Map<string, ArchiveIndexEntry>();
574
+ let suspSkip: number | undefined;
575
+
576
+ while (work.length > 0) {
577
+ const directory = work.pop()!;
578
+ if (directory.depth > MAX_DIRECTORY_DEPTH)
579
+ throw invalidIso(`directory hierarchy exceeds ${MAX_DIRECTORY_DEPTH} levels`);
580
+ const records = await readDirectoryRecords(source, directory.record, volume.blockSize, budget, options.limits);
581
+ if (
582
+ directory.depth === 0 &&
583
+ records.length > 0 &&
584
+ records[0]!.identifier.byteLength === 1 &&
585
+ records[0]!.identifier[0] === 0
586
+ ) {
587
+ suspSkip = findSuspSkip(records[0]!);
588
+ }
589
+ for (let index = 0; index < records.length; ) {
590
+ const record = records[index]!;
591
+ if (record.identifier.byteLength === 1 && (record.identifier[0] === 0 || record.identifier[0] === 1)) {
592
+ index++;
593
+ continue;
594
+ }
595
+ const parts = [record];
596
+ if ((record.flags & 0x80) !== 0) {
597
+ if ((record.flags & 0x02) !== 0) throw invalidIso("multi-extent directory is unsupported");
598
+ while ((parts[parts.length - 1]!.flags & 0x80) !== 0) {
599
+ const next = records[++index];
600
+ if (!next || next.identifier.byteLength !== record.identifier.byteLength)
601
+ throw invalidIso("unterminated multi-extent file");
602
+ for (let byteIndex = 0; byteIndex < record.identifier.byteLength; byteIndex++) {
603
+ if (next.identifier[byteIndex] !== record.identifier[byteIndex])
604
+ throw invalidIso("non-contiguous multi-extent file");
605
+ }
606
+ if ((next.flags & ~0x80) !== (record.flags & ~0x80))
607
+ throw invalidIso("inconsistent multi-extent file flags");
608
+ parts.push(next);
609
+ }
610
+ }
611
+ index++;
612
+ const susp = await parseSusp(record, suspSkip, source, volume.blockSize, budget, options.limits);
613
+ if (susp.relocation) {
614
+ throw new ArchiveError(`Unsupported Rock Ridge relocated directory (${susp.relocation})`);
615
+ }
616
+ const rawName = susp.name ?? decodeIdentifier(record.identifier, volume.joliet);
617
+ assertArchivePathBytes(Buffer.byteLength(rawName, "utf-8"), "member name", options.limits.maxPathBytes);
618
+ const rawPath = directory.parentPath ? `${directory.parentPath}/${rawName}` : rawName;
619
+ const normalizedPath = normalizeArchiveEntryPath(rawPath);
620
+ if (!normalizedPath) continue;
621
+ assertArchivePathBytes(Buffer.byteLength(normalizedPath, "utf-8"), "member path", options.limits.maxPathBytes);
622
+ const isDirectory = (record.flags & 0x02) !== 0;
623
+ if (susp.symlink !== undefined) {
624
+ assertArchivePathBytes(
625
+ Buffer.byteLength(susp.symlink, "utf-8"),
626
+ "link target",
627
+ options.limits.maxPathBytes,
628
+ );
629
+ const targetPath = path.posix.isAbsolute(susp.symlink)
630
+ ? undefined
631
+ : normalizeArchiveLookupPath(path.posix.join(path.posix.dirname(normalizedPath), susp.symlink));
632
+ upsertArchiveEntry(entries, {
633
+ path: normalizedPath,
634
+ isDirectory: false,
635
+ size: 0,
636
+ mtimeMs: record.mtimeMs,
637
+ mode: susp.mode,
638
+ storage: {
639
+ type: "link",
640
+ targetPath: targetPath ?? susp.symlink,
641
+ resolveTarget: targetPath !== undefined,
642
+ },
643
+ });
644
+ assertEntryCount(entries.size, options.limits);
645
+ continue;
646
+ }
647
+ if (isDirectory) {
648
+ upsertArchiveEntry(entries, {
649
+ path: normalizedPath,
650
+ isDirectory: true,
651
+ size: 0,
652
+ mtimeMs: record.mtimeMs,
653
+ mode: susp.mode,
654
+ });
655
+ assertEntryCount(entries.size, options.limits);
656
+ const childKey = `${record.extent}:${record.size}`;
657
+ if (directoryContainsAncestor(directory.ancestor, childKey))
658
+ throw invalidIso(`cyclic directory at '${normalizedPath}'`);
659
+ work.push({
660
+ record,
661
+ parentPath: normalizedPath,
662
+ depth: directory.depth + 1,
663
+ ancestor: { key: childKey, parent: directory.ancestor },
664
+ });
665
+ continue;
666
+ }
667
+ let totalSize = 0;
668
+ for (const part of parts) totalSize = safeAdd(totalSize, part.size, "multi-extent member size");
669
+ assertArchiveMemberSize(totalSize, normalizedPath, options.limits);
670
+ // Zero-length members never read their extents, and writers record
671
+ // junk locations for them (bsdtar's Joliet records for Rock Ridge
672
+ // symlinks, empty files at unallocated blocks) — 7-Zip and bsdtar
673
+ // both accept these, so validate extents only when bytes exist.
674
+ const extents = totalSize === 0 ? [] : parts.map(part => extentForRecord(part, volume.blockSize));
675
+ for (const extent of extents)
676
+ assertSourceRange(
677
+ source,
678
+ extent.start,
679
+ safeAdd(extent.start, extent.size, "file extent range"),
680
+ `extent for '${normalizedPath}'`,
681
+ );
682
+ upsertArchiveEntry(entries, {
683
+ path: normalizedPath,
684
+ isDirectory: false,
685
+ size: totalSize,
686
+ mtimeMs: record.mtimeMs,
687
+ mode: susp.mode,
688
+ storage: { type: "member", source: new IsoMemberSource(source, volume.blockSize, extents) },
689
+ });
690
+ assertEntryCount(entries.size, options.limits);
691
+ }
692
+ }
693
+ if (volume.rockRidgeRoot) {
694
+ await mergeRockRidgeMetadata(source, volume.rockRidgeRoot, volume.blockSize, budget, options.limits, entries);
695
+ }
696
+ return [...entries.values()];
697
+ }
698
+
699
+ /** Probe an ISO 9660 primary-volume signature at sector 16. */
700
+ export function sniffIso(bytes: Uint8Array): boolean {
701
+ return bytesMatchAscii(bytes, VOLUME_DESCRIPTOR_START + 1, "CD001");
702
+ }
703
+
704
+ /** Index an ISO 9660 image lazily, preferring a valid Joliet supplementary tree. */
705
+ export const readIso: FormatReader = async (source, options) => {
706
+ try {
707
+ return await readIsoImpl(source, options);
708
+ } catch (error) {
709
+ if (error instanceof ArchiveError) throw error;
710
+ throw invalidIso("could not parse image");
711
+ }
712
+ };