@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/tar.ts ADDED
@@ -0,0 +1,771 @@
1
+ import * as path from "node:path";
2
+ import { bytesMatchAscii } from "./bytes";
3
+ import { resolveArchiveLinkPath, upsertArchiveEntry } from "./entries";
4
+ import { ArchiveError } from "./error";
5
+ import {
6
+ type ArchiveLimits,
7
+ assertArchiveMemberSize,
8
+ assertEntryCount,
9
+ assertIndexSize,
10
+ assertInMemorySize,
11
+ } from "./limits";
12
+ import {
13
+ assertArchivePathBytes,
14
+ assertArchivePathString,
15
+ formatArchivePathForError,
16
+ normalizeArchiveEntryPath,
17
+ normalizeArchiveLookupPath,
18
+ } from "./paths";
19
+ import { readAllBytes } from "./source";
20
+ import type { ArchiveIndexEntry, FormatReader, FormatReadOptions, MemberSource } from "./types";
21
+
22
+ const BLOCK_SIZE = 512;
23
+ const NAME_OFFSET = 0;
24
+ const NAME_LENGTH = 100;
25
+ const MODE_OFFSET = 100;
26
+ const MODE_LENGTH = 8;
27
+ const SIZE_OFFSET = 124;
28
+ const SIZE_LENGTH = 12;
29
+ const MTIME_OFFSET = 136;
30
+ const MTIME_LENGTH = 12;
31
+ const CHECKSUM_OFFSET = 148;
32
+ const CHECKSUM_LENGTH = 8;
33
+ const TYPEFLAG_OFFSET = 156;
34
+ const LINKNAME_OFFSET = 157;
35
+ const LINKNAME_LENGTH = 100;
36
+ const MAGIC_OFFSET = 257;
37
+ const MAGIC = "ustar\0";
38
+ const VERSION_OFFSET = 263;
39
+ const VERSION = "00";
40
+ const PREFIX_OFFSET = 345;
41
+ const PREFIX_LENGTH = 155;
42
+ const GNU_SPARSE_ISEXTENDED_OFFSET = 482;
43
+ const GNU_SPARSE_CONT_ISEXTENDED_OFFSET = 504;
44
+ const MAX_PAX_NUMERIC_BYTES = 32;
45
+ const PAX_SPARSE_MARKER = "GNU.sparse.";
46
+ const TEXT_DECODER = new TextDecoder();
47
+ const TEXT_ENCODER = new TextEncoder();
48
+ const MAX_OCTAL_SIZE = 0o77777777777;
49
+ const MAX_OCTAL_MTIME = 0o77777777777;
50
+
51
+ interface PendingTarLink {
52
+ kind: "hard link" | "symlink";
53
+ targetPath: string;
54
+ }
55
+
56
+ class TarMemberSource implements MemberSource {
57
+ readonly #buffer: Uint8Array;
58
+ readonly #dataOffset: number;
59
+ readonly #sparse: boolean;
60
+
61
+ constructor(buffer: Uint8Array, dataOffset: number, sparse: boolean) {
62
+ this.#buffer = buffer;
63
+ this.#dataOffset = dataOffset;
64
+ this.#sparse = sparse;
65
+ }
66
+
67
+ async read(size: number, memberPath: string): Promise<Uint8Array> {
68
+ if (this.#sparse) {
69
+ throw new ArchiveError(
70
+ `Archive member '${formatArchivePathForError(memberPath)}' is a sparse file and cannot be read`,
71
+ );
72
+ }
73
+ if (size > this.#buffer.byteLength - this.#dataOffset) {
74
+ throw new ArchiveError(`Archive member '${formatArchivePathForError(memberPath)}' is truncated`);
75
+ }
76
+ const bytes = this.#buffer.subarray(this.#dataOffset, this.#dataOffset + size);
77
+ if (bytes.byteLength !== size) {
78
+ throw new ArchiveError(`Archive member '${formatArchivePathForError(memberPath)}' has an invalid size`);
79
+ }
80
+ return bytes;
81
+ }
82
+ }
83
+
84
+ function readTarString(buffer: Uint8Array, offset: number, length: number): string {
85
+ const limit = Math.min(offset + length, buffer.byteLength);
86
+ let end = offset;
87
+ while (end < limit && buffer[end] !== 0) end++;
88
+ return TEXT_DECODER.decode(buffer.subarray(offset, end));
89
+ }
90
+
91
+ function bytesEqualAscii(bytes: Uint8Array, value: string): boolean {
92
+ return bytes.byteLength === value.length && bytesMatchAscii(bytes, 0, value);
93
+ }
94
+
95
+ function isUstarHeader(buffer: Uint8Array, offset: number): boolean {
96
+ return (
97
+ bytesMatchAscii(buffer, offset + MAGIC_OFFSET, MAGIC) && bytesMatchAscii(buffer, offset + VERSION_OFFSET, VERSION)
98
+ );
99
+ }
100
+
101
+ function readMetadataPath(data: Uint8Array, field: string, limits: ArchiveLimits): string {
102
+ const nul = data.indexOf(0);
103
+ const value = data.subarray(0, nul === -1 ? data.byteLength : nul);
104
+ assertArchivePathBytes(value.byteLength, field, limits.maxPathBytes);
105
+ return TEXT_DECODER.decode(value);
106
+ }
107
+
108
+ function readPaxPath(data: Uint8Array, field: string, limits: ArchiveLimits): string {
109
+ assertArchivePathBytes(data.byteLength, field, limits.maxPathBytes);
110
+ return TEXT_DECODER.decode(data);
111
+ }
112
+
113
+ function readTarNumeric(buffer: Uint8Array, offset: number, length: number): number {
114
+ if (offset < 0 || length <= 0 || offset + length > buffer.byteLength) {
115
+ throw new ArchiveError("Invalid tar numeric field");
116
+ }
117
+ const first = buffer[offset]!;
118
+ let value = 0n;
119
+ if ((first & 0x80) !== 0) {
120
+ value = BigInt(first & 0x7f);
121
+ for (let index = 1; index < length; index++) {
122
+ value = (value << 8n) | BigInt(buffer[offset + index]!);
123
+ }
124
+ if ((first & 0x40) !== 0) value -= 1n << BigInt(length * 8 - 1);
125
+ } else {
126
+ for (let index = 0; index < length; index++) {
127
+ const byte = buffer[offset + index]!;
128
+ if (byte >= 0x30 && byte <= 0x37) value = value * 8n + BigInt(byte - 0x30);
129
+ }
130
+ }
131
+ return Number(value);
132
+ }
133
+
134
+ function readTarSize(buffer: Uint8Array, offset: number): number {
135
+ const size = readTarNumeric(buffer, offset, SIZE_LENGTH);
136
+ if (!Number.isSafeInteger(size) || size < 0) throw new ArchiveError("Invalid tar member size");
137
+ return size;
138
+ }
139
+
140
+ function paddedSize(size: number): number {
141
+ const remainder = size % BLOCK_SIZE;
142
+ const padded = size + (remainder === 0 ? 0 : BLOCK_SIZE - remainder);
143
+ if (!Number.isSafeInteger(padded)) throw new ArchiveError("Invalid tar member size");
144
+ return padded;
145
+ }
146
+
147
+ function parsePaxSize(value: string, field: string): number {
148
+ if (!/^\d+$/.test(value)) throw new ArchiveError(`Invalid tar ${field}`);
149
+ const size = Number(value);
150
+ if (!Number.isSafeInteger(size) || size < 0) throw new ArchiveError(`Invalid tar ${field}`);
151
+ return size;
152
+ }
153
+
154
+ function isZeroBlock(buffer: Uint8Array, offset: number): boolean {
155
+ for (let index = 0; index < BLOCK_SIZE; index++) {
156
+ if (buffer[offset + index] !== 0) return false;
157
+ }
158
+ return true;
159
+ }
160
+
161
+ function checksumMatches(buffer: Uint8Array, offset: number): boolean {
162
+ const stored = readTarNumeric(buffer, offset + CHECKSUM_OFFSET, CHECKSUM_LENGTH);
163
+ let unsigned = 0;
164
+ let signed = 0;
165
+ for (let index = 0; index < BLOCK_SIZE; index++) {
166
+ const inChecksum = index >= CHECKSUM_OFFSET && index < CHECKSUM_OFFSET + CHECKSUM_LENGTH;
167
+ const byte = inChecksum ? 0x20 : (buffer[offset + index] ?? 0);
168
+ unsigned += byte;
169
+ signed += (byte << 24) >> 24;
170
+ }
171
+ return stored === unsigned || stored === signed;
172
+ }
173
+
174
+ function parsePaxRecords(data: Uint8Array, limits: ArchiveLimits): Map<string, string> {
175
+ assertIndexSize(data.byteLength, limits, "tar PAX metadata");
176
+ const attrs = new Map<string, string>();
177
+ let pos = 0;
178
+ while (pos < data.byteLength) {
179
+ let space = pos;
180
+ while (space < data.byteLength && data[space] !== 0x20) space++;
181
+ if (space === pos || space >= data.byteLength || space - pos > 16) {
182
+ throw new ArchiveError("Invalid tar PAX record");
183
+ }
184
+ let length = 0;
185
+ for (let index = pos; index < space; index++) {
186
+ const byte = data[index]!;
187
+ if (byte < 0x30 || byte > 0x39) throw new ArchiveError("Invalid tar PAX record");
188
+ length = length * 10 + (byte - 0x30);
189
+ if (length > data.byteLength - pos) throw new ArchiveError("Invalid tar PAX record");
190
+ }
191
+ if (length <= 0 || pos + length > data.byteLength || data[pos + length - 1] !== 0x0a) {
192
+ throw new ArchiveError("Invalid tar PAX record");
193
+ }
194
+ const record = data.subarray(space + 1, pos + length - 1);
195
+ const equals = record.indexOf(0x3d);
196
+ if (equals >= 0) {
197
+ const key = record.subarray(0, equals);
198
+ const value = record.subarray(equals + 1);
199
+ if (bytesMatchAscii(key, 0, PAX_SPARSE_MARKER)) {
200
+ attrs.set(PAX_SPARSE_MARKER, value.byteLength === 0 ? "" : "1");
201
+ if (bytesEqualAscii(key, "GNU.sparse.name")) {
202
+ attrs.set("GNU.sparse.name", readPaxPath(value, "PAX sparse path", limits));
203
+ } else if (bytesEqualAscii(key, "GNU.sparse.realsize") || bytesEqualAscii(key, "GNU.sparse.size")) {
204
+ if (value.byteLength > MAX_PAX_NUMERIC_BYTES) {
205
+ throw new ArchiveError("Invalid tar sparse real size");
206
+ }
207
+ attrs.set("GNU.sparse.realsize", TEXT_DECODER.decode(value));
208
+ }
209
+ } else if (bytesEqualAscii(key, "path") || bytesEqualAscii(key, "linkpath")) {
210
+ const field = bytesEqualAscii(key, "path") ? "PAX path" : "PAX link target";
211
+ attrs.set(field === "PAX path" ? "path" : "linkpath", readPaxPath(value, field, limits));
212
+ } else if (bytesEqualAscii(key, "size")) {
213
+ if (value.byteLength > MAX_PAX_NUMERIC_BYTES) throw new ArchiveError("Invalid tar member size");
214
+ attrs.set("size", TEXT_DECODER.decode(value));
215
+ }
216
+ }
217
+ pos += length;
218
+ }
219
+ return attrs;
220
+ }
221
+
222
+ function applyGlobalPax(globalPax: Map<string, string>, update: ReadonlyMap<string, string>): void {
223
+ for (const [key, value] of update) {
224
+ if (value === "") globalPax.delete(key);
225
+ else globalPax.set(key, value);
226
+ }
227
+ }
228
+
229
+ function paxAttribute(
230
+ globalPax: ReadonlyMap<string, string>,
231
+ localPax: ReadonlyMap<string, string> | undefined,
232
+ key: string,
233
+ ): string | undefined {
234
+ if (localPax?.has(key)) return localPax.get(key);
235
+ return globalPax.get(key);
236
+ }
237
+
238
+ function paxDeclaresSparse(
239
+ globalPax: ReadonlyMap<string, string>,
240
+ localPax: ReadonlyMap<string, string> | undefined,
241
+ ): boolean {
242
+ return paxAttribute(globalPax, localPax, PAX_SPARSE_MARKER) === "1";
243
+ }
244
+
245
+ function indexOfAscii(bytes: Uint8Array, value: string, start: number): number {
246
+ for (let offset = start; offset <= bytes.byteLength - value.length; offset++) {
247
+ if (bytesMatchAscii(bytes, offset, value)) return offset;
248
+ }
249
+ return -1;
250
+ }
251
+
252
+ function normalizeOldGnuName(value: string, field: string, limits: ArchiveLimits): string {
253
+ const portable = value.replace(/\\/g, "/");
254
+ if (path.posix.isAbsolute(portable)) throw new ArchiveError(`Invalid old-GNU ${field}`);
255
+ const normalized = normalizeArchiveEntryPath(portable);
256
+ if (!normalized) throw new ArchiveError(`Invalid old-GNU ${field}`);
257
+ assertArchivePathString(normalized, field, limits.maxPathBytes);
258
+ return normalized;
259
+ }
260
+
261
+ function renameOldGnuEntries(
262
+ entries: Map<string, ArchiveIndexEntry>,
263
+ pendingLinks: Map<ArchiveIndexEntry, PendingTarLink>,
264
+ fromPath: string,
265
+ toPath: string,
266
+ limits: ArchiveLimits,
267
+ ): void {
268
+ const moved = [...entries.entries()].filter(
269
+ ([entryPath]) => entryPath === fromPath || entryPath.startsWith(`${fromPath}/`),
270
+ );
271
+ if (moved.length === 0) return;
272
+ for (const [entryPath] of moved) entries.delete(entryPath);
273
+ for (const [entryPath, entry] of moved) {
274
+ const suffix = entryPath.slice(fromPath.length);
275
+ const nextPath = `${toPath}${suffix}`;
276
+ assertArchivePathString(nextPath, "member path", limits.maxPathBytes);
277
+ entry.path = nextPath;
278
+ const replaced = entries.get(nextPath);
279
+ if (replaced) pendingLinks.delete(replaced);
280
+ entries.set(nextPath, entry);
281
+ }
282
+ for (const pending of pendingLinks.values()) {
283
+ if (
284
+ pending.kind === "hard link" &&
285
+ (pending.targetPath === fromPath || pending.targetPath.startsWith(`${fromPath}/`))
286
+ ) {
287
+ pending.targetPath = `${toPath}${pending.targetPath.slice(fromPath.length)}`;
288
+ }
289
+ }
290
+ }
291
+
292
+ function applyOldGnuNameRecords(
293
+ data: Uint8Array,
294
+ entries: Map<string, ArchiveIndexEntry>,
295
+ pendingLinks: Map<ArchiveIndexEntry, PendingTarLink>,
296
+ limits: ArchiveLimits,
297
+ ): void {
298
+ assertIndexSize(data.byteLength, limits, "old-GNU name metadata");
299
+ const terminator = data.indexOf(0);
300
+ const end = terminator === -1 ? data.byteLength : terminator;
301
+ let start = 0;
302
+ while (start < end) {
303
+ const newline = data.indexOf(0x0a, start);
304
+ const lineEnd = newline === -1 || newline > end ? end : newline;
305
+ const line = data.subarray(start, lineEnd);
306
+ if (bytesMatchAscii(line, 0, "Rename ")) {
307
+ const separator = indexOfAscii(line, " to ", "Rename ".length);
308
+ if (separator === -1) throw new ArchiveError("Invalid old-GNU name record");
309
+ const source = readMetadataPath(line.subarray("Rename ".length, separator), "old-GNU source path", limits);
310
+ const targetEnd = line[line.byteLength - 1] === 0x2f ? line.byteLength - 1 : line.byteLength;
311
+ const target = readMetadataPath(
312
+ line.subarray(separator + " to ".length, targetEnd),
313
+ "old-GNU target path",
314
+ limits,
315
+ );
316
+ renameOldGnuEntries(
317
+ entries,
318
+ pendingLinks,
319
+ normalizeOldGnuName(source, "source path", limits),
320
+ normalizeOldGnuName(target, "target path", limits),
321
+ limits,
322
+ );
323
+ }
324
+ start = lineEnd + 1;
325
+ }
326
+ }
327
+
328
+ function resolvePendingLinks(
329
+ entries: Map<string, ArchiveIndexEntry>,
330
+ pendingLinks: Map<ArchiveIndexEntry, PendingTarLink>,
331
+ limits: ArchiveLimits,
332
+ ): void {
333
+ if (pendingLinks.size === 0) return;
334
+ const directoryPrefixes = new Set<string>();
335
+ for (const entry of entries.values()) {
336
+ for (let cut = entry.path.lastIndexOf("/"); cut > 0; cut = entry.path.lastIndexOf("/", cut - 1)) {
337
+ const prefix = entry.path.slice(0, cut);
338
+ if (directoryPrefixes.has(prefix)) break;
339
+ directoryPrefixes.add(prefix);
340
+ }
341
+ }
342
+ const unresolved = new Set(pendingLinks.keys());
343
+ const dependents = new Map<ArchiveIndexEntry, ArchiveIndexEntry[]>();
344
+ const findUnresolvedBlocker = (targetPath: string): ArchiveIndexEntry | null => {
345
+ for (let end = targetPath.length; end > 0; end = targetPath.lastIndexOf("/", end - 1)) {
346
+ const prefixEntry = entries.get(targetPath.slice(0, end));
347
+ if (prefixEntry && unresolved.has(prefixEntry)) return prefixEntry;
348
+ }
349
+ return null;
350
+ };
351
+ const queue = [...unresolved];
352
+ while (queue.length > 0) {
353
+ const entry = queue.pop()!;
354
+ if (!unresolved.has(entry)) continue;
355
+ const pending = pendingLinks.get(entry)!;
356
+ let blocker = findUnresolvedBlocker(pending.targetPath);
357
+ let targetPath = pending.targetPath;
358
+ if (blocker === null) {
359
+ try {
360
+ targetPath = resolveArchiveLinkPath(entries, targetPath, limits.maxLinkDepth);
361
+ } catch (error) {
362
+ if (!(error instanceof ArchiveError)) throw new ArchiveError("Invalid archive link");
363
+ }
364
+ if (targetPath !== pending.targetPath) blocker = findUnresolvedBlocker(targetPath);
365
+ }
366
+ if (blocker !== null && blocker !== entry) {
367
+ const waiting = dependents.get(blocker);
368
+ if (waiting) waiting.push(entry);
369
+ else dependents.set(blocker, [entry]);
370
+ continue;
371
+ }
372
+ unresolved.delete(entry);
373
+ const settled = dependents.get(entry);
374
+ if (settled) {
375
+ dependents.delete(entry);
376
+ queue.push(...settled);
377
+ }
378
+ if (blocker === entry) {
379
+ if (pending.kind === "hard link") {
380
+ throw new ArchiveError(
381
+ `Archive hard link '${formatArchivePathForError(entry.path)}' has a cyclic target '${formatArchivePathForError(pending.targetPath)}'`,
382
+ );
383
+ }
384
+ entry.storage = { type: "link", targetPath: pending.targetPath, resolveTarget: false };
385
+ continue;
386
+ }
387
+ const target = entries.get(targetPath);
388
+ if (target?.storage && !target.isDirectory && !unresolved.has(target)) {
389
+ entry.size = target.size;
390
+ entry.storage = target.storage;
391
+ continue;
392
+ }
393
+ const targetIsDirectory = targetPath === "" || target?.isDirectory === true || directoryPrefixes.has(targetPath);
394
+ if (!targetIsDirectory) {
395
+ if (pending.kind === "symlink") {
396
+ entry.storage = { type: "link", targetPath: pending.targetPath, resolveTarget: false };
397
+ continue;
398
+ }
399
+ const reason = target ? "unreadable member" : "missing member";
400
+ throw new ArchiveError(
401
+ `Archive hard link '${formatArchivePathForError(entry.path)}' targets ${reason} '${formatArchivePathForError(pending.targetPath)}'`,
402
+ );
403
+ }
404
+ if (pending.kind === "hard link") {
405
+ throw new ArchiveError(
406
+ `Archive hard link '${formatArchivePathForError(entry.path)}' targets directory '${formatArchivePathForError(pending.targetPath)}'`,
407
+ );
408
+ }
409
+ entry.isDirectory = true;
410
+ entry.storage = { type: "link", targetPath: pending.targetPath, resolveTarget: false };
411
+ }
412
+ if (unresolved.size > 0) throw new ArchiveError("Archive contains cyclic or unsupported links");
413
+ }
414
+
415
+ /** Index an already-decompressed tar buffer with bounded GNU, ustar, and PAX handling. */
416
+ export function readTarEntriesFromBuffer(buffer: Uint8Array, options: FormatReadOptions): ArchiveIndexEntry[] {
417
+ const { limits } = options;
418
+ assertInMemorySize(buffer.byteLength, limits);
419
+ const entries = new Map<string, ArchiveIndexEntry>();
420
+ const pendingLinks = new Map<ArchiveIndexEntry, PendingTarLink>();
421
+ const addEntry = (entry: ArchiveIndexEntry, pendingLink?: PendingTarLink): void => {
422
+ const existing = entries.get(entry.path);
423
+ const indexed = upsertArchiveEntry(entries, entry);
424
+ if (!indexed) return;
425
+ if (existing) pendingLinks.delete(existing);
426
+ if (pendingLink) pendingLinks.set(indexed, pendingLink);
427
+ assertEntryCount(entries.size, limits);
428
+ };
429
+ let offset = 0;
430
+ let longName: string | undefined;
431
+ let longLink: string | undefined;
432
+ let localPax: Map<string, string> | undefined;
433
+ const globalPax = new Map<string, string>();
434
+ let sawTerminator = false;
435
+
436
+ while (offset + BLOCK_SIZE <= buffer.byteLength) {
437
+ if (isZeroBlock(buffer, offset)) {
438
+ sawTerminator = true;
439
+ break;
440
+ }
441
+ if (!checksumMatches(buffer, offset)) throw new ArchiveError("Invalid or corrupt tar archive header");
442
+ const headerOffset = offset;
443
+ const typeFlag = String.fromCharCode(buffer[headerOffset + TYPEFLAG_OFFSET] || 0x30);
444
+ let size = readTarSize(buffer, headerOffset + SIZE_OFFSET);
445
+ let name = readTarString(buffer, headerOffset + NAME_OFFSET, NAME_LENGTH);
446
+ if (isUstarHeader(buffer, headerOffset)) {
447
+ const prefix = readTarString(buffer, headerOffset + PREFIX_OFFSET, PREFIX_LENGTH);
448
+ if (prefix) name = `${prefix}/${name}`;
449
+ }
450
+ let linkName = readTarString(buffer, headerOffset + LINKNAME_OFFSET, LINKNAME_LENGTH);
451
+ const mtime = readTarNumeric(buffer, headerOffset + MTIME_OFFSET, MTIME_LENGTH);
452
+ const rawMode = readTarNumeric(buffer, headerOffset + MODE_OFFSET, MODE_LENGTH);
453
+ const mode = Number.isSafeInteger(rawMode) && rawMode >= 0 ? rawMode : undefined;
454
+ offset += BLOCK_SIZE;
455
+ const dataBlocks = paddedSize(size);
456
+ if (dataBlocks > buffer.byteLength - offset) throw new ArchiveError("Archive member data is truncated");
457
+ const data = buffer.subarray(offset, offset + size);
458
+
459
+ if (typeFlag === "L") {
460
+ assertIndexSize(data.byteLength, limits, "GNU long-name metadata");
461
+ longName = readMetadataPath(data, "GNU long path", limits);
462
+ offset += dataBlocks;
463
+ continue;
464
+ }
465
+ if (typeFlag === "K") {
466
+ assertIndexSize(data.byteLength, limits, "GNU long-link metadata");
467
+ longLink = readMetadataPath(data, "GNU long link target", limits);
468
+ offset += dataBlocks;
469
+ continue;
470
+ }
471
+ if (typeFlag === "N") {
472
+ applyOldGnuNameRecords(data, entries, pendingLinks, limits);
473
+ offset += dataBlocks;
474
+ continue;
475
+ }
476
+ if (typeFlag === "x" || typeFlag === "X") {
477
+ localPax = parsePaxRecords(data, limits);
478
+ offset += dataBlocks;
479
+ continue;
480
+ }
481
+ if (typeFlag === "g") {
482
+ applyGlobalPax(globalPax, parsePaxRecords(data, limits));
483
+ offset += dataBlocks;
484
+ continue;
485
+ }
486
+ if (typeFlag === "M") throw new ArchiveError("GNU multi-volume tar members are not supported");
487
+
488
+ if (longName !== undefined) name = longName;
489
+ if (longLink !== undefined) linkName = longLink;
490
+ const paxPath = paxAttribute(globalPax, localPax, "path");
491
+ if (paxPath !== undefined) name = paxPath;
492
+ const paxLinkPath = paxAttribute(globalPax, localPax, "linkpath");
493
+ if (paxLinkPath !== undefined) linkName = paxLinkPath;
494
+ const paxSize = paxAttribute(globalPax, localPax, "size");
495
+ if (paxSize !== undefined) size = parsePaxSize(paxSize, "member size");
496
+ const paxSparseName = paxAttribute(globalPax, localPax, "GNU.sparse.name");
497
+ if (paxSparseName !== undefined) name = paxSparseName;
498
+ let displaySize = size;
499
+ const paxSparseRealSize = paxAttribute(globalPax, localPax, "GNU.sparse.realsize");
500
+ if (paxSparseRealSize !== undefined) displaySize = parsePaxSize(paxSparseRealSize, "sparse real size");
501
+ const sparse = typeFlag === "S" || paxDeclaresSparse(globalPax, localPax);
502
+ if (typeFlag === "S" && buffer[headerOffset + GNU_SPARSE_ISEXTENDED_OFFSET] === 1) {
503
+ let extended = true;
504
+ while (extended) {
505
+ if (offset + BLOCK_SIZE > buffer.byteLength) {
506
+ throw new ArchiveError("Archive sparse metadata is truncated");
507
+ }
508
+ extended = buffer[offset + GNU_SPARSE_CONT_ISEXTENDED_OFFSET] === 1;
509
+ offset += BLOCK_SIZE;
510
+ }
511
+ }
512
+ const dataOffset = offset;
513
+ const memberDataBlocks = paddedSize(size);
514
+ if (memberDataBlocks > buffer.byteLength - dataOffset) {
515
+ throw new ArchiveError(`Archive member '${formatArchivePathForError(name)}' is truncated`);
516
+ }
517
+ offset += memberDataBlocks;
518
+ longName = undefined;
519
+ longLink = undefined;
520
+ localPax = undefined;
521
+
522
+ const isDirectory = typeFlag === "5" || name.endsWith("/");
523
+ const normalizedPath = normalizeArchiveEntryPath(name);
524
+ if (!normalizedPath) continue;
525
+ assertArchivePathString(normalizedPath, "member path", limits.maxPathBytes);
526
+ const scaledMtime = mtime * 1000;
527
+ const mtimeMs = mtime !== 0 && Number.isSafeInteger(scaledMtime) ? scaledMtime : undefined;
528
+ if (isDirectory) {
529
+ addEntry({ path: normalizedPath, isDirectory: true, size: 0, mtimeMs, mode });
530
+ continue;
531
+ }
532
+ if (typeFlag === "1" || typeFlag === "2") {
533
+ const kind = typeFlag === "1" ? "hard link" : "symlink";
534
+ const portableLinkName = linkName.replace(/\\/g, "/");
535
+ assertArchivePathString(portableLinkName, "link target", limits.maxPathBytes);
536
+ const targetPath =
537
+ typeFlag === "1"
538
+ ? normalizeArchiveEntryPath(portableLinkName)
539
+ : path.posix.isAbsolute(portableLinkName)
540
+ ? undefined
541
+ : normalizeArchiveLookupPath(path.posix.join(path.posix.dirname(normalizedPath), portableLinkName));
542
+ const entry: ArchiveIndexEntry = {
543
+ path: normalizedPath,
544
+ isDirectory: false,
545
+ size: 0,
546
+ mtimeMs,
547
+ mode,
548
+ };
549
+ if (targetPath === undefined || Buffer.byteLength(targetPath, "utf-8") > limits.maxPathBytes) {
550
+ if (kind === "hard link") {
551
+ throw new ArchiveError(
552
+ `Archive hard link '${formatArchivePathForError(normalizedPath)}' has an invalid target`,
553
+ );
554
+ }
555
+ entry.storage = { type: "link", targetPath: portableLinkName, resolveTarget: false };
556
+ addEntry(entry);
557
+ continue;
558
+ }
559
+ addEntry(entry, { kind, targetPath });
560
+ continue;
561
+ }
562
+ if (typeFlag !== "0" && typeFlag !== "\0" && typeFlag !== "7" && typeFlag !== "S") continue;
563
+ assertArchiveMemberSize(displaySize, normalizedPath, limits);
564
+ addEntry({
565
+ path: normalizedPath,
566
+ isDirectory: false,
567
+ size: displaySize,
568
+ mtimeMs,
569
+ mode,
570
+ storage: { type: "member", source: new TarMemberSource(buffer, dataOffset, sparse) },
571
+ });
572
+ }
573
+ if (!sawTerminator) throw new ArchiveError("Not a valid tar archive: missing terminating zero block");
574
+ resolvePendingLinks(entries, pendingLinks, limits);
575
+ return [...entries.values()];
576
+ }
577
+
578
+ /** Read and index a tar source after one bounded whole-stream read. */
579
+ export const readTar: FormatReader = async (source, options) => {
580
+ assertInMemorySize(source.size, options.limits);
581
+ let bytes: Uint8Array;
582
+ try {
583
+ bytes = await readAllBytes(source);
584
+ } catch (error) {
585
+ if (error instanceof ArchiveError) throw error;
586
+ throw new ArchiveError(error instanceof Error ? error.message : "Failed to read tar archive");
587
+ }
588
+ if (bytes.byteLength !== source.size) throw new ArchiveError("Invalid archive: truncated data");
589
+ return readTarEntriesFromBuffer(bytes, options);
590
+ };
591
+
592
+ /** Detect a tar header, including legacy pre-ustar archives, by its checksum. */
593
+ export function sniffTar(bytes: Uint8Array): boolean {
594
+ if (bytes.byteLength < BLOCK_SIZE) return false;
595
+ if (isZeroBlock(bytes, 0)) return true;
596
+ try {
597
+ if (readTarString(bytes, NAME_OFFSET, NAME_LENGTH).length === 0) return false;
598
+ const size = readTarSize(bytes, SIZE_OFFSET);
599
+ return Number.isSafeInteger(paddedSize(size)) && checksumMatches(bytes, 0);
600
+ } catch {
601
+ return false;
602
+ }
603
+ }
604
+
605
+ function writeField(target: Uint8Array, offset: number, length: number, value: Uint8Array): void {
606
+ if (value.byteLength > length) throw new ArchiveError("Tar header field is too long");
607
+ target.set(value, offset);
608
+ }
609
+
610
+ function writeOctal(target: Uint8Array, offset: number, length: number, value: number): void {
611
+ if (!Number.isSafeInteger(value) || value < 0) throw new ArchiveError("Invalid tar numeric value");
612
+ const digits = value.toString(8);
613
+ if (digits.length > length - 1) throw new ArchiveError("Tar numeric value does not fit its header field");
614
+ for (let index = offset; index < offset + length - 1 - digits.length; index++) target[index] = 0x30;
615
+ for (let index = 0; index < digits.length; index++)
616
+ target[offset + length - 1 - digits.length + index] = digits.charCodeAt(index);
617
+ target[offset + length - 1] = 0;
618
+ }
619
+
620
+ function splitUstarPath(pathBytes: Uint8Array): readonly [Uint8Array, Uint8Array] | undefined {
621
+ if (pathBytes.byteLength <= NAME_LENGTH) return [pathBytes, new Uint8Array(0)];
622
+ for (let index = pathBytes.byteLength - 1; index > 0; index--) {
623
+ if (pathBytes[index] !== 0x2f) continue;
624
+ const prefix = pathBytes.subarray(0, index);
625
+ const name = pathBytes.subarray(index + 1);
626
+ if (prefix.byteLength <= PREFIX_LENGTH && name.byteLength > 0 && name.byteLength <= NAME_LENGTH) {
627
+ return [name, prefix];
628
+ }
629
+ }
630
+ return undefined;
631
+ }
632
+
633
+ function makePaxRecord(key: string, value: string): Uint8Array {
634
+ const body = TEXT_ENCODER.encode(`${key}=${value}\n`);
635
+ let length = body.byteLength + 2;
636
+ for (;;) {
637
+ const digits = String(length).length;
638
+ const next = digits + 1 + body.byteLength;
639
+ if (next === length) break;
640
+ length = next;
641
+ }
642
+ const prefix = TEXT_ENCODER.encode(`${length} `);
643
+ const record = new Uint8Array(length);
644
+ record.set(prefix);
645
+ record.set(body, prefix.byteLength);
646
+ return record;
647
+ }
648
+
649
+ function concatBytes(parts: readonly Uint8Array[]): Uint8Array {
650
+ let length = 0;
651
+ for (const part of parts) {
652
+ length += part.byteLength;
653
+ if (!Number.isSafeInteger(length)) throw new ArchiveError("Tar archive is too large to encode safely");
654
+ }
655
+ const output = new Uint8Array(length);
656
+ let offset = 0;
657
+ for (const part of parts) {
658
+ output.set(part, offset);
659
+ offset += part.byteLength;
660
+ }
661
+ return output;
662
+ }
663
+
664
+ function makeHeader(
665
+ name: Uint8Array,
666
+ prefix: Uint8Array,
667
+ size: number,
668
+ mtime: number,
669
+ mode: number,
670
+ typeFlag: number,
671
+ ): Uint8Array {
672
+ const header = new Uint8Array(BLOCK_SIZE);
673
+ writeField(header, NAME_OFFSET, NAME_LENGTH, name);
674
+ writeOctal(header, MODE_OFFSET, MODE_LENGTH, mode);
675
+ writeOctal(header, 108, 8, 0);
676
+ writeOctal(header, 116, 8, 0);
677
+ writeOctal(header, SIZE_OFFSET, SIZE_LENGTH, size);
678
+ writeOctal(header, MTIME_OFFSET, MTIME_LENGTH, mtime);
679
+ header.fill(0x20, CHECKSUM_OFFSET, CHECKSUM_OFFSET + CHECKSUM_LENGTH);
680
+ header[TYPEFLAG_OFFSET] = typeFlag;
681
+ writeField(header, MAGIC_OFFSET, MAGIC.length, TEXT_ENCODER.encode(MAGIC));
682
+ writeField(header, VERSION_OFFSET, VERSION.length, TEXT_ENCODER.encode(VERSION));
683
+ writeField(header, PREFIX_OFFSET, PREFIX_LENGTH, prefix);
684
+ let checksum = 0;
685
+ for (const byte of header) checksum += byte;
686
+ const digits = checksum.toString(8).padStart(6, "0");
687
+ for (let index = 0; index < 6; index++) header[CHECKSUM_OFFSET + index] = digits.charCodeAt(index);
688
+ header[CHECKSUM_OFFSET + 6] = 0;
689
+ header[CHECKSUM_OFFSET + 7] = 0x20;
690
+ return header;
691
+ }
692
+
693
+ function appendPayload(parts: Uint8Array[], payload: Uint8Array): void {
694
+ parts.push(payload);
695
+ const padding = paddedSize(payload.byteLength) - payload.byteLength;
696
+ if (padding > 0) parts.push(new Uint8Array(padding));
697
+ }
698
+
699
+ function appendTarEntry(
700
+ parts: Uint8Array[],
701
+ archivePath: string,
702
+ payload: Uint8Array,
703
+ directory: boolean,
704
+ sequence: number,
705
+ ): void {
706
+ const pathBytes = TEXT_ENCODER.encode(directory ? `${archivePath}/` : archivePath);
707
+ const split = splitUstarPath(pathBytes);
708
+ const size = directory ? 0 : payload.byteLength;
709
+ const mtime = 0;
710
+ const paxRecords: Uint8Array[] = [];
711
+ if (!split) paxRecords.push(makePaxRecord("path", directory ? `${archivePath}/` : archivePath));
712
+ if (size > MAX_OCTAL_SIZE) paxRecords.push(makePaxRecord("size", String(size)));
713
+ if (mtime > MAX_OCTAL_MTIME) paxRecords.push(makePaxRecord("mtime", String(mtime)));
714
+ if (paxRecords.length > 0) {
715
+ const paxPayload = concatBytes(paxRecords);
716
+ const paxName = TEXT_ENCODER.encode(`PaxHeaders/${String(sequence).padStart(8, "0")}`);
717
+ parts.push(makeHeader(paxName, new Uint8Array(0), paxPayload.byteLength, 0, 0o644, 0x78));
718
+ appendPayload(parts, paxPayload);
719
+ }
720
+ const effectiveSplit = split ?? [
721
+ TEXT_ENCODER.encode(`PaxFile/${String(sequence).padStart(8, "0")}`),
722
+ new Uint8Array(0),
723
+ ];
724
+ parts.push(
725
+ makeHeader(
726
+ effectiveSplit[0],
727
+ effectiveSplit[1],
728
+ size <= MAX_OCTAL_SIZE ? size : 0,
729
+ mtime,
730
+ directory ? 0o755 : 0o644,
731
+ directory ? 0x35 : 0x30,
732
+ ),
733
+ );
734
+ if (!directory) appendPayload(parts, payload);
735
+ }
736
+
737
+ /** Encode files as a deterministic ustar archive, using PAX records for overflow paths. */
738
+ export async function encodeTar(members: Iterable<readonly [string, Uint8Array]>): Promise<Uint8Array> {
739
+ const parts: Uint8Array[] = [];
740
+ const kinds = new Map<string, "directory" | "file">();
741
+ let sequence = 0;
742
+ for (const [rawPath, bytes] of members) {
743
+ const directory = rawPath.endsWith("/") || rawPath.endsWith("\\");
744
+ const normalized = normalizeArchiveEntryPath(rawPath);
745
+ if (!normalized) throw new ArchiveError(`Invalid tar member path '${formatArchivePathForError(rawPath)}'`);
746
+ const pathBytes = TEXT_ENCODER.encode(normalized);
747
+ if (pathBytes.byteLength === 0) throw new ArchiveError("Invalid empty tar member path");
748
+ if (directory && bytes.byteLength !== 0) {
749
+ throw new ArchiveError(`Tar directory '${formatArchivePathForError(normalized)}' cannot contain file data`);
750
+ }
751
+ const segments = normalized.split("/");
752
+ for (let index = 1; index < segments.length; index++) {
753
+ const parent = segments.slice(0, index).join("/");
754
+ const kind = kinds.get(parent);
755
+ if (kind === "file")
756
+ throw new ArchiveError(`Tar member '${formatArchivePathForError(parent)}' is not a directory`);
757
+ if (kind === "directory") continue;
758
+ kinds.set(parent, "directory");
759
+ appendTarEntry(parts, parent, new Uint8Array(0), true, sequence++);
760
+ }
761
+ const existing = kinds.get(normalized);
762
+ if (existing) {
763
+ if (directory && existing === "directory") continue;
764
+ throw new ArchiveError(`Duplicate tar member path '${formatArchivePathForError(normalized)}'`);
765
+ }
766
+ kinds.set(normalized, directory ? "directory" : "file");
767
+ appendTarEntry(parts, normalized, bytes, directory, sequence++);
768
+ }
769
+ parts.push(new Uint8Array(BLOCK_SIZE * 2));
770
+ return concatBytes(parts);
771
+ }