@blamejs/blamejs-shop 0.0.83 → 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.
- package/CHANGELOG.md +4 -0
- package/lib/email-campaigns.js +1 -1
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +4 -0
- package/lib/vendor/blamejs/README.md +1 -1
- package/lib/vendor/blamejs/SECURITY.md +1 -0
- package/lib/vendor/blamejs/api-snapshot.json +151 -2
- package/lib/vendor/blamejs/fuzz/safe-archive.fuzz.js +37 -0
- package/lib/vendor/blamejs/index.js +15 -1
- package/lib/vendor/blamejs/lib/archive-adapters.js +629 -0
- package/lib/vendor/blamejs/lib/archive-read.js +781 -0
- package/lib/vendor/blamejs/lib/archive-tar-read.js +418 -0
- package/lib/vendor/blamejs/lib/archive-tar.js +557 -0
- package/lib/vendor/blamejs/lib/archive.js +17 -0
- package/lib/vendor/blamejs/lib/audit.js +22 -7
- package/lib/vendor/blamejs/lib/backup/index.js +429 -0
- package/lib/vendor/blamejs/lib/guard-archive.js +180 -0
- package/lib/vendor/blamejs/lib/guard-filename.js +205 -0
- package/lib/vendor/blamejs/lib/safe-archive.js +295 -0
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.7.json +86 -0
- package/lib/vendor/blamejs/release-notes/v0.12.8.json +81 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/archive-read.test.js +247 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/archive-tar.test.js +228 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +127 -0
- package/package.json +2 -2
|
@@ -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
|
+
};
|