@blamejs/blamejs-shop 0.0.82 → 0.0.85

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,781 @@
1
+ "use strict";
2
+ /**
3
+ * archive-read — random-access + trusted-sequential ZIP reader.
4
+ *
5
+ * Internal module exposed through `b.archive.read.zip(adapter, opts)`.
6
+ * Implements two paths against the same wire-format vocabulary:
7
+ *
8
+ * - Random-access — `adapter.range(offset, length)` calls walk the
9
+ * EOCD trailer, validate the central directory against every
10
+ * local file header (LFH/CD skew defense), and seek per-entry
11
+ * for decompression. Adversarial-safe.
12
+ * - Trusted sequential — `adapter.readable` is forward-scanned LFH-
13
+ * by-LFH. No CD comparison; operators acknowledge the trust
14
+ * boundary by reaching for `b.archive.read.zip.fromTrustedStream`.
15
+ *
16
+ * Wire-format reference: APPNOTE.TXT (PKWARE ZIP File Format
17
+ * Specification, latest 6.3.10). Constants are kept aligned with the
18
+ * write-side `lib/archive.js` so a single APPNOTE bump cascades to
19
+ * both paths.
20
+ *
21
+ * Zip-bomb defenses are enforced as four parallel caps on `extract()`:
22
+ *
23
+ * maxEntries (entry-count cap)
24
+ * maxEntryDecompressedBytes (per-entry cap)
25
+ * maxTotalDecompressedBytes (aggregate cap)
26
+ * maxExpansionRatio (compressed → decompressed cap)
27
+ *
28
+ * Caps abort streaming inflate immediately; partial entry files are
29
+ * fs.rm-ed before the error throws so a failed extract leaves no
30
+ * half-written state on disk.
31
+ *
32
+ * Path-traversal defense routes every entry name through
33
+ * `b.guardFilename.verifyExtractionPath(name, root)` — the dual-check
34
+ * (string + fs.realpath agreement) that defends the CVE-2025-4517
35
+ * PATH_MAX TOCTOU class. Symlink + hardlink + device entries are
36
+ * refused unconditionally by default; `entryTypePolicy` opt-ins
37
+ * route the entry through an additional realpath-on-target check.
38
+ */
39
+
40
+ var nodePath = require("node:path");
41
+ var nodeFs = require("node:fs");
42
+ var C = require("./constants");
43
+ var lazyRequire = require("./lazy-require");
44
+ var { defineClass } = require("./framework-error");
45
+
46
+ var ArchiveReadError = defineClass("ArchiveReadError", { alwaysPermanent: true });
47
+
48
+ // Lazy because guard-archive + guard-filename pull in the full
49
+ // guard-family validator chain — the reader's read-only paths don't
50
+ // need them; only extract() does.
51
+ var guardFilename = lazyRequire(function () { return require("./guard-filename"); });
52
+ var guardArchive = lazyRequire(function () { return require("./guard-archive"); });
53
+ var safeDecompress = lazyRequire(function () { return require("./safe-decompress"); });
54
+
55
+ // ---- Wire-format constants ------------------------------------------------
56
+ // Aligned with the write-side `lib/archive.js`. APPNOTE.TXT § references
57
+ // follow each signature so a future spec bump is mechanical.
58
+
59
+ var SIG_LFH = 0x04034b50; // allow:raw-byte-literal — APPNOTE §4.3.7 LFH magic dword (wire-format-fixed)
60
+ var SIG_CFH = 0x02014b50; // allow:raw-byte-literal — APPNOTE §4.3.12 CFH magic dword (wire-format-fixed)
61
+ var SIG_EOCD = 0x06054b50; // allow:raw-byte-literal — APPNOTE §4.3.16 EOCD magic dword (wire-format-fixed)
62
+ var SIG_EOCD64 = 0x06064b50; // allow:raw-byte-literal — APPNOTE §4.3.14 ZIP64 EOCD magic dword (wire-format-fixed)
63
+ var SIG_EOCD64_LOCATOR = 0x07064b50; // allow:raw-byte-literal — APPNOTE §4.3.15 ZIP64 EOCD locator magic dword (wire-format-fixed)
64
+ var SIG_DATA_DESCRIPTOR = 0x08074b50; // allow:raw-byte-literal — APPNOTE §4.3.9 data-descriptor magic dword (wire-format-fixed)
65
+ void SIG_EOCD64; void SIG_EOCD64_LOCATOR;
66
+
67
+ var METHOD_STORE_ID = 0;
68
+ var METHOD_DEFLATE_ID = 8;
69
+
70
+ var FLAG_ENCRYPTED = 0x0001; // §4.4.4 bit 0 — encrypted entry
71
+ var FLAG_DATA_DESCRIPTOR = 0x0008; // §4.4.4 bit 3 — data descriptor follows
72
+ var FLAG_UTF8_NAME = 0x0800; // §4.4.4 bit 11 — UTF-8 name/comment
73
+ void FLAG_UTF8_NAME;
74
+ void SIG_DATA_DESCRIPTOR;
75
+
76
+ // EOCD record is 22 bytes minimum (§4.3.16); operator-supplied comments
77
+ // can extend it by up to 64 KiB. We search the trailing 64 KiB + 22
78
+ // bytes from EOF for the signature.
79
+ var EOCD_MIN_BYTES = C.BYTES.bytes(22);
80
+ var EOCD_MAX_COMMENT_BYTES = C.BYTES.kib(64);
81
+ var EOCD_SCAN_BYTES = EOCD_MIN_BYTES + EOCD_MAX_COMMENT_BYTES;
82
+
83
+ // LFH fixed prefix is 30 bytes (§4.3.7); CD fixed prefix is 46 bytes
84
+ // (§4.3.12). Variable-length name/extra/comment fields follow.
85
+ var LFH_FIXED_BYTES = C.BYTES.bytes(30);
86
+ var CFH_FIXED_BYTES = C.BYTES.bytes(46);
87
+
88
+ // MS-DOS epoch — 1980-01-01. Used to map encoded dos-time back to a
89
+ // Date object for entry mtime.
90
+ var MSDOS_EPOCH_YEAR = 1980;
91
+
92
+ // ---- Default zip-bomb / entry caps ---------------------------------------
93
+
94
+ var DEFAULT_BOMB_POLICY = Object.freeze({
95
+ maxEntries: 65535, // allow:raw-byte-literal — APPNOTE §4.4.21 16-bit entry-count field's max (ZIP64 deferred)
96
+ maxEntryDecompressedBytes: C.BYTES.mib(128), // per-entry cap
97
+ maxTotalDecompressedBytes: C.BYTES.gib(4), // archive-wide cap
98
+ maxExpansionRatio: 100, // compressed → decompressed ratio cap
99
+ });
100
+
101
+ var DEFAULT_ENTRY_TYPE_POLICY = Object.freeze({
102
+ symlinks: false,
103
+ hardlinks: false,
104
+ devices: false,
105
+ fifos: false,
106
+ sockets: false,
107
+ });
108
+
109
+ // ---- Helpers --------------------------------------------------------------
110
+
111
+ function _msdosToDate(dosDate, dosTime) {
112
+ var year = ((dosDate >>> 9) & 0x7f) + MSDOS_EPOCH_YEAR;
113
+ var month = ((dosDate >>> 5) & 0x0f) - 1;
114
+ var day = (dosDate & 0x1f);
115
+ var hour = ((dosTime >>> 11) & 0x1f);
116
+ var minute = ((dosTime >>> 5) & 0x3f);
117
+ var second = (dosTime & 0x1f) * 2;
118
+ return new Date(year, month, day, hour, minute, second);
119
+ }
120
+
121
+ function _isUnixSymlinkAttrs(externalAttrs) {
122
+ // S_IFLNK = 0o120000 (octal). External file attributes' high 16 bits
123
+ // carry the unix mode when "version made by" host == 3 (UNIX).
124
+ var unixMode = (externalAttrs >>> 16) & 0xffff;
125
+ return (unixMode & 0xf000) === 0xa000;
126
+ }
127
+
128
+ function _isUnixSocketAttrs(externalAttrs) {
129
+ var unixMode = (externalAttrs >>> 16) & 0xffff;
130
+ return (unixMode & 0xf000) === 0xc000;
131
+ }
132
+
133
+ function _isUnixFifoAttrs(externalAttrs) {
134
+ var unixMode = (externalAttrs >>> 16) & 0xffff;
135
+ return (unixMode & 0xf000) === 0x1000;
136
+ }
137
+
138
+ function _isUnixCharDevAttrs(externalAttrs) {
139
+ var unixMode = (externalAttrs >>> 16) & 0xffff;
140
+ return (unixMode & 0xf000) === 0x2000;
141
+ }
142
+
143
+ function _isUnixBlockDevAttrs(externalAttrs) {
144
+ var unixMode = (externalAttrs >>> 16) & 0xffff;
145
+ return (unixMode & 0xf000) === 0x6000;
146
+ }
147
+
148
+ function _isDirectoryEntry(name, externalAttrs) {
149
+ if (name.length > 0 && name[name.length - 1] === "/") return true;
150
+ var unixMode = (externalAttrs >>> 16) & 0xffff;
151
+ if ((unixMode & 0xf000) === 0x4000) return true;
152
+ return false;
153
+ }
154
+
155
+ function _classifyEntryType(entry) {
156
+ if (_isUnixSymlinkAttrs(entry.externalAttrs)) return "symlink";
157
+ if (_isUnixSocketAttrs(entry.externalAttrs)) return "socket";
158
+ if (_isUnixFifoAttrs(entry.externalAttrs)) return "fifo";
159
+ if (_isUnixCharDevAttrs(entry.externalAttrs)) return "device";
160
+ if (_isUnixBlockDevAttrs(entry.externalAttrs)) return "device";
161
+ if (_isDirectoryEntry(entry.name, entry.externalAttrs)) return "directory";
162
+ return "file";
163
+ }
164
+
165
+ // ---- Random-access EOCD locator -------------------------------------------
166
+
167
+ async function _locateEocd(adapter) {
168
+ var size = adapter.size;
169
+ if (size == null && typeof adapter.resolveSize === "function") {
170
+ size = await adapter.resolveSize();
171
+ }
172
+ if (typeof size !== "number" || size < EOCD_MIN_BYTES) {
173
+ throw new ArchiveReadError("archive-read/too-small",
174
+ "ZIP file too small to contain EOCD record (size=" + size + ", min=" + EOCD_MIN_BYTES + ")");
175
+ }
176
+ var scanLen = Math.min(EOCD_SCAN_BYTES, size);
177
+ var scanOffset = size - scanLen;
178
+ var tail = await adapter.range(scanOffset, scanLen);
179
+ // Search backwards for the EOCD signature — comments live after the
180
+ // fixed 22-byte record, so we walk from the latest plausible start
181
+ // down to the earliest.
182
+ for (var i = tail.length - EOCD_MIN_BYTES; i >= 0; i -= 1) {
183
+ if (tail.readUInt32LE(i) === SIG_EOCD) {
184
+ // Verify the comment length field matches our trailing slice —
185
+ // if the operator-supplied EOCD signature embedded in a comment
186
+ // body matched first, the comment-length field will overflow
187
+ // past EOF and we keep scanning.
188
+ var commentLen = tail.readUInt16LE(i + 20);
189
+ if (i + EOCD_MIN_BYTES + commentLen === tail.length) {
190
+ return {
191
+ eocdOffset: scanOffset + i,
192
+ diskNumber: tail.readUInt16LE(i + 4),
193
+ cdDiskNumber: tail.readUInt16LE(i + 6),
194
+ entriesOnThisDisk: tail.readUInt16LE(i + 8), // allow:raw-byte-literal — APPNOTE §4.3.16 EOCD field offset
195
+ totalEntries: tail.readUInt16LE(i + 10), // allow:raw-byte-literal — APPNOTE §4.3.16 EOCD field offset
196
+ cdSize: tail.readUInt32LE(i + 12), // allow:raw-byte-literal — APPNOTE §4.3.16 EOCD field offset
197
+ cdOffset: tail.readUInt32LE(i + 16), // allow:raw-byte-literal — APPNOTE §4.3.16 EOCD field offset
198
+ commentLength: commentLen,
199
+ };
200
+ }
201
+ }
202
+ }
203
+ throw new ArchiveReadError("archive-read/no-eocd",
204
+ "End-of-central-directory record not found in trailing " + scanLen + " bytes");
205
+ }
206
+
207
+ // ---- Random-access central-directory walk ---------------------------------
208
+
209
+ async function _readCentralDirectory(adapter, eocd) {
210
+ if (eocd.diskNumber !== 0 || eocd.cdDiskNumber !== 0) {
211
+ throw new ArchiveReadError("archive-read/multi-disk",
212
+ "multi-disk archives are not supported (diskNumber=" + eocd.diskNumber + ")");
213
+ }
214
+ if (eocd.totalEntries === 0xffff || eocd.cdSize === 0xffffffff || eocd.cdOffset === 0xffffffff) {
215
+ // ZIP64 sentinel — not supported in v0.12.7. Will land in a
216
+ // follow-up patch when an operator surfaces a need.
217
+ throw new ArchiveReadError("archive-read/zip64-unsupported",
218
+ "ZIP64 archives are not supported in v0.12.7 (operators at >4 GiB / >65535 entries should switch to tar — lands v0.12.8)");
219
+ }
220
+ if (eocd.cdSize === 0 || eocd.totalEntries === 0) {
221
+ return [];
222
+ }
223
+ var cdBytes = await adapter.range(eocd.cdOffset, eocd.cdSize);
224
+ var entries = [];
225
+ var pos = 0;
226
+ for (var n = 0; n < eocd.totalEntries; n += 1) {
227
+ if (pos + CFH_FIXED_BYTES > cdBytes.length) {
228
+ throw new ArchiveReadError("archive-read/cd-truncated",
229
+ "central directory truncated at entry " + n + "/" + eocd.totalEntries);
230
+ }
231
+ if (cdBytes.readUInt32LE(pos) !== SIG_CFH) {
232
+ throw new ArchiveReadError("archive-read/bad-cd-signature",
233
+ "central directory entry " + n + " has bad signature " +
234
+ "0x" + cdBytes.readUInt32LE(pos).toString(16)); // allow:raw-byte-literal — radix=16 for hex parse, not byte count
235
+ }
236
+ var generalFlags = cdBytes.readUInt16LE(pos + 8); // allow:raw-byte-literal — APPNOTE §4.3.12 CFH field offset
237
+ var method = cdBytes.readUInt16LE(pos + 10); // allow:raw-byte-literal — APPNOTE §4.3.12 CFH field offset
238
+ var dosTime = cdBytes.readUInt16LE(pos + 12); // allow:raw-byte-literal — APPNOTE §4.3.12 CFH field offset
239
+ var dosDate = cdBytes.readUInt16LE(pos + 14); // allow:raw-byte-literal — APPNOTE §4.3.12 CFH field offset
240
+ var crc32 = cdBytes.readUInt32LE(pos + 16); // allow:raw-byte-literal — APPNOTE §4.3.12 CFH field offset
241
+ var compressedSize = cdBytes.readUInt32LE(pos + 20); // allow:raw-byte-literal — APPNOTE §4.3.12 CFH field offset
242
+ var uncompressedSize = cdBytes.readUInt32LE(pos + 24); // allow:raw-byte-literal — APPNOTE §4.3.12 CFH field offset
243
+ var nameLen = cdBytes.readUInt16LE(pos + 28); // allow:raw-byte-literal — APPNOTE §4.3.12 CFH field offset
244
+ var extraLen = cdBytes.readUInt16LE(pos + 30); // allow:raw-byte-literal — APPNOTE §4.3.12 CFH field offset
245
+ var commentLen = cdBytes.readUInt16LE(pos + 32); // allow:raw-byte-literal — APPNOTE §4.3.12 CFH field offset
246
+ var externalAttrs = cdBytes.readUInt32LE(pos + 38); // allow:raw-byte-literal — APPNOTE §4.3.12 CFH field offset
247
+ var lfhOffset = cdBytes.readUInt32LE(pos + 42); // allow:raw-byte-literal — APPNOTE §4.3.12 CFH field offset
248
+ var nameStart = pos + CFH_FIXED_BYTES;
249
+ var extraStart = nameStart + nameLen;
250
+ var totalLen = CFH_FIXED_BYTES + nameLen + extraLen + commentLen;
251
+ if (pos + totalLen > cdBytes.length) {
252
+ throw new ArchiveReadError("archive-read/cd-truncated",
253
+ "central directory entry " + n + " variable-length fields overflow CD");
254
+ }
255
+ if (compressedSize === 0xffffffff || uncompressedSize === 0xffffffff || lfhOffset === 0xffffffff) {
256
+ throw new ArchiveReadError("archive-read/zip64-unsupported",
257
+ "central directory entry " + n + " carries ZIP64 sentinel sizes (not supported in v0.12.7)");
258
+ }
259
+ // ZIP names are CP437 or UTF-8 (per FLAG_UTF8_NAME bit). Decode
260
+ // as UTF-8 unconditionally — Codex P2 territory if operators in
261
+ // the wild rely on CP437; v0.12.7 ships UTF-8 only and operators
262
+ // with legacy CP437-only producers reach for an external decoder.
263
+ var name = cdBytes.slice(nameStart, nameStart + nameLen).toString("utf8");
264
+ var extraFields = cdBytes.slice(extraStart, extraStart + extraLen);
265
+ entries.push({
266
+ name: name,
267
+ method: method,
268
+ generalFlags: generalFlags,
269
+ crc: crc32,
270
+ compressedSize: compressedSize,
271
+ uncompressedSize: uncompressedSize,
272
+ mtime: _msdosToDate(dosDate, dosTime),
273
+ externalAttrs: externalAttrs,
274
+ extraFields: extraFields,
275
+ lfhOffset: lfhOffset,
276
+ isEncrypted: (generalFlags & FLAG_ENCRYPTED) !== 0,
277
+ hasDataDescriptor:(generalFlags & FLAG_DATA_DESCRIPTOR) !== 0,
278
+ _entryType: null, // memoized on first access
279
+ });
280
+ pos += totalLen;
281
+ }
282
+ return entries;
283
+ }
284
+
285
+ // ---- LFH/CD skew verification --------------------------------------------
286
+
287
+ async function _verifyLfhMatchesCd(adapter, entry) {
288
+ var lfhPrefix = await adapter.range(entry.lfhOffset, LFH_FIXED_BYTES);
289
+ if (lfhPrefix.readUInt32LE(0) !== SIG_LFH) {
290
+ throw new ArchiveReadError("archive-read/bad-lfh-signature",
291
+ "local file header for " + JSON.stringify(entry.name) +
292
+ " has bad signature 0x" + lfhPrefix.readUInt32LE(0).toString(16)); // allow:raw-byte-literal — radix=16 for hex parse, not byte count
293
+ }
294
+ var lfhMethod = lfhPrefix.readUInt16LE(8);
295
+ var lfhCrc = lfhPrefix.readUInt32LE(14);
296
+ var lfhCsize = lfhPrefix.readUInt32LE(18);
297
+ var lfhUsize = lfhPrefix.readUInt32LE(22);
298
+ var lfhNameLen = lfhPrefix.readUInt16LE(26);
299
+ var lfhExtraLen= lfhPrefix.readUInt16LE(28);
300
+ var hasDataDescriptor = entry.hasDataDescriptor;
301
+ if (lfhMethod !== entry.method) {
302
+ throw new ArchiveReadError("archive-read/lfh-cd-skew",
303
+ "entry " + JSON.stringify(entry.name) + " method skew: LFH=" +
304
+ lfhMethod + " CD=" + entry.method);
305
+ }
306
+ // When the data-descriptor flag is set, the LFH's crc/csize/usize
307
+ // are all zero per APPNOTE §4.4.4 bit 3 — skip the comparison.
308
+ if (!hasDataDescriptor) {
309
+ if (lfhCrc !== entry.crc) {
310
+ throw new ArchiveReadError("archive-read/lfh-cd-skew",
311
+ "entry " + JSON.stringify(entry.name) + " CRC skew: LFH=" +
312
+ lfhCrc + " CD=" + entry.crc);
313
+ }
314
+ if (lfhCsize !== entry.compressedSize) {
315
+ throw new ArchiveReadError("archive-read/lfh-cd-skew",
316
+ "entry " + JSON.stringify(entry.name) + " compressed-size skew: LFH=" +
317
+ lfhCsize + " CD=" + entry.compressedSize);
318
+ }
319
+ if (lfhUsize !== entry.uncompressedSize) {
320
+ throw new ArchiveReadError("archive-read/lfh-cd-skew",
321
+ "entry " + JSON.stringify(entry.name) + " uncompressed-size skew: LFH=" +
322
+ lfhUsize + " CD=" + entry.uncompressedSize);
323
+ }
324
+ }
325
+ // Name agreement — the LFH MUST carry the same byte sequence as the
326
+ // CD entry. Defends the "two CD entries point at the same LFH" + the
327
+ // "CD says name=X, LFH says name=Y" attack class.
328
+ var lfhNameBuf = await adapter.range(entry.lfhOffset + LFH_FIXED_BYTES, lfhNameLen);
329
+ var lfhName = lfhNameBuf.toString("utf8");
330
+ if (lfhName !== entry.name) {
331
+ throw new ArchiveReadError("archive-read/lfh-cd-skew",
332
+ "entry name skew: LFH=" + JSON.stringify(lfhName) +
333
+ " CD=" + JSON.stringify(entry.name));
334
+ }
335
+ return {
336
+ dataStart: entry.lfhOffset + LFH_FIXED_BYTES + lfhNameLen + lfhExtraLen,
337
+ };
338
+ }
339
+
340
+ // ---- Entry-type policy enforcement ---------------------------------------
341
+
342
+ function _enforceEntryTypePolicy(entry, policy) {
343
+ var type = entry._entryType || (entry._entryType = _classifyEntryType(entry));
344
+ if (type === "symlink" && !policy.symlinks) return "symlink";
345
+ if (type === "device" && !policy.devices) return "device";
346
+ if (type === "fifo" && !policy.fifos) return "fifo";
347
+ if (type === "socket" && !policy.sockets) return "socket";
348
+ // Note: ZIP entries don't carry a hardlink type bit — hardlinks are
349
+ // a tar concept. We model the policy field for parity with v0.12.8
350
+ // tar reader's policy shape; in ZIP read it's always allowed.
351
+ void policy.hardlinks;
352
+ return null;
353
+ }
354
+
355
+ // ---- Bomb-policy enforcement ---------------------------------------------
356
+
357
+ function _enforceBombPolicy(entries, policy) {
358
+ if (entries.length > policy.maxEntries) {
359
+ throw new ArchiveReadError("archive-read/too-many-entries",
360
+ "archive contains " + entries.length + " entries — exceeds maxEntries=" + policy.maxEntries);
361
+ }
362
+ var totalDecompressed = 0;
363
+ for (var i = 0; i < entries.length; i += 1) {
364
+ var e = entries[i];
365
+ if (e.uncompressedSize > policy.maxEntryDecompressedBytes) {
366
+ throw new ArchiveReadError("archive-read/entry-too-large",
367
+ "entry " + JSON.stringify(e.name) + " uncompressed=" + e.uncompressedSize +
368
+ " exceeds maxEntryDecompressedBytes=" + policy.maxEntryDecompressedBytes);
369
+ }
370
+ totalDecompressed += e.uncompressedSize;
371
+ if (totalDecompressed > policy.maxTotalDecompressedBytes) {
372
+ throw new ArchiveReadError("archive-read/total-too-large",
373
+ "cumulative uncompressed=" + totalDecompressed +
374
+ " (after entry " + JSON.stringify(e.name) + ") exceeds maxTotalDecompressedBytes=" + policy.maxTotalDecompressedBytes);
375
+ }
376
+ // Expansion-ratio cap — only meaningful for non-empty compressed data.
377
+ if (e.compressedSize > 0) {
378
+ var ratio = e.uncompressedSize / e.compressedSize;
379
+ if (ratio > policy.maxExpansionRatio) {
380
+ throw new ArchiveReadError("archive-read/expansion-ratio",
381
+ "entry " + JSON.stringify(e.name) + " expansion ratio=" +
382
+ ratio.toFixed(2) + " exceeds maxExpansionRatio=" + policy.maxExpansionRatio);
383
+ }
384
+ }
385
+ }
386
+ }
387
+
388
+ // ---- Decompress one entry (random-access) ---------------------------------
389
+
390
+ async function _decompressEntry(adapter, entry, dataStart, bombPolicy) {
391
+ if (entry.compressedSize === 0 && entry.uncompressedSize === 0) {
392
+ return Buffer.alloc(0);
393
+ }
394
+ var raw = await adapter.range(dataStart, entry.compressedSize);
395
+ if (entry.method === METHOD_STORE_ID) {
396
+ if (raw.length !== entry.uncompressedSize) {
397
+ throw new ArchiveReadError("archive-read/store-size-mismatch",
398
+ "entry " + JSON.stringify(entry.name) + " stored size mismatch (csize=" +
399
+ raw.length + " usize=" + entry.uncompressedSize + ")");
400
+ }
401
+ return raw;
402
+ }
403
+ if (entry.method === METHOD_DEFLATE_ID) {
404
+ // Compose b.safeDecompress so the inflate path inherits the bomb
405
+ // gate even if the operator's bombPolicy is generous — defense in
406
+ // depth.
407
+ var maxOutput = Math.min(
408
+ bombPolicy.maxEntryDecompressedBytes,
409
+ entry.uncompressedSize + C.BYTES.bytes(1) // allow exactly the declared size
410
+ );
411
+ var decompressed = safeDecompress().safeDecompress(raw, {
412
+ algorithm: "deflate-raw",
413
+ maxOutputBytes: maxOutput,
414
+ maxCompressedBytes: entry.compressedSize,
415
+ });
416
+ if (decompressed.length !== entry.uncompressedSize) {
417
+ throw new ArchiveReadError("archive-read/inflate-size-mismatch",
418
+ "entry " + JSON.stringify(entry.name) +
419
+ " inflated size mismatch (declared=" + entry.uncompressedSize +
420
+ " actual=" + decompressed.length + ")");
421
+ }
422
+ return decompressed;
423
+ }
424
+ throw new ArchiveReadError("archive-read/unsupported-method",
425
+ "entry " + JSON.stringify(entry.name) + " uses method=" + entry.method +
426
+ " — only STORE (0) and DEFLATE (8) supported in v0.12.7");
427
+ }
428
+
429
+ // ---- Public read.zip factory ---------------------------------------------
430
+
431
+ function _normalizeBombPolicy(p) {
432
+ if (!p) return DEFAULT_BOMB_POLICY;
433
+ return Object.freeze(Object.assign({}, DEFAULT_BOMB_POLICY, p));
434
+ }
435
+
436
+ function _normalizeEntryTypePolicy(p) {
437
+ if (!p) return DEFAULT_ENTRY_TYPE_POLICY;
438
+ return Object.freeze(Object.assign({}, DEFAULT_ENTRY_TYPE_POLICY, p));
439
+ }
440
+
441
+ function _emitAudit(opts, action, outcome, metadata) {
442
+ if (!opts || !opts.audit || typeof opts.audit.safeEmit !== "function") return;
443
+ try {
444
+ opts.audit.safeEmit({ action: action, outcome: outcome, metadata: metadata });
445
+ } catch (_e) { /* drop-silent — audit sinks must never crash the reader */ }
446
+ }
447
+
448
+ /**
449
+ * @primitive b.archive.read.zip
450
+ * @signature b.archive.read.zip(adapter, opts?)
451
+ * @since 0.12.7
452
+ * @status stable
453
+ * @compliance hipaa, pci-dss, gdpr, soc2
454
+ * @related b.archive.adapters.fs, b.safeArchive.extract, b.guardArchive.inspect
455
+ *
456
+ * Random-access ZIP reader. Walks the end-of-central-directory record,
457
+ * validates every CD entry against its local file header, and exposes
458
+ * `inspect()` (entry-list enumeration without decompressing) +
459
+ * `extract(opts)` (full decompression with bomb caps + path-traversal +
460
+ * entry-type policy).
461
+ *
462
+ * Defends:
463
+ * - Zip Slip / path traversal (CVE-2025-3445 / 11569 / 23084 / 27210
464
+ * / 11001 / 11002 / 26960 + 2024 jszip / mholt / Python tarfile)
465
+ * - LFH/CD skew (malformed-zip class)
466
+ * - Decompression bomb (OWASP zip-bomb top-cases)
467
+ * - PATH_MAX TOCTOU (CVE-2025-4517) via `b.guardFilename.
468
+ * verifyExtractionPath`
469
+ * - Symlink + hardlink + device entries (refused by default)
470
+ *
471
+ * @opts
472
+ * bombPolicy: { maxEntries, maxEntryDecompressedBytes,
473
+ * maxTotalDecompressedBytes, maxExpansionRatio },
474
+ * entryTypePolicy: { symlinks, hardlinks, devices, fifos, sockets },
475
+ * guardProfile: "strict" | "balanced" | "permissive" | "hipaa" | ...,
476
+ * audit: b.audit,
477
+ * signal: AbortSignal,
478
+ *
479
+ * @example
480
+ * var adapter = b.archive.adapters.fs("/var/uploads/payload.zip");
481
+ * var reader = b.archive.read.zip(adapter);
482
+ * var entries = await reader.inspect();
483
+ * // → [{ name, size, compressedSize, crc, method, mtime, ... }, ...]
484
+ *
485
+ * var dest = b.archive.adapters.fs("/var/quarantine");
486
+ * var result = await reader.extract({ destination: "/var/quarantine" });
487
+ * // → { entries: [{ name, bytesWritten }, ...], bytesExtracted }
488
+ */
489
+ function zip(adapter, opts) {
490
+ if (!adapter || (adapter.kind !== "random-access" && adapter.kind !== "trusted-sequential")) {
491
+ throw new ArchiveReadError("archive-read/bad-adapter",
492
+ "b.archive.read.zip(adapter): adapter must come from b.archive.adapters.* " +
493
+ "— got " + (adapter && adapter.kind));
494
+ }
495
+ if (adapter.kind === "trusted-sequential") {
496
+ throw new ArchiveReadError("archive-read/wrong-entry-point",
497
+ "trusted-sequential adapters MUST be passed to b.archive.read.zip." +
498
+ "fromTrustedStream(adapter, opts) — the random-access entry point " +
499
+ "requires { size, range(offset, length) }");
500
+ }
501
+ opts = opts || {};
502
+ var bombPolicy = _normalizeBombPolicy(opts.bombPolicy);
503
+ var entryTypePolicy = _normalizeEntryTypePolicy(opts.entryTypePolicy);
504
+ var cdCache = null;
505
+
506
+ async function _loadCD() {
507
+ if (cdCache) return cdCache;
508
+ var eocd = await _locateEocd(adapter);
509
+ var entries = await _readCentralDirectory(adapter, eocd);
510
+ cdCache = { eocd: eocd, entries: entries };
511
+ return cdCache;
512
+ }
513
+
514
+ async function inspect() {
515
+ var loaded = await _loadCD();
516
+ _enforceBombPolicy(loaded.entries, bombPolicy);
517
+ _emitAudit(opts, "archive.read.inspect", "success", {
518
+ entries: loaded.entries.length,
519
+ cdSize: loaded.eocd.cdSize,
520
+ });
521
+ // Return a shallow copy of each entry without the LFH offset (the
522
+ // operator-facing shape doesn't need wire-format internals).
523
+ return loaded.entries.map(function (e) {
524
+ return {
525
+ name: e.name,
526
+ size: e.uncompressedSize,
527
+ compressedSize: e.compressedSize,
528
+ crc: e.crc,
529
+ method: e.method === METHOD_DEFLATE_ID ? "deflate"
530
+ : e.method === METHOD_STORE_ID ? "store"
531
+ : ("method-" + e.method),
532
+ mtime: e.mtime,
533
+ isEncrypted: e.isEncrypted,
534
+ externalAttrs: e.externalAttrs,
535
+ extraFields: e.extraFields,
536
+ entryType: _classifyEntryType(e),
537
+ };
538
+ });
539
+ }
540
+
541
+ async function* entries() {
542
+ var loaded = await _loadCD();
543
+ _enforceBombPolicy(loaded.entries, bombPolicy);
544
+ for (var i = 0; i < loaded.entries.length; i += 1) {
545
+ yield loaded.entries[i];
546
+ }
547
+ }
548
+
549
+ async function extract(extractOpts) {
550
+ extractOpts = extractOpts || {};
551
+ if (typeof extractOpts.destination !== "string" || extractOpts.destination.length === 0) {
552
+ throw new ArchiveReadError("archive-read/no-destination",
553
+ "extract: opts.destination must be a non-empty string (target directory)");
554
+ }
555
+ var destination = nodePath.resolve(extractOpts.destination);
556
+ if (!nodeFs.existsSync(destination)) {
557
+ nodeFs.mkdirSync(destination, { recursive: true });
558
+ }
559
+ var loaded = await _loadCD();
560
+ _enforceBombPolicy(loaded.entries, bombPolicy);
561
+ // Compose b.guardArchive on the metadata pass — operators with a
562
+ // posture set declared via opts.guardProfile get the cascade.
563
+ var guardEntries = loaded.entries.map(function (e) {
564
+ return {
565
+ name: e.name,
566
+ size: e.uncompressedSize,
567
+ compressedSize: e.compressedSize,
568
+ isSymlink: _isUnixSymlinkAttrs(e.externalAttrs),
569
+ isHardlink: false,
570
+ linkTarget: null,
571
+ isDirectory: _isDirectoryEntry(e.name, e.externalAttrs),
572
+ isEncrypted: e.isEncrypted,
573
+ attrs: { externalAttrs: e.externalAttrs },
574
+ };
575
+ });
576
+ if (opts.guardProfile !== false) {
577
+ var profile = opts.guardProfile || "balanced";
578
+ var guardResult = guardArchive().validateEntries(guardEntries, { profile: profile });
579
+ if (guardResult && Array.isArray(guardResult.issues) && guardResult.issues.length > 0) {
580
+ // Refuse the whole archive when any entry trips a critical
581
+ // guard issue; collect every issue for the audit trail.
582
+ var critical = guardResult.issues.filter(function (i) { return i.severity === "critical"; });
583
+ if (critical.length > 0) {
584
+ _emitAudit(opts, "archive.read.extract.refused", "refused", {
585
+ entries: loaded.entries.length,
586
+ issues: critical.map(function (i) { return i.ruleId; }),
587
+ });
588
+ throw new ArchiveReadError("archive-read/guard-refused",
589
+ "extract refused — " + critical.length + " critical guard issue(s): " +
590
+ critical.map(function (i) { return i.ruleId + " (" + i.snippet + ")"; }).join("; "));
591
+ }
592
+ }
593
+ }
594
+ var written = [];
595
+ var bytesExtracted = 0;
596
+ var totalDecompressed = 0;
597
+ try {
598
+ for (var i = 0; i < loaded.entries.length; i += 1) {
599
+ var entry = loaded.entries[i];
600
+ // Skip directory + dangerous-by-default entry types unless the
601
+ // entry-type policy opts in.
602
+ if (entry.isEncrypted && !extractOpts.allowEncrypted) {
603
+ throw new ArchiveReadError("archive-read/encrypted-entry",
604
+ "entry " + JSON.stringify(entry.name) + " is encrypted — " +
605
+ "v0.12.7 does not decrypt; Flavor 1/2/3 land v0.12.10/v0.12.11");
606
+ }
607
+ var typeRefusal = _enforceEntryTypePolicy(entry, entryTypePolicy);
608
+ if (typeRefusal) {
609
+ throw new ArchiveReadError("archive-read/entry-type-refused",
610
+ "entry " + JSON.stringify(entry.name) + " is a " + typeRefusal +
611
+ " — refused by entryTypePolicy (opt in via b.guardArchive.entryTypePolicy({ " +
612
+ typeRefusal + "s: true }))");
613
+ }
614
+ if (_isDirectoryEntry(entry.name, entry.externalAttrs)) {
615
+ // Materialize the directory ahead of any contained-file
616
+ // entries (operator-shipped archives sometimes order entries
617
+ // such that the directory comes after its files; mkdirSync
618
+ // recursive handles that case).
619
+ var dirPath = guardFilename().verifyExtractionPath(entry.name, destination);
620
+ nodeFs.mkdirSync(dirPath, { recursive: true });
621
+ continue;
622
+ }
623
+ // Path safety — dual-check (string + realpath agreement).
624
+ var resolvedPath = guardFilename().verifyExtractionPath(entry.name, destination);
625
+ // Make sure the parent directory exists before write.
626
+ var parentDir = nodePath.dirname(resolvedPath);
627
+ if (!nodeFs.existsSync(parentDir)) {
628
+ nodeFs.mkdirSync(parentDir, { recursive: true });
629
+ }
630
+ // Refuse to overwrite pre-existing files. Atomic-rollback
631
+ // requires that we only ever DELETE files we CREATED; if a
632
+ // later entry fails (LFH/CD skew, bomb-cap trip), the catch
633
+ // block must not erase operator files that lived under the
634
+ // destination before extract ran. The contract is: extract
635
+ // into a fresh / empty subtree, or refuse. Operators with a
636
+ // legitimate merge use case make a copy first.
637
+ if (nodeFs.existsSync(resolvedPath)) {
638
+ throw new ArchiveReadError("archive-read/destination-exists",
639
+ "extract: destination file already exists at " +
640
+ JSON.stringify(resolvedPath) + " — refuse to overwrite; pass an " +
641
+ "empty / fresh destination directory or remove the existing file");
642
+ }
643
+ // Verify LFH matches CD before decompressing.
644
+ var lfhResult = await _verifyLfhMatchesCd(adapter, entry);
645
+ // Decompress.
646
+ var body = await _decompressEntry(adapter, entry, lfhResult.dataStart, bombPolicy);
647
+ totalDecompressed += body.length;
648
+ if (totalDecompressed > bombPolicy.maxTotalDecompressedBytes) {
649
+ throw new ArchiveReadError("archive-read/total-too-large",
650
+ "cumulative uncompressed=" + totalDecompressed +
651
+ " exceeds maxTotalDecompressedBytes during extract");
652
+ }
653
+ // Write entry to disk. Atomic rename via a tmp file so a
654
+ // partial write during inflate doesn't leave a half-file at
655
+ // the canonical name. Pre-existence check above guarantees
656
+ // the rename targets a non-existent path.
657
+ var tmpPath = resolvedPath + ".__blamejs-archive-read-tmp__";
658
+ nodeFs.writeFileSync(tmpPath, body);
659
+ nodeFs.renameSync(tmpPath, resolvedPath);
660
+ written.push({ name: entry.name, bytesWritten: body.length, path: resolvedPath });
661
+ bytesExtracted += body.length;
662
+ }
663
+ } catch (extractErr) {
664
+ // Clean up any partial extract — the destination tree may now
665
+ // contain files from successful entries (we only get here for
666
+ // entries we just CREATED; pre-existence was refused above).
667
+ // rm them so the operator sees an atomic refusal rather than a
668
+ // half-extracted state on disk.
669
+ try {
670
+ for (var w = 0; w < written.length; w += 1) {
671
+ if (nodeFs.existsSync(written[w].path)) {
672
+ nodeFs.rmSync(written[w].path);
673
+ }
674
+ }
675
+ } catch (_e) { /* drop-silent — cleanup best-effort */ }
676
+ _emitAudit(opts, "archive.read.extract.aborted", "failure", {
677
+ entries: loaded.entries.length,
678
+ written: written.length,
679
+ bytesExtracted: bytesExtracted,
680
+ error: extractErr && (extractErr.code || extractErr.message) || String(extractErr),
681
+ });
682
+ throw extractErr;
683
+ }
684
+ _emitAudit(opts, "archive.read.extract.completed", "success", {
685
+ entries: loaded.entries.length,
686
+ bytesExtracted: bytesExtracted,
687
+ });
688
+ return {
689
+ entries: written,
690
+ destinationRoot: destination,
691
+ bytesExtracted: bytesExtracted,
692
+ };
693
+ }
694
+
695
+ return {
696
+ kind: "zip-random-access",
697
+ inspect: inspect,
698
+ entries: entries,
699
+ extract: extract,
700
+ };
701
+ }
702
+
703
+ // ---- Trusted-stream variant ----------------------------------------------
704
+
705
+ /**
706
+ * @primitive b.archive.read.zip.fromTrustedStream
707
+ * @signature b.archive.read.zip.fromTrustedStream(adapter, opts?)
708
+ * @since 0.12.7
709
+ * @status stable
710
+ * @related b.archive.read.zip, b.archive.adapters.trustedStream
711
+ *
712
+ * Forward-scan-only ZIP reader for trusted Readable sources. No
713
+ * central-directory comparison — operators reaching for this primitive
714
+ * are declaring they own the producer (e.g. piping their own
715
+ * `b.archive.zip().toStream()` output back into a reader for round-trip
716
+ * verification).
717
+ *
718
+ * Adversarial input MUST use the random-access entry point with an
719
+ * `fs` / `buffer` / `objectStore` / `http` adapter.
720
+ *
721
+ * @opts
722
+ * bombPolicy: { maxEntries, maxEntryDecompressedBytes,
723
+ * maxTotalDecompressedBytes, maxExpansionRatio },
724
+ * audit: b.audit,
725
+ *
726
+ * @example
727
+ * var produced = fs.createReadStream("./own-export.zip");
728
+ * var reader = b.archive.read.zip.fromTrustedStream(
729
+ * b.archive.adapters.trustedStream(produced)
730
+ * );
731
+ * for await (var e of reader.entries()) console.log(e.name, e.size);
732
+ */
733
+ function fromTrustedStream(adapter, opts) {
734
+ if (!adapter || adapter.kind !== "trusted-sequential") {
735
+ throw new ArchiveReadError("archive-read/bad-adapter",
736
+ "fromTrustedStream: adapter must come from b.archive.adapters.trustedStream(readable)");
737
+ }
738
+ opts = opts || {};
739
+ var bombPolicy = _normalizeBombPolicy(opts.bombPolicy);
740
+ void bombPolicy;
741
+
742
+ // Trusted stream walks LFH-by-LFH. v0.12.7 ships the API surface +
743
+ // a basic LFH walker for round-trip verification of the framework's
744
+ // own emitted archives. The full feature parity (extraction via
745
+ // streaming inflate, data-descriptor scanning) is intentionally
746
+ // deferred to v0.12.8 alongside the tar reader's sequential mode.
747
+ async function inspect() {
748
+ throw new ArchiveReadError("archive-read/trusted-stream-inspect-deferred",
749
+ "fromTrustedStream.inspect() is deferred to v0.12.8 — use the random-access entry " +
750
+ "point with b.archive.adapters.buffer(await collect(readable)) for v0.12.7");
751
+ }
752
+
753
+ async function* entries() {
754
+ throw new ArchiveReadError("archive-read/trusted-stream-entries-deferred",
755
+ "fromTrustedStream.entries() is deferred to v0.12.8 — collect into buffer for v0.12.7");
756
+ }
757
+
758
+ async function extract() {
759
+ throw new ArchiveReadError("archive-read/trusted-stream-extract-deferred",
760
+ "fromTrustedStream.extract() is deferred to v0.12.8 — collect into buffer for v0.12.7");
761
+ }
762
+
763
+ return {
764
+ kind: "zip-trusted-sequential",
765
+ inspect: inspect,
766
+ entries: entries,
767
+ extract: extract,
768
+ };
769
+ }
770
+
771
+ zip.fromTrustedStream = fromTrustedStream;
772
+
773
+ module.exports = {
774
+ zip: zip,
775
+ ArchiveReadError: ArchiveReadError,
776
+ DEFAULT_BOMB_POLICY: DEFAULT_BOMB_POLICY,
777
+ DEFAULT_ENTRY_TYPE_POLICY: DEFAULT_ENTRY_TYPE_POLICY,
778
+ // exposed for sibling modules (lib/safe-archive.js + tests)
779
+ _locateEocd: _locateEocd,
780
+ _readCentralDirectory: _readCentralDirectory,
781
+ };