amicus 4.9.5 → 4.9.6
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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +86 -0
- package/README.md +1 -1
- package/docs/ROADMAP.md +4 -4
- package/docs/architecture-map.md +31 -7
- package/docs/configuration.md +91 -11
- package/docs/troubleshooting.md +96 -3
- package/docs/usage.md +1 -1
- package/package.json +2 -1
- package/scripts/postinstall.js +24 -3
- package/src/sidecar/electron-cache.js +41 -1
- package/src/sidecar/electron-custody.js +180 -0
- package/src/sidecar/electron-ensure.js +25 -4
- package/src/sidecar/electron-env-scrub.js +233 -0
- package/src/sidecar/electron-install.js +115 -125
- package/src/sidecar/electron-layout.js +300 -0
- package/src/sidecar/electron-native-plan.js +157 -0
- package/src/sidecar/electron-native-rescue.js +231 -0
- package/src/sidecar/electron-provision.js +148 -95
- package/src/sidecar/electron-refuse.js +253 -0
- package/src/sidecar/electron-repair-cache.js +212 -0
- package/src/sidecar/electron-rescue-notice.js +78 -0
- package/src/sidecar/electron-trust.js +45 -118
- package/src/sidecar/unzip.js +13 -3
- package/src/sidecar/zip-entry-write.js +268 -0
- package/src/sidecar/zip-from-buffer.js +220 -0
- package/src/sidecar/zip-name-scan.js +141 -0
- package/src/sidecar/zip-stall-bound.js +144 -0
- package/src/utils/doctor-electron-mcp-check.js +9 -2
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE ENTRY of an in-memory archive, and the classified failures every caller
|
|
3
|
+
* acts on.
|
|
4
|
+
*
|
|
5
|
+
* SPLIT OUT of zip-from-buffer.js in the v4.9.6 third round: that file reached
|
|
6
|
+
* the repo's 300-line gate when the symlink-target control and the stall bound
|
|
7
|
+
* grew, and the seam is real. This module answers "what does one archive entry
|
|
8
|
+
* become on disk, and whose fault is it when that fails"; zip-from-buffer.js
|
|
9
|
+
* answers "how is the archive driven, and when do we give up on it". Nothing
|
|
10
|
+
* here knows about yauzl's event loop and nothing there writes a byte.
|
|
11
|
+
*
|
|
12
|
+
* ── ERROR CODES ARE A CAUSAL CLAIM, AND THE CALLER DELETES ON ONE OF THEM ──
|
|
13
|
+
* `UNZIP_BUFFER_FAILED` = the ARCHIVE is bad; it is the ONLY code that lets
|
|
14
|
+
* `electron-repair-cache` evict a user's cached artifact. `UNZIP_DEST_FAILED` =
|
|
15
|
+
* the DESTINATION is bad (no space, a read-only dist/, a path too long, a
|
|
16
|
+
* machine that cannot represent a symlink). `UNZIP_BUFFER_UNAVAILABLE` =
|
|
17
|
+
* neither; yauzl would not load. Every throw in this module goes through one of
|
|
18
|
+
* the three constructors below, so no unclassified error can reach a caller
|
|
19
|
+
* that reads "unclassified" as "the archive is bad" — council finding D2, and
|
|
20
|
+
* the leak that reopened it (a raw stream error out of `collect`) is closed by
|
|
21
|
+
* `collect` tagging its own rejection.
|
|
22
|
+
*
|
|
23
|
+
* @module sidecar/zip-entry-write
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
const path = require('path');
|
|
29
|
+
const zlib = require('zlib');
|
|
30
|
+
const { pipeline } = require('stream/promises');
|
|
31
|
+
const { Transform } = require('stream');
|
|
32
|
+
|
|
33
|
+
const { collapseExcerpt } = require('../utils/text-sanitize');
|
|
34
|
+
|
|
35
|
+
/** @returns {Error} tagged with `code`, sanitized: every throw here is classified. */
|
|
36
|
+
function failure(code, message) {
|
|
37
|
+
return Object.assign(new Error(collapseExcerpt(message)), { code });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @returns {Error} an archive-is-bad failure: the caller MAY evict the artifact. */
|
|
41
|
+
const badArchive = (message) => failure('UNZIP_BUFFER_FAILED', message);
|
|
42
|
+
|
|
43
|
+
/** @returns {Error} a destination-is-bad failure: the caller must NOT evict anything. */
|
|
44
|
+
const badDestination = (message) => failure('UNZIP_DEST_FAILED', message);
|
|
45
|
+
|
|
46
|
+
/** @returns {Error} the TERMINAL path-traversal refusal, in extract-zip's own
|
|
47
|
+
* `Out of bound path ` wording so unzip.js's UNSAFE_PATTERNS still classifies it. */
|
|
48
|
+
const outOfBound = (where, fileName) => failure('UNZIP_UNSAFE_ARCHIVE', `Out of bound path "${where}" found while processing file ${fileName}`);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @returns {Error} yauzl would not load. NOT an archive failure and NOT a
|
|
52
|
+
* destination failure: the artifact is fine and so is the disk. unzip.js
|
|
53
|
+
* records the v4.5.2 outage where an undeclared `extract-zip` threw
|
|
54
|
+
* MODULE_NOT_FOUND out of a bare `require` and took a whole function with it;
|
|
55
|
+
* `yauzl` is declared for that reason, and this guard is what stops a hoisting
|
|
56
|
+
* surprise from turning into a DELETED cache entry.
|
|
57
|
+
*/
|
|
58
|
+
const extractorUnavailable = (message) => failure('UNZIP_BUFFER_UNAVAILABLE', `the in-memory zip extractor is unavailable: ${message}`);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Collect a readable fully into one Buffer (a symlink target is a few bytes).
|
|
62
|
+
*
|
|
63
|
+
* CLASSIFIED, like every other throw here. This was the ONE throw site that
|
|
64
|
+
* rejected with the RAW yauzl/stream error, which carries no `code` — and
|
|
65
|
+
* `electron-repair-cache` read an unclassified extract failure as "the archive
|
|
66
|
+
* is bad" and DELETED the user's cached artifact. A read error on a symlink
|
|
67
|
+
* target IS an archive-side failure, so it is named as one rather than left to
|
|
68
|
+
* be guessed at.
|
|
69
|
+
*/
|
|
70
|
+
function collect(stream, what) {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const chunks = [];
|
|
73
|
+
stream.on('data', (c) => chunks.push(c));
|
|
74
|
+
stream.on('error', (e) => reject(badArchive(`could not read ${what}: ${(e && e.message) || e}`)));
|
|
75
|
+
stream.on('end', () => resolve(Buffer.concat(chunks)));
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One entry's payload stream, decompressed by yauzl exactly as extract-zip gets it. */
|
|
80
|
+
function entryStream(zipfile, entry) {
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
zipfile.openReadStream(entry, (err, stream) => {
|
|
83
|
+
if (err) { reject(badArchive(`${entry.fileName}: ${(err && err.message) || err}`)); return; }
|
|
84
|
+
resolve(stream);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Write one entry, checking its CRC-32 against the archive's own declaration.
|
|
91
|
+
* An INTEGRITY check, not a security control (CRC-32 is linear and forgeable):
|
|
92
|
+
* it catches a broken zlib or bad RAM, and is meaningful only because the
|
|
93
|
+
* whole-buffer sha256 already ran on these exact bytes. yauzl checks no CRC at
|
|
94
|
+
* all; `validateEntrySizes: true` covers the length half.
|
|
95
|
+
*
|
|
96
|
+
* `onBytes` AND `signal` ARE THE STALL BOUND'S TWO HALVES (round 3, seat A1 +
|
|
97
|
+
* B2), and both live here because this is the only function that writes.
|
|
98
|
+
*
|
|
99
|
+
* `onBytes(n)` reports WRITE PROGRESS, which is what the bound is armed against
|
|
100
|
+
* — the caller re-arms on bytes, never on entries, so a single 225 MB
|
|
101
|
+
* `electron.exe` on slow storage cannot look idle. It fires from the CRC
|
|
102
|
+
* transform, the stage immediately upstream of the sink, so it reports bytes the
|
|
103
|
+
* DESTINATION has accepted rather than bytes read out of the buffer. MEASURED
|
|
104
|
+
* (Node 24.18.0) against a sink whose `_write` never calls back: the counter
|
|
105
|
+
* stops, overshooting the sink by exactly one 64 KiB readable-side highWaterMark
|
|
106
|
+
* and no more, whether the wedge happens after 64 KiB or after 640 KiB. A hung
|
|
107
|
+
* destination therefore still stops the counter, which is what makes a genuine
|
|
108
|
+
* stall catchable.
|
|
109
|
+
*
|
|
110
|
+
* `signal` is what makes the bound STOP the work rather than merely report it.
|
|
111
|
+
* `pipeline` destroys every stream on abort — MEASURED: source and sink both
|
|
112
|
+
* `destroyed`, not one further byte counted or accepted — so no write can land
|
|
113
|
+
* after the caller has given up and started cleaning the incoming tree. The
|
|
114
|
+
* rejection it produces is a plain `AbortError` (`pipeline` does not carry the
|
|
115
|
+
* abort reason), which is why the caller keeps its own classified failure and
|
|
116
|
+
* discards this one.
|
|
117
|
+
*/
|
|
118
|
+
async function writeEntry({ zipfile, entry, dest, mode, fs, onBytes, signal }) {
|
|
119
|
+
const source = await entryStream(zipfile, entry);
|
|
120
|
+
let crc = 0;
|
|
121
|
+
// Accumulated by a TRANSFORM in the pipeline, never by a `data` listener:
|
|
122
|
+
// attaching one starts the flow before `pipeline` has piped it, losing bytes.
|
|
123
|
+
const crcThrough = new Transform({
|
|
124
|
+
transform(chunk, _enc, cb) {
|
|
125
|
+
crc = zlib.crc32(chunk, crc);
|
|
126
|
+
if (onBytes) { onBytes(chunk.length); }
|
|
127
|
+
cb(null, chunk);
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
let sink;
|
|
131
|
+
try {
|
|
132
|
+
sink = fs.createWriteStream(dest, { mode });
|
|
133
|
+
} catch (e) {
|
|
134
|
+
throw badDestination(`could not write ${entry.fileName}: ${(e && e.message) || e}`);
|
|
135
|
+
}
|
|
136
|
+
// WHICH SIDE FAILED FIRST is the causal claim the caller acts on (D2): a bad
|
|
137
|
+
// archive may be evicted, a full disk must never be. `pipeline` destroys the
|
|
138
|
+
// other half after the first error, so both ends usually end up emitting —
|
|
139
|
+
// only the FIRST one recorded says what actually happened.
|
|
140
|
+
let first = null;
|
|
141
|
+
const note = (from) => (e) => { if (!first) { first = { from, e }; } };
|
|
142
|
+
source.on('error', note('archive'));
|
|
143
|
+
sink.on('error', note('dest'));
|
|
144
|
+
try {
|
|
145
|
+
await pipeline(source, crcThrough, sink, ...(signal ? [{ signal }] : []));
|
|
146
|
+
} catch (e) {
|
|
147
|
+
const cause = first ? first.e : e;
|
|
148
|
+
const detail = `${entry.fileName}: ${(cause && cause.message) || cause}`;
|
|
149
|
+
throw first && first.from === 'dest'
|
|
150
|
+
? badDestination(`could not write ${detail}`)
|
|
151
|
+
: badArchive(`could not inflate ${detail}`);
|
|
152
|
+
}
|
|
153
|
+
if ((crc >>> 0) !== (entry.crc32 >>> 0)) {
|
|
154
|
+
throw badArchive(`crc32 mismatch for ${entry.fileName}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Create one symlink, refusing a target that resolves outside `root`.
|
|
160
|
+
*
|
|
161
|
+
* `canonical` is `realpathSync` of the directory the link is ACTUALLY created
|
|
162
|
+
* in — never `path.dirname(dest)`. That distinction is the whole control, and
|
|
163
|
+
* getting it wrong was MEASURED to defeat the check completely: three entries
|
|
164
|
+
* naming `L0`, `L0/L1`, `L0/L1/L2`, each a symlink to `.`, make the LEXICAL
|
|
165
|
+
* dirname `<root>/L0/L1/L2` three levels deeper than the real one (`<root>`),
|
|
166
|
+
* so a fourth entry `L0/L1/L2/x -> ../../../victim` resolved to `<root>/victim`
|
|
167
|
+
* — inside, accepted — while the link really landed at `<root>/../../../victim`.
|
|
168
|
+
* Escape depth tracked chain length 1:1. Every one of those names passes
|
|
169
|
+
* yauzl's `validateFileName`, so one ordinary-looking archive was arbitrary
|
|
170
|
+
* same-user file write outside the extraction root.
|
|
171
|
+
*
|
|
172
|
+
* WHAT THIS DOES AND DOES NOT PROMISE. It resolves the target from the real
|
|
173
|
+
* directory and refuses anything that leaves `root`; it does NOT follow
|
|
174
|
+
* symlinks inside the target's own intermediate components, so a link pointing
|
|
175
|
+
* at an in-root path that some LATER entry turns into a link elsewhere is not
|
|
176
|
+
* caught here — that shape is caught by the per-entry `realpath` bound check in
|
|
177
|
+
* `placeEntry`, which re-runs after every earlier entry has been written.
|
|
178
|
+
*
|
|
179
|
+
* `signal` is checked immediately before the `symlinkSync`. A link target is a
|
|
180
|
+
* handful of bytes read through `collect`, which is not a `pipeline` and so is
|
|
181
|
+
* not destroyed by the abort — without this check the stall bound could fire and
|
|
182
|
+
* a symlink still appear in the tree the caller is about to delete.
|
|
183
|
+
*/
|
|
184
|
+
async function writeSymlink({ zipfile, entry, canonical, root, fs, signal }) {
|
|
185
|
+
const target = (await collect(await entryStream(zipfile, entry), `the symlink target for ${entry.fileName}`)).toString('utf8');
|
|
186
|
+
const dest = path.join(canonical, path.basename(entry.fileName));
|
|
187
|
+
const resolved = path.resolve(canonical, target);
|
|
188
|
+
const rel = path.relative(root, resolved);
|
|
189
|
+
// SYMLINKESCAPE: `..` at the head, or an absolute answer (a different Windows
|
|
190
|
+
// drive), means the link points out of the tree amicus is allowed to write.
|
|
191
|
+
if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
|
|
192
|
+
throw outOfBound(resolved, entry.fileName);
|
|
193
|
+
}
|
|
194
|
+
if (signal && signal.aborted) { return; }
|
|
195
|
+
try {
|
|
196
|
+
fs.symlinkSync(target, dest);
|
|
197
|
+
} catch (e) {
|
|
198
|
+
throw badDestination(`could not create the symlink ${entry.fileName}: ${(e && e.message) || e}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** stat mode constants, as extract-zip decodes them from externalFileAttributes. */
|
|
203
|
+
const IFMT = 61440;
|
|
204
|
+
const IFDIR = 16384;
|
|
205
|
+
const IFLNK = 40960;
|
|
206
|
+
|
|
207
|
+
/** extract-zip's getExtractedMode, with its 0755/0644 defaults. */
|
|
208
|
+
function extractedMode(entryMode, isDir) {
|
|
209
|
+
if (entryMode !== 0) { return entryMode; }
|
|
210
|
+
return isDir ? 0o755 : 0o644;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* One entry, mirroring extract-zip's Extractor.extractEntry decision order.
|
|
215
|
+
*
|
|
216
|
+
* MOVED HERE from zip-from-buffer.js in the third council round, when the stall
|
|
217
|
+
* bound's repair pushed that file back over the 300-line gate. It is not a
|
|
218
|
+
* convenience move: this function answers "what does one archive entry become on
|
|
219
|
+
* disk, and whose fault is it when that fails", which is this module's whole
|
|
220
|
+
* subject, and it calls nothing but this module's own writers. What stays next
|
|
221
|
+
* door is the archive DRIVER — yauzl's event loop, the bound, and the decision
|
|
222
|
+
* to give up.
|
|
223
|
+
*
|
|
224
|
+
* `signal` is the stall bound's halt: an entry that arrives after the bound
|
|
225
|
+
* fired creates NOTHING — not the directory, not the file — because the caller
|
|
226
|
+
* is already deleting the incoming tree.
|
|
227
|
+
* @returns {Promise<boolean>} true if the entry was placed (false = skipped)
|
|
228
|
+
*/
|
|
229
|
+
async function placeEntry({ zipfile, entry, root, fs, signal, onBytes }) {
|
|
230
|
+
// Nothing is created for an entry that arrives after the bound fired.
|
|
231
|
+
if (signal && signal.aborted) { return false; }
|
|
232
|
+
if (entry.fileName.startsWith('__MACOSX/')) { return false; }
|
|
233
|
+
if (entry.isEncrypted()) { throw badArchive(`${entry.fileName} is encrypted`); }
|
|
234
|
+
const dest = path.join(root, entry.fileName);
|
|
235
|
+
const mode = (entry.externalFileAttributes >> 16) & 0xFFFF;
|
|
236
|
+
const symlink = (mode & IFMT) === IFLNK;
|
|
237
|
+
let isDir = (mode & IFMT) === IFDIR;
|
|
238
|
+
if (!isDir && entry.fileName.endsWith('/')) { isDir = true; }
|
|
239
|
+
if (!isDir) { isDir = ((entry.versionMadeBy >> 8) === 0 && entry.externalFileAttributes === 16); }
|
|
240
|
+
const procMode = extractedMode(mode, isDir) & 0o777;
|
|
241
|
+
const destDir = isDir ? dest : path.dirname(dest);
|
|
242
|
+
let canonical;
|
|
243
|
+
try {
|
|
244
|
+
fs.mkdirSync(destDir, isDir ? { recursive: true, mode: procMode } : { recursive: true });
|
|
245
|
+
canonical = fs.realpathSync(destDir);
|
|
246
|
+
} catch (e) {
|
|
247
|
+
throw badDestination(`could not create ${destDir}: ${(e && e.message) || e}`);
|
|
248
|
+
}
|
|
249
|
+
// extract-zip's check, VERBATIM — re-run per entry, AFTER earlier entries were
|
|
250
|
+
// written, so a symlink an earlier entry created cannot redirect a later one.
|
|
251
|
+
if (path.relative(root, canonical).split(path.sep).includes('..')) {
|
|
252
|
+
throw outOfBound(canonical, entry.fileName);
|
|
253
|
+
}
|
|
254
|
+
if (isDir) { return true; }
|
|
255
|
+
if (symlink) {
|
|
256
|
+
// `canonical`, NEVER `dest`: the link's target is resolved against the
|
|
257
|
+
// directory realpath says it is created in (see writeSymlink).
|
|
258
|
+
await writeSymlink({ zipfile, entry, canonical, root, fs, signal });
|
|
259
|
+
} else {
|
|
260
|
+
await writeEntry({ zipfile, entry, dest, mode: procMode, fs, onBytes, signal });
|
|
261
|
+
}
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
module.exports = {
|
|
266
|
+
failure, badArchive, badDestination, outOfBound, extractorUnavailable,
|
|
267
|
+
collect, entryStream, writeEntry, writeSymlink, placeEntry,
|
|
268
|
+
};
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract an archive that is ALREADY IN MEMORY and ALREADY HASHED.
|
|
3
|
+
*
|
|
4
|
+
* There is no `zip` parameter because there is no file. The bytes arrive as a
|
|
5
|
+
* Buffer that `electron-custody.readArtifactBytes` read through a single
|
|
6
|
+
* descriptor and that `electron-trust.verifyArtifactBytes` hashed; nothing here
|
|
7
|
+
* resolves a path to the artifact, so there is no second read for a same-uid
|
|
8
|
+
* attacker to race. `electron-custody.js` carries the measured refutations of
|
|
9
|
+
* the two remedies this replaces. NO HAND-WRITTEN ZIP PARSING: a council judge
|
|
10
|
+
* killed a design that sliced local file headers by hand — "a terminal path with
|
|
11
|
+
* no rescue", since it would have had to re-derive ZIP64 local headers, data
|
|
12
|
+
* descriptors and the central-vs-local size disagreement `openReadStream`
|
|
13
|
+
* already handles. Every byte offset comes from `yauzl.openReadStream`, exactly
|
|
14
|
+
* as under extract-zip; only WHERE yauzl reads from changes.
|
|
15
|
+
*
|
|
16
|
+
* ── THE `autoClose` TRAP, MEASURED ────────────────────────────────────────
|
|
17
|
+
* `yauzl.fromBuffer` sets `options.autoClose = false` UNCONDITIONALLY
|
|
18
|
+
* (yauzl@2.10.0 index.js, line 67) — an explicit `{autoClose: true}` is silently
|
|
19
|
+
* discarded — and `extract-zip` resolves on the zipfile's `'close'` event
|
|
20
|
+
* (extract-zip@2.0.1 index.js, lines 32-37), so a naive port of extract-zip onto
|
|
21
|
+
* a buffer HANGS FOREVER. Both council judges hit it. MEASURED on the installed
|
|
22
|
+
* libraries (Windows 11, Node 24.18.0) it is worse than the brief recorded:
|
|
23
|
+
* `ZipFile.close()` only calls `reader.unref()`, and fd-slicer's BufferSlicer
|
|
24
|
+
* has no close and emits nothing on unref (fd-slicer index.js, lines 282-288)
|
|
25
|
+
* where FdSlicer closes the fd and emits `'close'` — so under `fromBuffer` that
|
|
26
|
+
* event is UNREACHABLE even if you call `close()` (500 ms after END fired, close
|
|
27
|
+
* had not). THE FIX, stated so nobody "tidies" it: this module resolves on
|
|
28
|
+
* `'end'`, which yauzl emits once the central directory is exhausted, and drives
|
|
29
|
+
* `readEntry()` only after the previous entry has been fully written, so `'end'`
|
|
30
|
+
* cannot arrive before the last write lands. `close()` is still called, for the
|
|
31
|
+
* reader refcount, but NOTHING waits on it. Pinned by YAUZLHANG.
|
|
32
|
+
*
|
|
33
|
+
* ── PARITY WITH extract-zip, AND THE ONE DELIBERATE DIFFERENCE ────────────
|
|
34
|
+
* The per-entry decisions all live in `./zip-entry-write :: placeEntry` now, and
|
|
35
|
+
* the `__MACOSX/` skip, the mode decode, both directory failsafes, the 0755/0644
|
|
36
|
+
* defaults and the per-entry `realpath(destDir)` out-of-bound check with its
|
|
37
|
+
* message VERBATIM are copied from extract-zip@2.0.1 index.js, lines 48-160, so
|
|
38
|
+
* `UNSAFE_PATTERNS` and `electron-refuse.isUnsafeArchive` classify exactly what
|
|
39
|
+
* they classified. The difference: a SYMLINK whose target resolves outside the
|
|
40
|
+
* extraction root is REFUSED here and is not by extract-zip, and it is resolved
|
|
41
|
+
* against the REALPATH of the directory the link lands in because the lexical
|
|
42
|
+
* `path.dirname` was measured to be defeated outright by a chain of
|
|
43
|
+
* directory-symlink entries earlier in the same archive. That is a behaviour
|
|
44
|
+
* change on a shape amicus cannot test on this machine (the darwin `.app` bundle
|
|
45
|
+
* is the only electron artifact with real symlinks), so it is refused in the
|
|
46
|
+
* same `Out of bound path` wording, exercised against synthetic archives, and
|
|
47
|
+
* named in the report as unverified on macOS.
|
|
48
|
+
*
|
|
49
|
+
* ── ERROR CODES ARE A CAUSAL CLAIM ───────────────────────────────────────
|
|
50
|
+
* `UNZIP_BUFFER_FAILED` = the ARCHIVE is bad. `UNZIP_DEST_FAILED` = the
|
|
51
|
+
* DESTINATION is (no space, a read-only dist, a path too long).
|
|
52
|
+
* `UNZIP_BUFFER_UNAVAILABLE` = neither, yauzl would not load.
|
|
53
|
+
* `UNZIP_BUFFER_STALLED` = no progress inside the bound; nobody learned anything
|
|
54
|
+
* about the archive OR the disk. The caller evicts on `UNZIP_BUFFER_FAILED`
|
|
55
|
+
* ONLY (finding D2). The constructors and the writers live in `./zip-entry-write`;
|
|
56
|
+
* `UNZIP_BUFFER_STALLED` and the windows that raise it live in `./zip-stall-bound`.
|
|
57
|
+
*
|
|
58
|
+
* @module sidecar/zip-from-buffer
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
'use strict';
|
|
62
|
+
|
|
63
|
+
const fsDefault = require('fs');
|
|
64
|
+
const path = require('path');
|
|
65
|
+
|
|
66
|
+
// The classified failures, and `placeEntry` — everything that decides what ONE
|
|
67
|
+
// entry becomes on disk. THE ONE-WAY ARROW: zip-from-buffer -> zip-entry-write,
|
|
68
|
+
// never back. `placeEntry` moved across that seam in the third council round,
|
|
69
|
+
// when this file hit the 300-line gate again: it answers "what does one entry
|
|
70
|
+
// become on disk", which is the neighbouring module's whole subject, while what
|
|
71
|
+
// stays here is "how is the archive DRIVEN, and when do we give up on it".
|
|
72
|
+
const {
|
|
73
|
+
failure, badArchive, badDestination, extractorUnavailable, placeEntry,
|
|
74
|
+
} = require('./zip-entry-write');
|
|
75
|
+
// THE GIVE-UP POLICY, and the whole record of how it has been wrong: the two
|
|
76
|
+
// windows, the bounded wait for an aborted write to come apart, and the
|
|
77
|
+
// classified failure. Read `./zip-stall-bound` before touching any of it.
|
|
78
|
+
const {
|
|
79
|
+
IDLE_MS, MAX_MS, UNWIND_MS, stalled, awaitUnwind,
|
|
80
|
+
} = require('./zip-stall-bound');
|
|
81
|
+
|
|
82
|
+
/** yauzl's own validateFileName refusals — three of unzip.js's UNSAFE_PATTERNS. */
|
|
83
|
+
const NAME_REFUSAL = /^(absolute path|invalid relative path|invalid characters in fileName): /;
|
|
84
|
+
|
|
85
|
+
/** yauzl's callback API as a promise, with `fromBuffer`'s options pinned here. */
|
|
86
|
+
function openBuffer(yauzl, bytes) {
|
|
87
|
+
return new Promise((resolve, reject) => {
|
|
88
|
+
yauzl.fromBuffer(bytes, { lazyEntries: true, validateEntrySizes: true }, (err, zipfile) => {
|
|
89
|
+
if (err) { reject(badArchive(`could not read the archive: ${(err && err.message) || err}`)); return; }
|
|
90
|
+
resolve(zipfile);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Extract `bytes` into `dir`. The caller has ALREADY hashed `bytes`.
|
|
97
|
+
*
|
|
98
|
+
* BOUNDED, AND EVERY WAIT IN IT IS BOUNDED. See `./zip-stall-bound`, which
|
|
99
|
+
* carries the whole record: an idle watchdog armed against BYTES WRITTEN (not entries completed),
|
|
100
|
+
* a hard cap, and a bounded wait for the aborted write to unwind — each one a
|
|
101
|
+
* live `setTimeout` handle, so a write that never completes becomes a catchable
|
|
102
|
+
* rejection instead of a promise that never settles, and SOME handle is alive
|
|
103
|
+
* for the whole of it, which is what stops Node exiting 0 mid-stall with a
|
|
104
|
+
* partial extract and no message. When a bound fires it ABORTS the in-flight
|
|
105
|
+
* write first, so no byte lands after the failure is decided; it then waits up
|
|
106
|
+
* to `unwindMs` for that write to come apart, and throws whether or not it did.
|
|
107
|
+
*
|
|
108
|
+
* @param {Buffer} bytes the whole archive, in this process's heap
|
|
109
|
+
* @param {object} o
|
|
110
|
+
* @param {string} o.dir absolute destination (created if absent)
|
|
111
|
+
* @param {number} [o.idleMs] no-progress window before the extract is stalled
|
|
112
|
+
* @param {number} [o.maxMs] hard cap on the whole extraction
|
|
113
|
+
* @param {number} [o.unwindMs] how long an aborted write may take to come apart
|
|
114
|
+
* @param {object} [o.deps] { fs, yauzl, log, setTimeout, clearTimeout }
|
|
115
|
+
* @returns {Promise<{strategy:'buffer', entries:number}>}
|
|
116
|
+
* @throws {Error} code 'UNZIP_UNSAFE_ARCHIVE' — terminal; never retried
|
|
117
|
+
* @throws {Error} code 'UNZIP_BUFFER_FAILED' — the archive is bad
|
|
118
|
+
* @throws {Error} code 'UNZIP_DEST_FAILED' — the destination is bad
|
|
119
|
+
* @throws {Error} code 'UNZIP_BUFFER_UNAVAILABLE' — yauzl could not be loaded
|
|
120
|
+
* @throws {Error} code 'UNZIP_BUFFER_STALLED' — no progress; NOT an artifact verdict
|
|
121
|
+
*/
|
|
122
|
+
async function extractZipBuffer(bytes, {
|
|
123
|
+
dir, idleMs = IDLE_MS, maxMs = MAX_MS, unwindMs = UNWIND_MS, deps = {},
|
|
124
|
+
} = {}) {
|
|
125
|
+
const fs = deps.fs || fsDefault;
|
|
126
|
+
const setTimer = deps.setTimeout || setTimeout;
|
|
127
|
+
const clearTimer = deps.clearTimeout || clearTimeout;
|
|
128
|
+
// GUARDED (the v4.5.2 lesson). `yauzl` IS declared in package.json; the guard
|
|
129
|
+
// keeps a broken install a refusal rather than a deleted cache entry.
|
|
130
|
+
let yauzl = deps.yauzl;
|
|
131
|
+
if (!yauzl) {
|
|
132
|
+
try { yauzl = require('yauzl'); } catch (e) { throw extractorUnavailable((e && e.message) || String(e)); }
|
|
133
|
+
}
|
|
134
|
+
if (!path.isAbsolute(dir)) { throw badDestination('Target directory is expected to be absolute'); }
|
|
135
|
+
let root;
|
|
136
|
+
try {
|
|
137
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
138
|
+
root = fs.realpathSync(dir);
|
|
139
|
+
} catch (e) {
|
|
140
|
+
throw badDestination(`could not prepare ${dir}: ${(e && e.message) || e}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const zipfile = await openBuffer(yauzl, bytes);
|
|
144
|
+
let entries = 0;
|
|
145
|
+
let written = 0; // BYTES the destination accepted — the progress signal
|
|
146
|
+
let idleTimer = null;
|
|
147
|
+
let maxTimer = null;
|
|
148
|
+
let inFlight = null; // the entry being written when the bound fires
|
|
149
|
+
const halt = new AbortController();
|
|
150
|
+
const cancelTimers = () => {
|
|
151
|
+
if (idleTimer !== null) { clearTimer(idleTimer); idleTimer = null; }
|
|
152
|
+
if (maxTimer !== null) { clearTimer(maxTimer); maxTimer = null; }
|
|
153
|
+
};
|
|
154
|
+
let thrown = null;
|
|
155
|
+
try {
|
|
156
|
+
await new Promise((resolve, reject) => {
|
|
157
|
+
let settled = false;
|
|
158
|
+
const finish = (fn, value) => {
|
|
159
|
+
if (settled) { return; }
|
|
160
|
+
settled = true;
|
|
161
|
+
cancelTimers();
|
|
162
|
+
fn(value);
|
|
163
|
+
};
|
|
164
|
+
// STOP THE WORK FIRST, THEN REPORT IT. A control that reports a failure
|
|
165
|
+
// while the work continues is not a control (round 3, seat A1): the abort
|
|
166
|
+
// destroys the in-flight pipeline, and `placeEntry`/`writeSymlink` refuse
|
|
167
|
+
// to start or finish anything once the signal is set.
|
|
168
|
+
const fail = (e) => {
|
|
169
|
+
if (!halt.signal.aborted) { halt.abort(e); }
|
|
170
|
+
finish(reject, e);
|
|
171
|
+
};
|
|
172
|
+
// The idle WATCHDOG re-arms itself from the marks it took, so a single
|
|
173
|
+
// huge entry that is writing steadily is progress and never a stall.
|
|
174
|
+
const armIdle = () => {
|
|
175
|
+
const atBytes = written;
|
|
176
|
+
const atEntries = entries;
|
|
177
|
+
idleTimer = setTimer(() => {
|
|
178
|
+
if (written !== atBytes || entries !== atEntries) { armIdle(); return; }
|
|
179
|
+
fail(stalled(`no extract progress for ${idleMs}ms`));
|
|
180
|
+
}, idleMs);
|
|
181
|
+
};
|
|
182
|
+
maxTimer = setTimer(() => fail(stalled(`extraction exceeded ${maxMs}ms`)), maxMs);
|
|
183
|
+
armIdle();
|
|
184
|
+
// yauzl's own validateFileName refusals ('absolute path: ', 'invalid
|
|
185
|
+
// relative path: ', 'invalid characters in fileName: ') arrive here.
|
|
186
|
+
zipfile.on('error', (e) => fail(failure(
|
|
187
|
+
NAME_REFUSAL.test((e && e.message) || '') ? 'UNZIP_UNSAFE_ARCHIVE' : 'UNZIP_BUFFER_FAILED',
|
|
188
|
+
(e && e.message) || 'the archive could not be read',
|
|
189
|
+
)));
|
|
190
|
+
// RESOLVE ON 'end', NEVER ON 'close' — see the docblock. `close` is
|
|
191
|
+
// unreachable under fromBuffer, so waiting for it hangs forever.
|
|
192
|
+
zipfile.on('end', () => finish(resolve));
|
|
193
|
+
zipfile.on('entry', (entry) => {
|
|
194
|
+
inFlight = placeEntry({
|
|
195
|
+
zipfile, entry, root, fs, signal: halt.signal, onBytes: (n) => { written += n; },
|
|
196
|
+
});
|
|
197
|
+
inFlight.then((placed) => {
|
|
198
|
+
if (settled) { return; } // the bound already fired; stop driving
|
|
199
|
+
if (placed) { entries += 1; }
|
|
200
|
+
zipfile.readEntry();
|
|
201
|
+
}, fail);
|
|
202
|
+
});
|
|
203
|
+
zipfile.readEntry();
|
|
204
|
+
});
|
|
205
|
+
} catch (e) {
|
|
206
|
+
thrown = e;
|
|
207
|
+
} finally {
|
|
208
|
+
cancelTimers();
|
|
209
|
+
// The abort has been issued; give the destroyed pipeline a BOUNDED window to
|
|
210
|
+
// unwind, so the caller's cleanup (extractBytesToDist deletes the incoming
|
|
211
|
+
// tree in its own `finally`) does not usually race an open descriptor — and
|
|
212
|
+
// so a pipeline that can never unwind cannot hang this call. Round 4.
|
|
213
|
+
if (inFlight) { await awaitUnwind(inFlight, unwindMs, setTimer, clearTimer); }
|
|
214
|
+
try { zipfile.close(); } catch { /* the buffer reader holds no fd */ }
|
|
215
|
+
}
|
|
216
|
+
if (thrown) { throw thrown; }
|
|
217
|
+
return { strategy: 'buffer', entries };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
module.exports = { extractZipBuffer };
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHAT NAMES DOES THIS ARCHIVE DECLARE? A read-only walk of the central
|
|
3
|
+
* directory, for the one caller that is about to hand an archive to a tool with
|
|
4
|
+
* no path-traversal check of its own.
|
|
5
|
+
*
|
|
6
|
+
* ── THE HOLE THIS CLOSES, MEASURED ────────────────────────────────────────
|
|
7
|
+
* `electron-native-rescue.js` refuses to rescue a `UNZIP_UNSAFE_ARCHIVE`, and
|
|
8
|
+
* that refusal is exactly right — but it keys on the REFUSAL yauzl happened to
|
|
9
|
+
* form, not on the hostile ENTRY. yauzl validates an entry's NAME in
|
|
10
|
+
* `_readEntry`, AFTER its size check (yauzl@2.10.0 index.js lines 407-426), so
|
|
11
|
+
* one bad entry earlier in the same archive ends the walk before any later name
|
|
12
|
+
* is ever looked at. MEASURED on the installed yauzl (Windows 11, Node 24), real
|
|
13
|
+
* `extractZipBuffer`, one archive per line:
|
|
14
|
+
*
|
|
15
|
+
* [`../../../PWNED.txt`] -> UNZIP_UNSAFE_ARCHIVE
|
|
16
|
+
* ("invalid relative path: …")
|
|
17
|
+
* [`first.bin` w/ the encrypted flag set,
|
|
18
|
+
* `../../../PWNED.txt`, `electron.exe`] -> UNZIP_BUFFER_FAILED
|
|
19
|
+
* ("compressed/uncompressed size
|
|
20
|
+
* mismatch for stored file: 4 != 4")
|
|
21
|
+
*
|
|
22
|
+
* The second archive is one flag bit different from the first and lands in the
|
|
23
|
+
* ONE class the rescue acts on, so the whole thing — traversal entry included —
|
|
24
|
+
* was handed to `tar` / `Expand-Archive`. What stopped the escape in that run was
|
|
25
|
+
* each tool's own check (`tar.exe`: `../../../PWNED.txt: Path contains '..'`,
|
|
26
|
+
* exit 1; `Expand-Archive`: `Can not process invalid archive entry '…'`, exit 0,
|
|
27
|
+
* nothing outside the destination in either case) — precisely the reliance
|
|
28
|
+
* `unzip.js` says amicus will not make: "a tool with no such check".
|
|
29
|
+
*
|
|
30
|
+
* ── WHAT THIS CAN AND CANNOT SEE, STATED BEFORE THE CODE ──────────────────
|
|
31
|
+
* The scan reads the CENTRAL DIRECTORY with size validation and string decoding
|
|
32
|
+
* both OFF, so the entry that breaks the extraction does not stop the walk.
|
|
33
|
+
* MEASURED on the archive above: all three names come back, the traversal entry
|
|
34
|
+
* included. It sees NOTHING when the central directory itself is unreadable —
|
|
35
|
+
* a truncated zip answers `end of central directory record signature not found`
|
|
36
|
+
* and yields no names at all — which is why the result carries `read` and the
|
|
37
|
+
* caller states that residual out loud rather than pretending to a guarantee.
|
|
38
|
+
* It also cannot see a SYMLINK whose TARGET escapes the root: that is bytes, not
|
|
39
|
+
* a name, and `zip-from-buffer.js` refuses it only because it reads the payload.
|
|
40
|
+
*
|
|
41
|
+
* ── AND IT IS ONLY EVER A NARROWING ───────────────────────────────────────
|
|
42
|
+
* Nothing here can make a rescue happen. A refusal it forms turns a
|
|
43
|
+
* `UNZIP_BUFFER_FAILED` into the TERMINAL `UNZIP_UNSAFE_ARCHIVE` its caller
|
|
44
|
+
* already excludes; silence changes nothing. So a bug in this file can cost the
|
|
45
|
+
* rescue, never widen it.
|
|
46
|
+
*
|
|
47
|
+
* @module sidecar/zip-name-scan
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
'use strict';
|
|
51
|
+
|
|
52
|
+
/** Hard bound: this is an in-memory walk, but nothing waits forever here. */
|
|
53
|
+
const SCAN_MS = 10_000;
|
|
54
|
+
|
|
55
|
+
/** No real electron artifact is near this; a hostile central directory can be. */
|
|
56
|
+
const MAX_ENTRIES = 200_000;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* yauzl's own `validateFileName` (index.js lines 607-619), under the options
|
|
60
|
+
* amicus really extracts with — `decodeStrings` on and `strictFileNames` off,
|
|
61
|
+
* which rewrites backslashes to `/` BEFORE validating (lines 420-426). The
|
|
62
|
+
* wording is yauzl's verbatim so a refusal built from it is classified by
|
|
63
|
+
* `unzip.js :: UNSAFE_PATTERNS` as well as by its `code`.
|
|
64
|
+
*
|
|
65
|
+
* `invalid characters in fileName` is deliberately absent: with the backslash
|
|
66
|
+
* rewrite in force yauzl cannot produce it, and inventing a refusal yauzl would
|
|
67
|
+
* not make is how a scan starts costing the rescue legitimate archives.
|
|
68
|
+
* @returns {string|null} yauzl's refusal for this name, or null
|
|
69
|
+
*/
|
|
70
|
+
function nameRefusal(name) {
|
|
71
|
+
const n = name.replace(/\\/g, '/');
|
|
72
|
+
if (/^[a-zA-Z]:/.test(n) || n.startsWith('/')) { return `absolute path: ${name}`; }
|
|
73
|
+
if (n.split('/').includes('..')) { return `invalid relative path: ${name}`; }
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Walk `bytes`' central directory and report the first entry name yauzl would
|
|
79
|
+
* refuse.
|
|
80
|
+
*
|
|
81
|
+
* DECODED AS LATIN-1, ON PURPOSE. `decodeStrings: false` hands back raw name
|
|
82
|
+
* Buffers, and latin-1 is the one decoding that maps bytes to characters 1:1 —
|
|
83
|
+
* no replacement characters, no multi-byte collapsing. Every byte the two rules
|
|
84
|
+
* look at (`.`, `/`, `\`, `:`) is ASCII, and no continuation byte of a UTF-8
|
|
85
|
+
* sequence can be ASCII, so this sees exactly what a correct decode would.
|
|
86
|
+
*
|
|
87
|
+
* NEVER THROWS, never rejects: a caller reaching this already has a failure in
|
|
88
|
+
* flight and must not acquire a second one from a diagnostic.
|
|
89
|
+
*
|
|
90
|
+
* @param {Buffer} bytes the archive, in this process's heap
|
|
91
|
+
* @param {object} [o]
|
|
92
|
+
* @param {object} [o.deps] { yauzl, setTimeout, clearTimeout }
|
|
93
|
+
* @returns {Promise<{read:boolean, refusal:string|null, why:string}>}
|
|
94
|
+
* `read` = the whole central directory was enumerated, so `refusal: null`
|
|
95
|
+
* really means "no such name in this archive". `read:false` means the scan
|
|
96
|
+
* proved nothing.
|
|
97
|
+
*/
|
|
98
|
+
function scanEntryNames(bytes, { deps = {} } = {}) {
|
|
99
|
+
const setTimer = deps.setTimeout || setTimeout;
|
|
100
|
+
const clearTimer = deps.clearTimeout || clearTimeout;
|
|
101
|
+
let yauzl = deps.yauzl;
|
|
102
|
+
if (!yauzl) {
|
|
103
|
+
try {
|
|
104
|
+
// eslint-disable-next-line global-require
|
|
105
|
+
yauzl = require('yauzl');
|
|
106
|
+
} catch (e) { return Promise.resolve({ read: false, refusal: null, why: `yauzl unavailable: ${(e && e.message) || e}` }); }
|
|
107
|
+
}
|
|
108
|
+
return new Promise((resolve) => {
|
|
109
|
+
let settled = false;
|
|
110
|
+
let refusal = null;
|
|
111
|
+
let seen = 0;
|
|
112
|
+
let timer = null;
|
|
113
|
+
const done = (read, why) => {
|
|
114
|
+
if (settled) { return; }
|
|
115
|
+
settled = true;
|
|
116
|
+
if (timer !== null) { clearTimer(timer); }
|
|
117
|
+
resolve({ read, refusal, why });
|
|
118
|
+
};
|
|
119
|
+
timer = setTimer(() => done(false, `the name scan exceeded ${SCAN_MS}ms`), SCAN_MS);
|
|
120
|
+
try {
|
|
121
|
+
yauzl.fromBuffer(bytes, { lazyEntries: true, decodeStrings: false, validateEntrySizes: false }, (err, zipfile) => {
|
|
122
|
+
if (err || !zipfile) { done(false, `could not read the central directory: ${(err && err.message) || 'no zipfile'}`); return; }
|
|
123
|
+
zipfile.on('error', (e) => done(false, `could not read the central directory: ${(e && e.message) || e}`));
|
|
124
|
+
zipfile.on('end', () => done(true, ''));
|
|
125
|
+
zipfile.on('entry', (entry) => {
|
|
126
|
+
seen += 1;
|
|
127
|
+
const name = Buffer.isBuffer(entry.fileName) ? entry.fileName.toString('latin1') : String(entry.fileName);
|
|
128
|
+
refusal = nameRefusal(name);
|
|
129
|
+
if (refusal) { done(true, ''); return; }
|
|
130
|
+
if (seen >= MAX_ENTRIES) { done(false, `stopped after ${MAX_ENTRIES} entries`); return; }
|
|
131
|
+
zipfile.readEntry();
|
|
132
|
+
});
|
|
133
|
+
zipfile.readEntry();
|
|
134
|
+
});
|
|
135
|
+
} catch (e) {
|
|
136
|
+
done(false, `could not read the central directory: ${(e && e.message) || e}`);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = { scanEntryNames, nameRefusal, SCAN_MS, MAX_ENTRIES };
|