amicus 4.9.5 → 4.9.7
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 +238 -0
- package/README.md +1 -1
- package/docs/ROADMAP.md +4 -4
- package/docs/architecture-map.md +36 -7
- package/docs/configuration.md +119 -11
- package/docs/electron-testing.md +133 -0
- package/docs/troubleshooting.md +103 -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-exe-rel.js +131 -0
- package/src/sidecar/electron-install.js +120 -135
- package/src/sidecar/electron-layout.js +300 -0
- package/src/sidecar/electron-native-plan.js +175 -0
- package/src/sidecar/electron-native-rescue.js +270 -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 +95 -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 +231 -0
- package/src/sidecar/zip-local-name-scan.js +238 -0
- package/src/sidecar/zip-name-scan.js +146 -0
- package/src/sidecar/zip-stall-bound.js +144 -0
- package/src/utils/doctor-electron-mcp-check.js +9 -2
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE OTHER TABLE AN ARCHIVE DECLARES ITS NAMES IN — the local file headers.
|
|
3
|
+
*
|
|
4
|
+
* SPLIT OUT of zip-name-scan.js (v4.9.7, council #239 round 2): that file reached
|
|
5
|
+
* the repo's 300-line gate, and this walk answers a different question from the
|
|
6
|
+
* central-directory scan beside it. `nameRefusal` is imported rather than
|
|
7
|
+
* reimplemented, so there is exactly ONE rule about what yauzl would refuse and
|
|
8
|
+
* the two tables cannot drift apart.
|
|
9
|
+
*
|
|
10
|
+
* WHY A SECOND WALK EXISTS. `scanEntryNames` reads the CENTRAL directory, and an
|
|
11
|
+
* archive can make that unreadable while leaving every local header whole — a
|
|
12
|
+
* truncation does it by accident, four one-field edits to a COMPLETE
|
|
13
|
+
* end-of-central-directory record do it on purpose. MEASURED before this shipped:
|
|
14
|
+
* seven such archives carrying `../../../PWNED-BY-NATIVE.txt` reached a real
|
|
15
|
+
* spawn, two of them running to completion. And the tables can DISAGREE, which is
|
|
16
|
+
* why both are read: on an archive declaring one name locally and another
|
|
17
|
+
* centrally, `tar.exe` wrote the LOCAL name and `Expand-Archive` the CENTRAL one.
|
|
18
|
+
*
|
|
19
|
+
* ONLY EVER A NARROWING. Nothing here can make a rescue HAPPEN: the sole output
|
|
20
|
+
* that changes control flow is a REFUSAL, and `complete`/`names` exist for the
|
|
21
|
+
* notice printed before a spawn and decide nothing.
|
|
22
|
+
*
|
|
23
|
+
* @module sidecar/zip-local-name-scan
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
const { nameRefusal, MAX_ENTRIES } = require('./zip-name-scan');
|
|
29
|
+
|
|
30
|
+
/** The three signatures the local-header chain walks between. */
|
|
31
|
+
const LOCAL_SIG = 0x04034b50;
|
|
32
|
+
const CENTRAL_SIG = 0x02014b50;
|
|
33
|
+
const EOCD_SIG = 0x06054b50;
|
|
34
|
+
/** Bit 3: the sizes are BEHIND the payload, in a data descriptor. */
|
|
35
|
+
const FLAG_SIZES_DEFERRED = 0x08;
|
|
36
|
+
/** The same local signature as bytes, for scanning a span the walk would jump. */
|
|
37
|
+
const LOCAL_SIG_BYTES = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Where this archive's END-OF-CENTRAL-DIRECTORY record says its directory begins,
|
|
41
|
+
* or -1 when no such record can be found.
|
|
42
|
+
*
|
|
43
|
+
* THE TERMINUS HAS TO BE ANCHORED TO SOMETHING THE ARCHIVE DECLARES. A walk that
|
|
44
|
+
* stops the moment it lands on `PK\x01\x02` believes four bytes it has not
|
|
45
|
+
* earned: MEASURED, planting that signature where an honest advance lands ends
|
|
46
|
+
* the chain with every local header in the archive untouched and no field lying,
|
|
47
|
+
* so neither the deferred-size demotion nor the span check fires -- and the walk
|
|
48
|
+
* reports it saw everything while `tar.exe` went on to reach a `../../../` entry
|
|
49
|
+
* sitting behind it.
|
|
50
|
+
*/
|
|
51
|
+
function declaredCentralOffset(bytes) {
|
|
52
|
+
if (bytes.length < 22) { return -1; }
|
|
53
|
+
const floor = Math.max(0, bytes.length - 22 - 0xFFFF);
|
|
54
|
+
for (let i = bytes.length - 22; i >= floor; i -= 1) {
|
|
55
|
+
if (bytes.readUInt32LE(i) === EOCD_SIG) { return bytes.readUInt32LE(i + 16); }
|
|
56
|
+
}
|
|
57
|
+
return -1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* THE BYTES THE CHAIN COULD NOT REACH, ASKED THE SAME QUESTION ANYWAY.
|
|
62
|
+
*
|
|
63
|
+
* Every way the walk can stop early -- a size deferred to a data descriptor, the
|
|
64
|
+
* zip64 sentinel, a chain that runs off the end or lands on a non-signature --
|
|
65
|
+
* leaves the region AHEAD of it unexamined, and an entry the walk never reached
|
|
66
|
+
* is an entry it cannot refuse. That is the council #239 round-2 BLOCKER, and it
|
|
67
|
+
* needed no exotic archive: bit 3 with a zero size is a STANDARD encoding, so an
|
|
68
|
+
* attacker puts one entry in front of `../../../PWNED.txt`, blinds the central
|
|
69
|
+
* directory, and the walk stops before it ever sees the hostile name.
|
|
70
|
+
*
|
|
71
|
+
* WHY NOT SIMPLY REFUSE WHEN THE WALK CANNOT FINISH. Because that is the blanket
|
|
72
|
+
* fail-closed this cluster already rejected on MEASURED availability grounds: a
|
|
73
|
+
* truncated zip is the case the whole rescue exists for, and its walk cannot
|
|
74
|
+
* finish either. Refusing there kills the feature to close the hole.
|
|
75
|
+
*
|
|
76
|
+
* So the region is SWEPT rather than trusted or refused: every local-header
|
|
77
|
+
* signature in it is located and its declared name put through the SAME
|
|
78
|
+
* `nameRefusal`. It cannot follow the chain (that is what broke), so it does not
|
|
79
|
+
* pretend to -- `complete` stays false and the notice still says the names could
|
|
80
|
+
* not be confirmed. It can only ADD refusals, so the narrowing invariant holds
|
|
81
|
+
* and no archive that is rescued today stops being rescued unless it declares a
|
|
82
|
+
* hostile name.
|
|
83
|
+
*
|
|
84
|
+
* THE RESIDUAL: a `PK\x03\x04` occurring by chance inside a payload is read as a
|
|
85
|
+
* header, so a refusal can name something that is not an entry. MEASURED: zero
|
|
86
|
+
* spurious signatures across 777 MB of six real Electron artifacts, and a false
|
|
87
|
+
* hit must ALSO be followed by bytes that parse as a traversal or absolute name.
|
|
88
|
+
* It fails toward REFUSING, which costs a rescue and never grants one.
|
|
89
|
+
*
|
|
90
|
+
* Bounded: one pass, and at most MAX_ENTRIES candidates.
|
|
91
|
+
*/
|
|
92
|
+
function sweepUnreached(bytes, from) {
|
|
93
|
+
let seen = 0;
|
|
94
|
+
let at = from;
|
|
95
|
+
while (at >= 0 && at + 30 <= bytes.length && seen < MAX_ENTRIES) {
|
|
96
|
+
const hit = bytes.indexOf(LOCAL_SIG_BYTES, at);
|
|
97
|
+
if (hit === -1 || hit + 30 > bytes.length) { return null; }
|
|
98
|
+
seen += 1;
|
|
99
|
+
const nameLen = bytes.readUInt16LE(hit + 26);
|
|
100
|
+
const stop = Math.min(hit + 30 + nameLen, bytes.length);
|
|
101
|
+
const refusal = nameRefusal(bytes.subarray(hit + 30, stop).toString('latin1'));
|
|
102
|
+
if (refusal) { return refusal; }
|
|
103
|
+
at = hit + 4;
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* THE SAME QUESTION, ASKED OF THE LOCAL FILE HEADERS.
|
|
110
|
+
*
|
|
111
|
+
* WHY A SECOND WALK EXISTS. `scanEntryNames` reads the CENTRAL directory, and an
|
|
112
|
+
* archive can make that unreadable while leaving every local header whole.
|
|
113
|
+
* MEASURED: four ONE-FIELD edits to a COMPLETE end-of-central-directory record —
|
|
114
|
+
* entry count `0xFFFF`, cd offset `0xFFFFFFFF`, a lying comment length, a
|
|
115
|
+
* multi-disk marker — each blind yauzl on an archive whose `../../../` entry
|
|
116
|
+
* `tar` and `Expand-Archive` then read perfectly. On the comment-length one the
|
|
117
|
+
* rescue RAN TO COMPLETION and promoted the result. Cutting the tail off the file
|
|
118
|
+
* does the same thing by accident.
|
|
119
|
+
*
|
|
120
|
+
* AND IT IS THE TABLE ONE OF THE STRATEGIES ACTUALLY USES. MEASURED on an archive
|
|
121
|
+
* declaring one name locally and another centrally: `tar.exe` (bsdtar 3.8.4) wrote
|
|
122
|
+
* the LOCAL name; `Expand-Archive` wrote the CENTRAL one. Neither table is the
|
|
123
|
+
* right one to read. Both are. That is the whole ruling — the earlier candidates
|
|
124
|
+
* argued about which BLINDNESS to tolerate while looking in one table.
|
|
125
|
+
*
|
|
126
|
+
* ONE NAME RULE, NOT TWO. It calls `nameRefusal` above, deliberately: a second
|
|
127
|
+
* lexical rule free to drift from yauzl's would start costing the rescue archives
|
|
128
|
+
* yauzl accepts, which is the failure this module was written to avoid.
|
|
129
|
+
*
|
|
130
|
+
* ONLY `refusal` MAY CHANGE CONTROL FLOW. `complete` and `names` exist for the
|
|
131
|
+
* notice printed before a spawn and decide nothing, so the module's NARROWING
|
|
132
|
+
* invariant above holds verbatim: nothing here can make a rescue HAPPEN.
|
|
133
|
+
*
|
|
134
|
+
* NEVER THROWS, and synchronous: it reads headers and SKIPS payloads, so it never
|
|
135
|
+
* decompresses. MEASURED on six real electron artifacts (v28.0.0-v43.6.0,
|
|
136
|
+
* 107-151 MB): 73-75 names in 0-1 ms, no data descriptors, and the local names
|
|
137
|
+
* equal the central names entry for entry. Truncated, the central walk goes blind
|
|
138
|
+
* and this one still enumerates all 73-75.
|
|
139
|
+
*
|
|
140
|
+
* WHAT `complete` MEANS, AND WHY IT IS A VARIABLE. It is the claim "I saw every
|
|
141
|
+
* local header", it starts true, and it may only ever be turned OFF. Three things
|
|
142
|
+
* turn it off: an entry declaring bit 3 (its stated size is not authoritative),
|
|
143
|
+
* a declared size whose span HIDES another local signature, and a chain that ends
|
|
144
|
+
* anywhere but the offset this archive's own EOCD names. All three DEMOTE and
|
|
145
|
+
* keep walking. That distinction is the whole design: a name the walk can still
|
|
146
|
+
* read is a name it can still REFUSE, and MEASURED, turning any of these into an
|
|
147
|
+
* early `return` forfeits the refusal for every entry behind it — an archive
|
|
148
|
+
* with bit 3, an honest size and `../../../PWNED.txt` at entry 2 went from a
|
|
149
|
+
* terminal refusal to a completed rescue on 5.7% of forged shapes.
|
|
150
|
+
*
|
|
151
|
+
* THE RESIDUAL: the walk still trusts a local size to FIND the next header, so a
|
|
152
|
+
* desynchronised walk can read a "name" out of payload bytes and refuse a name
|
|
153
|
+
* that is not an entry. MEASURED constructible; MEASURED to need deliberate
|
|
154
|
+
* construction — zero spurious `PK\x03\x04` signatures across 777 MB of six real
|
|
155
|
+
* Electron artifacts. It fails toward REFUSING, which costs a rescue and never
|
|
156
|
+
* grants one.
|
|
157
|
+
*
|
|
158
|
+
* @param {Buffer} bytes the archive, in this process's heap
|
|
159
|
+
* @returns {{refusal:string|null, complete:boolean, names:number, why:string}}
|
|
160
|
+
* `complete` = the chain reached the directory, so EVERY local header was seen.
|
|
161
|
+
* A refusal stops the walk early, so it reports `complete:false` too: it did not
|
|
162
|
+
* see them all, and a field must not claim otherwise on any of its return sites.
|
|
163
|
+
*/
|
|
164
|
+
function scanLocalNames(bytes) {
|
|
165
|
+
let at = 0;
|
|
166
|
+
let names = 0;
|
|
167
|
+
// `complete` MAY ONLY EVER BE TURNED OFF, and the walk is never SHORTENED by
|
|
168
|
+
// anything but a refusal or a genuinely unknowable next offset. Turning a
|
|
169
|
+
// doubt into an early `return` was MEASURED to destroy the refusal for every
|
|
170
|
+
// entry behind it -- an archive with bit 3, an HONEST size and
|
|
171
|
+
// `../../../PWNED.txt` at entry 2 went from a terminal UNZIP_UNSAFE_ARCHIVE to
|
|
172
|
+
// a completed rescue. Deleting the one control-flow-changing power this module
|
|
173
|
+
// has IS the control-flow change; demoting a claim is not.
|
|
174
|
+
let complete = true;
|
|
175
|
+
let why = '';
|
|
176
|
+
const demote = (m) => { if (complete) { complete = false; why = m; } };
|
|
177
|
+
const cdOffset = declaredCentralOffset(bytes);
|
|
178
|
+
// EVERY EARLY EXIT SWEEPS WHAT IT NEVER REACHED. A stop is not a clean bill,
|
|
179
|
+
// and it is not a refusal either -- but the bytes ahead of it may declare a
|
|
180
|
+
// name that IS one. See `sweepUnreached`.
|
|
181
|
+
const stopped = (from, m) => ({ refusal: sweepUnreached(bytes, from), complete: false, names, why: why || m });
|
|
182
|
+
try {
|
|
183
|
+
for (;;) {
|
|
184
|
+
if (at + 4 > bytes.length) { return stopped(at, `the local-header chain ran off the end at ${at}`); }
|
|
185
|
+
const sig = bytes.readUInt32LE(at);
|
|
186
|
+
if (sig === CENTRAL_SIG || sig === EOCD_SIG) {
|
|
187
|
+
// ANCHORED: the chain may claim it reached the directory only where this
|
|
188
|
+
// archive says its directory begins. See `declaredCentralOffset`.
|
|
189
|
+
if (at !== cdOffset) { demote(`the chain ended at ${at}, not where this archive declares its directory begins (${cdOffset})`); }
|
|
190
|
+
return { refusal: null, complete, names, why };
|
|
191
|
+
}
|
|
192
|
+
if (sig !== LOCAL_SIG || at + 30 > bytes.length) { return stopped(at, `no local file header at ${at}`); }
|
|
193
|
+
const flags = bytes.readUInt16LE(at + 6);
|
|
194
|
+
const compressed = bytes.readUInt32LE(at + 18);
|
|
195
|
+
const nameLen = bytes.readUInt16LE(at + 26);
|
|
196
|
+
const extraLen = bytes.readUInt16LE(at + 28);
|
|
197
|
+
if (at + 30 + nameLen > bytes.length) { return stopped(at, `a local file name was cut off at ${at}`); }
|
|
198
|
+
names += 1;
|
|
199
|
+
// Latin-1 for the reason the central walk gives: bytes to characters 1:1.
|
|
200
|
+
const refusal = nameRefusal(bytes.subarray(at + 30, at + 30 + nameLen).toString('latin1'));
|
|
201
|
+
if (refusal) { return { refusal, complete: false, names, why: '' }; }
|
|
202
|
+
// NO NEXT OFFSET AT ALL. These two are the only genuine stops: a zero size
|
|
203
|
+
// under bit 3 would advance by nothing and resynchronise on payload, and
|
|
204
|
+
// the zip64 sentinel names a size that is not here.
|
|
205
|
+
if (((flags & FLAG_SIZES_DEFERRED) && compressed === 0) || compressed === 0xFFFFFFFF) {
|
|
206
|
+
return stopped(at, `entry ${names} does not declare its size here`);
|
|
207
|
+
}
|
|
208
|
+
// BIT 3 SAYS THIS SIZE IS NOT AUTHORITATIVE. APPNOTE 4.4.4 has the writer
|
|
209
|
+
// set it to ZERO, so a nonzero value beside the flag is malformed by
|
|
210
|
+
// construction and `complete` may not rest on it -- but it is still the
|
|
211
|
+
// only lead to the next header, and a name the walk can still read is a
|
|
212
|
+
// name it can still REFUSE. Follow it; just stop claiming to have proved
|
|
213
|
+
// anything (the filed blocker: a lying nonzero size jumped a hostile entry
|
|
214
|
+
// and the walk reported it had seen them all).
|
|
215
|
+
if (flags & FLAG_SIZES_DEFERRED) { demote(`entry ${names} declares bit 3, so the size it states here is not authoritative`); }
|
|
216
|
+
const next = at + 30 + nameLen + extraLen + compressed;
|
|
217
|
+
// THE SPAN THE WALK NEVER LOOKS AT -- name, extra field and payload, every
|
|
218
|
+
// length the archive's to choose. A span holding a local signature may be
|
|
219
|
+
// hiding an entry, so the claim is demoted; the walk still follows the
|
|
220
|
+
// offset, because stopping here would forfeit the refusals behind it.
|
|
221
|
+
if (next <= bytes.length && next > at + 30) {
|
|
222
|
+
// BOUNDED TO THE SPAN. Searching to the end of the archive and only then
|
|
223
|
+
// comparing against `next` is the same answer for O(archive) work PER
|
|
224
|
+
// ENTRY -- quadratic on attacker-chosen input, synchronous, and on the
|
|
225
|
+
// rescue path before the hatch policy is even read (council #239 r2).
|
|
226
|
+
// `subarray` is a view, not a copy, so the total is one pass.
|
|
227
|
+
const hidden = bytes.subarray(at + 30, next).indexOf(LOCAL_SIG_BYTES);
|
|
228
|
+
if (hidden !== -1) { demote(`entry ${names}'s declared size jumps over a local file header at ${at + 30 + hidden}`); }
|
|
229
|
+
}
|
|
230
|
+
if (names >= MAX_ENTRIES) { return stopped(at, `stopped after ${MAX_ENTRIES} entries`); }
|
|
231
|
+
at = next;
|
|
232
|
+
}
|
|
233
|
+
} catch (e) {
|
|
234
|
+
return { refusal: null, complete: false, names, why: `the local-header chain could not be walked: ${(e && e.message) || e}` };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
module.exports = { scanLocalNames, declaredCentralOffset };
|
|
@@ -0,0 +1,146 @@
|
|
|
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
|
+
* THE BLINDNESS IN THE FIRST PARAGRAPH IS WHY `scanLocalNames` EXISTS (v4.9.7,
|
|
42
|
+
* B3). An archive carries its names TWICE, and an unreadable central directory
|
|
43
|
+
* says nothing about the local file headers — which is the table `tar` was
|
|
44
|
+
* MEASURED to act on. The caller asks both.
|
|
45
|
+
*
|
|
46
|
+
* ── AND IT IS ONLY EVER A NARROWING ───────────────────────────────────────
|
|
47
|
+
* Nothing here can make a rescue happen. A refusal it forms turns a
|
|
48
|
+
* `UNZIP_BUFFER_FAILED` into the TERMINAL `UNZIP_UNSAFE_ARCHIVE` its caller
|
|
49
|
+
* already excludes; silence changes nothing. So a bug in this file can cost the
|
|
50
|
+
* rescue, never widen it.
|
|
51
|
+
*
|
|
52
|
+
* @module sidecar/zip-name-scan
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
'use strict';
|
|
56
|
+
|
|
57
|
+
/** Hard bound: this is an in-memory walk, but nothing waits forever here. */
|
|
58
|
+
const SCAN_MS = 10_000;
|
|
59
|
+
|
|
60
|
+
/** No real electron artifact is near this; a hostile central directory can be. */
|
|
61
|
+
const MAX_ENTRIES = 200_000;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* yauzl's own `validateFileName` (index.js lines 607-619), under the options
|
|
65
|
+
* amicus really extracts with — `decodeStrings` on and `strictFileNames` off,
|
|
66
|
+
* which rewrites backslashes to `/` BEFORE validating (lines 420-426). The
|
|
67
|
+
* wording is yauzl's verbatim so a refusal built from it is classified by
|
|
68
|
+
* `unzip.js :: UNSAFE_PATTERNS` as well as by its `code`.
|
|
69
|
+
*
|
|
70
|
+
* `invalid characters in fileName` is deliberately absent: with the backslash
|
|
71
|
+
* rewrite in force yauzl cannot produce it, and inventing a refusal yauzl would
|
|
72
|
+
* not make is how a scan starts costing the rescue legitimate archives.
|
|
73
|
+
* @returns {string|null} yauzl's refusal for this name, or null
|
|
74
|
+
*/
|
|
75
|
+
function nameRefusal(name) {
|
|
76
|
+
const n = name.replace(/\\/g, '/');
|
|
77
|
+
if (/^[a-zA-Z]:/.test(n) || n.startsWith('/')) { return `absolute path: ${name}`; }
|
|
78
|
+
if (n.split('/').includes('..')) { return `invalid relative path: ${name}`; }
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Walk `bytes`' central directory and report the first entry name yauzl would
|
|
84
|
+
* refuse.
|
|
85
|
+
*
|
|
86
|
+
* DECODED AS LATIN-1, ON PURPOSE. `decodeStrings: false` hands back raw name
|
|
87
|
+
* Buffers, and latin-1 is the one decoding that maps bytes to characters 1:1 —
|
|
88
|
+
* no replacement characters, no multi-byte collapsing. Every byte the two rules
|
|
89
|
+
* look at (`.`, `/`, `\`, `:`) is ASCII, and no continuation byte of a UTF-8
|
|
90
|
+
* sequence can be ASCII, so this sees exactly what a correct decode would.
|
|
91
|
+
*
|
|
92
|
+
* NEVER THROWS, never rejects: a caller reaching this already has a failure in
|
|
93
|
+
* flight and must not acquire a second one from a diagnostic.
|
|
94
|
+
*
|
|
95
|
+
* @param {Buffer} bytes the archive, in this process's heap
|
|
96
|
+
* @param {object} [o]
|
|
97
|
+
* @param {object} [o.deps] { yauzl, setTimeout, clearTimeout }
|
|
98
|
+
* @returns {Promise<{read:boolean, refusal:string|null, why:string}>}
|
|
99
|
+
* `read` = the whole central directory was enumerated, so `refusal: null`
|
|
100
|
+
* really means "no such name in this archive". `read:false` means the scan
|
|
101
|
+
* proved nothing.
|
|
102
|
+
*/
|
|
103
|
+
function scanEntryNames(bytes, { deps = {} } = {}) {
|
|
104
|
+
const setTimer = deps.setTimeout || setTimeout;
|
|
105
|
+
const clearTimer = deps.clearTimeout || clearTimeout;
|
|
106
|
+
let yauzl = deps.yauzl;
|
|
107
|
+
if (!yauzl) {
|
|
108
|
+
try {
|
|
109
|
+
// eslint-disable-next-line global-require
|
|
110
|
+
yauzl = require('yauzl');
|
|
111
|
+
} catch (e) { return Promise.resolve({ read: false, refusal: null, why: `yauzl unavailable: ${(e && e.message) || e}` }); }
|
|
112
|
+
}
|
|
113
|
+
return new Promise((resolve) => {
|
|
114
|
+
let settled = false;
|
|
115
|
+
let refusal = null;
|
|
116
|
+
let seen = 0;
|
|
117
|
+
let timer = null;
|
|
118
|
+
const done = (read, why) => {
|
|
119
|
+
if (settled) { return; }
|
|
120
|
+
settled = true;
|
|
121
|
+
if (timer !== null) { clearTimer(timer); }
|
|
122
|
+
resolve({ read, refusal, why });
|
|
123
|
+
};
|
|
124
|
+
timer = setTimer(() => done(false, `the name scan exceeded ${SCAN_MS}ms`), SCAN_MS);
|
|
125
|
+
try {
|
|
126
|
+
yauzl.fromBuffer(bytes, { lazyEntries: true, decodeStrings: false, validateEntrySizes: false }, (err, zipfile) => {
|
|
127
|
+
if (err || !zipfile) { done(false, `could not read the central directory: ${(err && err.message) || 'no zipfile'}`); return; }
|
|
128
|
+
zipfile.on('error', (e) => done(false, `could not read the central directory: ${(e && e.message) || e}`));
|
|
129
|
+
zipfile.on('end', () => done(true, ''));
|
|
130
|
+
zipfile.on('entry', (entry) => {
|
|
131
|
+
seen += 1;
|
|
132
|
+
const name = Buffer.isBuffer(entry.fileName) ? entry.fileName.toString('latin1') : String(entry.fileName);
|
|
133
|
+
refusal = nameRefusal(name);
|
|
134
|
+
if (refusal) { done(true, ''); return; }
|
|
135
|
+
if (seen >= MAX_ENTRIES) { done(false, `stopped after ${MAX_ENTRIES} entries`); return; }
|
|
136
|
+
zipfile.readEntry();
|
|
137
|
+
});
|
|
138
|
+
zipfile.readEntry();
|
|
139
|
+
});
|
|
140
|
+
} catch (e) {
|
|
141
|
+
done(false, `could not read the central directory: ${(e && e.message) || e}`);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
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)
|