amicus 4.9.4 → 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.
@@ -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 };
@@ -0,0 +1,144 @@
1
+ /**
2
+ * WHEN AMICUS GIVES UP ON AN IN-MEMORY EXTRACTION, and how it stops the work.
3
+ *
4
+ * SPLIT OUT of zip-from-buffer.js in the v4.9.6 FOURTH council round, when the
5
+ * repair below pushed that file back over the repo's 300-line gate. The seam is
6
+ * the one the last three rounds have all been about: this module owns the
7
+ * give-up POLICY — the two windows, the bounded wait for an aborted write, and
8
+ * the classified failure that is raised — while zip-from-buffer.js owns the
9
+ * yauzl event loop that the policy is applied to. Nothing here knows what a zip
10
+ * entry is; nothing there decides how long to wait.
11
+ *
12
+ * @module sidecar/zip-stall-bound
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ const { failure } = require('./zip-entry-write');
18
+
19
+ /**
20
+ * THE STALL BOUND, and what did and did not come back with it.
21
+ *
22
+ * unzip.js exists for a field bug that was never root-caused: "on some Node 24
23
+ * boxes extract-zip@2.0.1 STALLS mid-extract — its promise never resolves AND
24
+ * never rejects", so the awaiting self-heal let the event loop drain and Node
25
+ * exited 0 with a partial extract and no message. It answered that with three
26
+ * layers: (1) an idle timer + a hard cap, whose LIVE handle is what stops the
27
+ * process exiting mid-stall, (2) a native OS unzip fallback, (3) a
28
+ * files-actually-landed check. All three went with unzip.js when the electron
29
+ * artifact moved onto `extractZipBuffer` and nothing replaced them: it
30
+ * had no timer of any kind, so a stalled write (a network volume, a hung AV
31
+ * filter, an `openReadStream` callback that never arrives) hung `ensureElectron`
32
+ * forever — and in `scripts/postinstall.js` the loop drained and Node exited 0.
33
+ *
34
+ * LAYER 1 IS BACK, with unzip.js's own numbers (30 s idle, 240 s hard) and
35
+ * unzip.js itself untouched: `IDLE_MS`/`MAX_MS` below.
36
+ *
37
+ * ── THE FIRST CUT WAS WRONG IN BOTH DIRECTIONS (round 3) ──────────────────
38
+ * Seat A1: "the advertised idle timeout fires during legitimate active writes
39
+ * and does not actually stop extraction." Seat B2: "can false-fire on a single
40
+ * slow entry write, failing a valid repair on slow storage." Two seats, opposite
41
+ * directions, both true of code that re-armed on ENTRY COMPLETION and settled
42
+ * its promise without stopping anything.
43
+ *
44
+ * ARMED AGAINST BYTES, NOT ENTRIES. The real artifact contains a 225 MB
45
+ * `electron.exe`, which is ONE entry: on storage slower than 7.5 MB/s that
46
+ * entry alone exceeds a 30 s window while writing perfectly well. Progress is
47
+ * now `written` — reported by `zip-entry-write.writeEntry`'s CRC transform,
48
+ * the stage immediately upstream of the sink, so it counts bytes the
49
+ * DESTINATION accepted. The entry count is kept only as a SECOND progress
50
+ * term, because an archive of empty files legitimately writes zero bytes.
51
+ *
52
+ * AND IT STOPS THE WORK. `fail()` aborts an `AbortController` BEFORE it
53
+ * rejects; `writeEntry` hands that signal to `pipeline`, which destroys the
54
+ * source and the sink. MEASURED (Node 24.18.0): after the abort not one
55
+ * further byte is accepted by the sink. `placeEntry` refuses to start a new
56
+ * entry once the signal is aborted, and `writeSymlink` re-checks it before
57
+ * `symlinkSync` (a link target is read through `collect`, not a pipeline).
58
+ *
59
+ * A WATCHDOG, NOT A RE-ARM PER CHUNK. The idle timer re-arms ITSELF: when it
60
+ * fires it compares bytes and entries against the mark it took and fails only
61
+ * when neither moved. One timer per window instead of one per 64 KiB chunk,
62
+ * at the cost of detecting a stall between one and two idle windows after it
63
+ * starts — irrelevant at 30 s, and stated rather than left to be discovered.
64
+ *
65
+ * ── AND THAT REMEDY REINTRODUCED THE HANG IT REMOVED (round 4) ────────────
66
+ * The round-3 repair also made the `finally` `await inFlight` — so the caller's
67
+ * cleanup could not race a live descriptor — AFTER `cancelTimers()` had cleared
68
+ * both handles. On the one shape the bound exists for, that await CANNOT settle:
69
+ * `pipeline` waits for every stream to close, and yauzl@2.10.0 replaces its
70
+ * endpoint stream's `destroy` with a no-arg function that emits neither 'error'
71
+ * nor 'close' (node_modules/yauzl/index.js, lines 566-573 and 698-713).
72
+ * MEASURED on the shipped code, Node 24.18.0, real yauzl and a real `pipeline`:
73
+ * one 1 MiB entry into a sink whose `_write` never calls back, `idleMs: 300` —
74
+ * the bound fired, the sink accepted nothing further, and the promise was STILL
75
+ * PENDING at 6 s; in a bare process with no other handle the loop drained and
76
+ * Node exited 0 having printed nothing. The original field bug, verbatim, from
77
+ * the commit whose message claimed to prevent it.
78
+ *
79
+ * SO THE UNWIND IS ITSELF BOUNDED. `awaitUnwind` races the in-flight write
80
+ * against `UNWIND_MS` on a LIVE timer: the wait keeps the loop alive, and
81
+ * then it ENDS. A write that comes apart normally is still awaited in full,
82
+ * which is all the round-3 repair actually wanted; one that never does costs
83
+ * `unwindMs` and the classified failure is thrown regardless. What that
84
+ * trades away is the descriptor race — whose cost is LITTER, not damage:
85
+ * `electron-layout.extractBytesToDist` removes the incoming tree best-effort
86
+ * (`try { rmSync } catch {}`) and `sweepPromoteLitter` takes what an EPERM
87
+ * leaves. A bound that cannot end is not a bound.
88
+ *
89
+ * LAYER 2 IS NOT THE DEFAULT AND NEVER CAN BE. Every native strategy (`tar`,
90
+ * `Expand-Archive`, `ditto`, `unzip`) takes a PATH, and a path is what the
91
+ * custody finding is about: handing one an artifact would extract bytes amicus
92
+ * did not hash, and writing our hashed Buffer to a temp file for it would
93
+ * rebuild the staged copy the council deleted. The two properties cannot both
94
+ * hold on one run.
95
+ *
96
+ * WHAT THAT COST, AND WHAT C2 BOUGHT BACK. This paragraph used to end "an archive
97
+ * yauzl cannot parse but a native extractor could is a failed repair plus a
98
+ * re-download" — which on an air-gapped machine is a permanent no-rescue failure,
99
+ * and a council seat filed it as such. Layer 2 is now reachable again as a
100
+ * RESCUE, for that ONE failure class (`UNZIP_BUFFER_FAILED`) and only when
101
+ * `AMICUS_ALLOW_UNVERIFIED_ELECTRON=1` was already set. `electron-native-rescue.js`
102
+ * owns the whole boundary and the custody it spends; nothing here changed, and a
103
+ * stall in particular is still NOT a rescue trigger — `UNZIP_BUFFER_STALLED` is
104
+ * a verdict about nothing, this bound exists to STOP work rather than hand it to
105
+ * someone else, and it is still never an eviction (see `electron-repair-cache.js`).
106
+ * LAYER 3 lives upstream and always did:
107
+ * `electron-quarantine.verifyExtractOutcome` stats the exe after a non-throwing
108
+ * extract.
109
+ */
110
+ /** No-progress window, then the hard cap. unzip.js's IDLE_MS / MAX_MS, to the ms. */
111
+ const IDLE_MS = 30_000;
112
+ const MAX_MS = 240_000;
113
+ /** How long an ABORTED write is given to come apart before it is abandoned. */
114
+ const UNWIND_MS = 5_000;
115
+
116
+ /**
117
+ * @returns {Error} the extraction made no progress. NOT an archive verdict and
118
+ * NOT a destination verdict — nobody learned anything about either — so it must
119
+ * never be the code that evicts a user's cached artifact.
120
+ */
121
+ const stalled = (message) => failure('UNZIP_BUFFER_STALLED', `the in-memory extraction stalled: ${message}`);
122
+
123
+ /**
124
+ * Wait for an ABORTED entry to come apart — bounded, because it may never.
125
+ *
126
+ * yauzl's endpoint stream has a `destroy` that emits nothing, so `pipeline`'s
127
+ * promise can stay pending forever after the abort (see the module docblock's
128
+ * round-4 section above, where an unbounded wait was measured to hang the whole
129
+ * extractor). The timer is a LIVE handle for the length of the wait, which is
130
+ * what stops the process draining mid-stall, and it ends the wait when it fires.
131
+ *
132
+ * @returns {Promise<boolean>} true if the write unwound, false if it was abandoned
133
+ */
134
+ function awaitUnwind(inFlight, ms, setTimer, clearTimer) {
135
+ return new Promise((resolve) => {
136
+ const t = setTimer(() => resolve(false), ms);
137
+ // The rejection is already handled by the driver; this only observes settling.
138
+ inFlight.then(() => {}, () => {}).then(() => { clearTimer(t); resolve(true); });
139
+ });
140
+ }
141
+
142
+ module.exports = {
143
+ IDLE_MS, MAX_MS, UNWIND_MS, stalled, awaitUnwind,
144
+ };
@@ -138,11 +138,18 @@ async function evaluateElectronMcp(d) {
138
138
  const after = evaluateElectronInstalls(d); // fresh scan reflects the repairs
139
139
  if (after.status === 'ok') {
140
140
  const n = results.length;
141
+ // A2/B3: `unverified` used to be written by repairElectron and read by
142
+ // NOTHING. A repair that no published digest could vouch for is exactly the
143
+ // thing a `doctor --fix` report exists to say out loud.
144
+ const unverified = results.filter((r) => r.repaired && r.unverified).length;
145
+ const mark = unverified > 0
146
+ ? `, ${unverified} UNVERIFIED (no published sha256 covered the artifact, or its sha256`
147
+ + ' contradicted the published one and the hatch accepted it)' : '';
141
148
  return {
142
149
  ...after,
143
- message: `${after.message} (self-healed ${n} npx-cache ${plural(n, 'copy', 'copies')})`,
150
+ message: `${after.message} (self-healed ${n} npx-cache ${plural(n, 'copy', 'copies')}${mark})`,
144
151
  fixed: true,
145
- fixDetail: `self-healed ${n} npx-cache ${plural(n, 'copy', 'copies')}`,
152
+ fixDetail: `self-healed ${n} npx-cache ${plural(n, 'copy', 'copies')}${mark}`,
146
153
  };
147
154
  }
148
155
  const failed = results.filter((r) => !r.repaired)