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,231 @@
1
+ /**
2
+ * THE NATIVE-EXTRACTOR RESCUE — the one way an archive amicus's own extractor
3
+ * cannot parse still becomes an install, what it costs, and why it is reachable
4
+ * from exactly one place.
5
+ *
6
+ * ── THE FINDING (council run 34182994208, major, deepseek seat) ───────────
7
+ * "Deleting the native-unzip fallback and the last-resort install.js spawn
8
+ * weakens the air-gapped recovery path to a permanent no-rescue failure for
9
+ * archives yauzl cannot parse." True as filed. `zip-stall-bound.js` had already
10
+ * written the loss down honestly — "an archive yauzl cannot parse but a native
11
+ * extractor could is a failed repair plus a re-download" — and on a machine with
12
+ * no network to re-download from, that is the end of the road.
13
+ *
14
+ * ── WHY IT CANNOT SIMPLY BE PUT BACK, WHICH IS THE WHOLE JUSTIFICATION ────
15
+ * Every native extractor — `tar`, `Expand-Archive`, `ditto`, `unzip` — takes a
16
+ * PATH. Using one therefore means writing bytes down and letting a child process
17
+ * open them: the SECOND PATH RESOLUTION the custody design exists to eliminate
18
+ * (`electron-custody.js` carries the three refuted remedies and the measurements
19
+ * that killed them). The custody property — **amicus never itself writes, or
20
+ * reports as verified, bytes it did not hash** — and a path-taking extractor
21
+ * cannot both hold on the same run. There is no clever version of this: the two
22
+ * properties are in direct contradiction.
23
+ *
24
+ * That trade is acceptable ONLY under a flag whose existing documented meaning is
25
+ * already "I accept Electron bytes amicus cannot vouch for", and it must not be
26
+ * reachable any other way. `AMICUS_ALLOW_UNVERIFIED_ELECTRON=1` is that flag, and
27
+ * it is read in exactly one place (`electron-trust.js :: electronTrustPolicy`),
28
+ * from a bare environment name a repository's `.npmrc` cannot plant. Custody
29
+ * stays absolute by default. `runInstaller` is NOT coming back — it did its own
30
+ * download with its own anchor, which is a different and much worse thing (see
31
+ * `electron-provision.js`).
32
+ *
33
+ * ── THE TRIGGER BOUNDARY, WHICH MATTERS MORE THAN THE MECHANISM ───────────
34
+ * "Fall back when extraction fails" would re-arm the laundering bug this cluster
35
+ * already fixed once (C4): a security refusal handed to a tool with no such
36
+ * check. So the trigger is ONE extractor verdict, `UNZIP_BUFFER_FAILED` — the
37
+ * code whose defined meaning is "the ARCHIVE is bad", and already the only
38
+ * failure that licences an eviction (`electron-repair-cache.js ::
39
+ * EVICTS_THE_ARTIFACT`). The rescue and the eviction now share one trigger.
40
+ *
41
+ * YES the extractor positively identified the ARCHIVE as unreadable.
42
+ * NO `UNZIP_UNSAFE_ARCHIVE` — a path-traversal REFUSAL. Terminal by design;
43
+ * C4 exists precisely to stop a refusal being retried through a tool
44
+ * amicus does not control, and the hatch is not even MENTIONED on that
45
+ * exit — advertising it there would be the same laundering with a human
46
+ * in the loop.
47
+ * NO `UNZIP_BUFFER_STALLED` — the bound exists to STOP work, not to hand it
48
+ * to someone else, and a stall is a verdict about nothing.
49
+ * NO a digest MISMATCH. The bytes are known wrong; there is nothing to
50
+ * rescue. This is the exclusion that is easy to get wrong, because the
51
+ * same flag that opens the rescue ALSO downgrades a mismatch refusal to a
52
+ * warning (`electron-trust.js :: verifyArtifactBytes`) — so with the hatch
53
+ * set, contradicted bytes really do reach the extractor. `gate.verdict`
54
+ * is checked here for that one reason.
55
+ * NO `UNZIP_DEST_FAILED` — not the archive's fault, and a native extractor
56
+ * writing to the same full or unwritable destination fails identically.
57
+ * NO `UNZIP_BUFFER_UNAVAILABLE`, and NO an error carrying no `code` at all.
58
+ * Neither is a parse failure, and a rule that fails OPEN on the shapes
59
+ * nobody enumerated is the shape D2 was filed against. Fail closed.
60
+ *
61
+ * AND A VERDICT IS NOT THE WHOLE QUESTION. Every line above keys on the refusal
62
+ * yauzl FORMED, and yauzl checks an entry's size before its name — so an archive
63
+ * whose first entry breaks the extraction arrives as `UNZIP_BUFFER_FAILED` with
64
+ * its traversal entry never looked at. One flag bit, MEASURED, moves the same
65
+ * archive from the terminal class into the rescuable one. So the boundary also
66
+ * asks what names the archive DECLARES (`hostileName`), and a name yauzl would
67
+ * have refused is treated as the refusal it would have raised. What that scan
68
+ * cannot see is written down there rather than papered over.
69
+ *
70
+ * ── AND `no-digest` IS ALLOWED, DELIBERATELY ──────────────────────────────
71
+ * Only `mismatch` is excluded. `no-digest` means nobody ever published a digest
72
+ * for this artifact — an Electron package predating `checksums.json` — which is
73
+ * exactly the old, air-gapped machine the finding is about. Nothing contradicts
74
+ * those bytes; refusing them would delete the rescue for its main case.
75
+ *
76
+ * ── WHAT A RESCUE CAN NEVER DO: REPORT CLEAN ──────────────────────────────
77
+ * `rescue.used` is set when the rescue runs, and both routes fold it into their
78
+ * `unverified` mark, so a rescued install is never reported as verified even
79
+ * when the artifact's own sha256 matched. It has to be: the bytes in `dist/` were
80
+ * placed there by a child process reading a path, not by amicus writing what it
81
+ * hashed. Reporting that as verified is the exact overclaim the custody property
82
+ * forbids.
83
+ *
84
+ * ── OFFERED-BUT-UNARMED: THE OFFER IS A PROMISE ──────────────────────────
85
+ * With the hatch OFF, a parse failure prints an offer naming the flag and telling
86
+ * an air-gapped user to set it and provision again. The FIRST cut of C2 printed
87
+ * that and then let the same `UNZIP_BUFFER_FAILED` reach the cache route's
88
+ * eviction, which DELETED the artifact on the way out — MEASURED end to end: the
89
+ * ten-line offer on stderr, `reason: "… was corrupt and removed"`, the zip gone,
90
+ * and the re-run the message asks for ending in `No cached electron zip found`.
91
+ * On the machine the whole finding is about, the offer named a rescue that its
92
+ * own run had just made impossible.
93
+ *
94
+ * So `rescue.offered` is set HERE, where the promise is made, and
95
+ * `electron-repair-cache.js` keeps the artifact when it is set. The cost is the
96
+ * availability cost that module already accepts wherever its delete fence says
97
+ * no: an unreadable zip survives and is re-downloaded once per provision. That
98
+ * is the right way round — a copy nobody can read costs one download; a copy
99
+ * that is gone costs the only rescue there was.
100
+ *
101
+ * @module sidecar/electron-native-rescue
102
+ */
103
+
104
+ 'use strict';
105
+
106
+ const { spawnSync } = require('child_process');
107
+
108
+ // The cap comes from `unzip.js`, which is byte-for-byte unchanged: this is a
109
+ // re-wiring of a caller, not a change to that module.
110
+ const { MAX_MS } = require('./unzip');
111
+ // The mechanism this file decides about: writing the buffer down, walking the
112
+ // platform's plan, sweeping up. `RESCUE_ZIP` and `INCOMING_PREFIX` are re-exported
113
+ // below because they name what a rescue leaves on disk, which is this module's
114
+ // subject even though the code that writes them is next door.
115
+ const { nativeRescue, RESCUE_ZIP, INCOMING_PREFIX } = require('./electron-native-plan');
116
+ // The offer a parse failure gets when the hatch is off. Every other user-facing
117
+ // sentence in this subsystem is written in the same two files.
118
+ const { offerNativeRescue } = require('./electron-rescue-notice');
119
+ // The read-only name walk the boundary consults before it trusts a verdict.
120
+ const { scanEntryNames } = require('./zip-name-scan');
121
+ const { collapseExcerpt } = require('../utils/text-sanitize');
122
+
123
+ /** The ONE extractor verdict a rescue may act on. See the docblock's boundary. */
124
+ const RESCUE_TRIGGER = 'UNZIP_BUFFER_FAILED';
125
+
126
+ /** True for the ONE failure class the owner authorised a rescue for. */
127
+ function isRescuableFailure(err) {
128
+ return !!err && err.code === RESCUE_TRIGGER;
129
+ }
130
+
131
+ /**
132
+ * A parse failure's archive, asked what ENTRY NAMES it declares.
133
+ *
134
+ * THE EXCLUSION ABOVE KEYS ON A REFUSAL, AND A REFUSAL HAS TO BE FORMED. yauzl
135
+ * checks an entry's size before its name, so an archive whose FIRST entry breaks
136
+ * the extraction never reaches the traversal entry behind it and arrives here as
137
+ * `UNZIP_BUFFER_FAILED` — the one class a rescue acts on. MEASURED: one flag bit
138
+ * moves the same archive from `UNZIP_UNSAFE_ARCHIVE` into the rescue, traversal
139
+ * entry and all (`zip-name-scan.js` carries both measurements and the two
140
+ * Windows tools' own refusals, which are what stopped the escape that run).
141
+ *
142
+ * So the boundary asks about the ENTRIES, not only the verdict, and a name yauzl
143
+ * would have refused becomes the refusal yauzl would have raised: terminal,
144
+ * unadvertised, left in place, exactly as if the archive had had nothing wrong
145
+ * with it but that entry.
146
+ *
147
+ * THE RESIDUAL, STATED RATHER THAN ENGINEERED AWAY. The scan reads the central
148
+ * directory; an archive whose central directory is unreadable — a truncated zip,
149
+ * the commonest thing this rescue exists for — declares no names it can see, and
150
+ * that archive still reaches the native extractor. Nothing here covers a SYMLINK
151
+ * whose target escapes either: that is a payload, not a name. In both cases the
152
+ * only remaining check is the extractor's own, which `tar` and `Expand-Archive`
153
+ * were MEASURED to have (`ditto` and Info-ZIP `unzip` are unmeasured), and
154
+ * `cleanDir` sweeps only inside `dir` — anything a native tool wrote outside it
155
+ * would survive a failed strategy. `docs/configuration.md` says the same thing to
156
+ * the user who has to decide whether to set the flag.
157
+ * @returns {Error|null} a terminal UNZIP_UNSAFE_ARCHIVE, or null
158
+ */
159
+ async function hostileName(bytes, log) {
160
+ const seen = await scanEntryNames(bytes);
161
+ if (!seen.refusal) { return null; }
162
+ log('[amicus] REFUSING to rescue this archive: amicus could not read it, and while asking what');
163
+ log('[amicus] it contains it found an entry that tries to write OUTSIDE the destination:');
164
+ log(`[amicus] ${collapseExcerpt(seen.refusal)}`);
165
+ log('[amicus] A native extractor may have no such check, so it is not offered this archive.');
166
+ return Object.assign(
167
+ new Error(`refusing to extract this archive: ${collapseExcerpt(seen.refusal)}`),
168
+ { code: 'UNZIP_UNSAFE_ARCHIVE' },
169
+ );
170
+ }
171
+
172
+ /**
173
+ * Wrap a buffer extractor so a PARSE FAILURE — and nothing else — may be rescued
174
+ * by the native plan when the hatch is set.
175
+ *
176
+ * ONE WRAPPER, WIRED AT BOTH CALL SITES. `gate` and `policy` are only known after
177
+ * the digest gate has run, which is why this is composed inside each route rather
178
+ * than once in `repairElectron`; F3 is the standing reminder of what happens when
179
+ * a rule lands on one provision route and not the other, so a test asserts both.
180
+ *
181
+ * @param {object} o
182
+ * @param {function} o.extract the buffer extractor being wrapped
183
+ * @param {object} o.gate verifyArtifactBytes's result (its `verdict` is read)
184
+ * @param {object} o.policy electronTrustPolicy's result (the hatch)
185
+ * @param {object} o.rescue OUT: `{used, strategy}` when a rescue ran, and
186
+ * `{offered:true}` when one was named but not armed — the caller must not then
187
+ * discard the artifact the offer points at
188
+ * @returns {function} an extractor with the same (bytes, {dir}) signature
189
+ */
190
+ function withNativeRescue({
191
+ extract, gate = {}, policy = {}, rescue = {}, platform = process.platform,
192
+ fs, spawn = spawnSync, maxMs = MAX_MS, log = () => {},
193
+ }) {
194
+ return async (bytes, o) => {
195
+ try {
196
+ return await extract(bytes, o);
197
+ } catch (err) {
198
+ // Every class but one leaves through here untouched and unadvertised.
199
+ if (!isRescuableFailure(err)) { throw err; }
200
+ // ...and the one class that IS rescuable is asked what names it declares
201
+ // first, because the exclusion above keys on the refusal yauzl FORMED and
202
+ // an earlier bad entry stops it forming one. See `hostileName`.
203
+ const hostile = await hostileName(bytes, log);
204
+ if (hostile) { throw hostile; }
205
+ if (!policy.allowUnverified) {
206
+ // `offered` IS THE OFFER'S RECEIPT, and the cache route is required to
207
+ // honour it: see the docblock's OFFERED-BUT-UNARMED section.
208
+ rescue.offered = true;
209
+ offerNativeRescue({ reason: (err && err.message) || '', log });
210
+ throw err;
211
+ }
212
+ if (gate.verdict === 'mismatch') {
213
+ log('[amicus] the native-extractor rescue was NOT attempted: these bytes contradict the published sha256, so there is nothing to rescue.');
214
+ throw err;
215
+ }
216
+ const strategy = nativeRescue({
217
+ bytes, dir: o.dir, reason: (err && err.message) || '', platform, fs, spawn, maxMs, log,
218
+ });
219
+ // A rescue that failed leaves the ORIGINAL classified error in flight, so
220
+ // a genuinely bad archive is still evicted exactly as it was before.
221
+ if (!strategy) { throw err; }
222
+ rescue.used = true;
223
+ rescue.strategy = strategy;
224
+ return { strategy, rescued: true };
225
+ }
226
+ };
227
+ }
228
+
229
+ module.exports = {
230
+ withNativeRescue, isRescuableFailure, RESCUE_TRIGGER, RESCUE_ZIP, INCOMING_PREFIX,
231
+ };
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Electron CONTROLLED provision — the pinned download, and the fence that says
3
+ * whether a refused cache artifact may be deleted.
4
+ *
5
+ * Split out of electron-install.js because that file sits at the repo's 300-line
6
+ * gate and cannot grow; the refusal MESSAGES were split out of this file, into
7
+ * ./electron-refuse, for the same reason. The require arrow is
8
+ * electron-install -> electron-provision -> {electron-custody, electron-layout,
9
+ * electron-refuse, electron-trust} and must never point back. Those four are
10
+ * near-leaves required by both this module and electron-install.js — and by
11
+ * ./electron-repair-cache, which requires THIS module for `mayDeleteRejectedZip`
12
+ * and is required only by electron-install.js, so the arrow stays acyclic.
13
+ *
14
+ * @module sidecar/electron-provision
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ const path = require('path');
20
+
21
+ const { resolveCacheRoots } = require('./electron-cache');
22
+ const { readArtifactBytes } = require('./electron-custody');
23
+ const { extractBytesToDist } = require('./electron-layout');
24
+ const { withNativeRescue } = require('./electron-native-rescue');
25
+ const { refuseUnreadableArtifact, rejectDownloadedZip } = require('./electron-refuse');
26
+ const { withScrubbedRepoEnv } = require('./electron-env-scrub');
27
+ const { artifactFileName, expectedDigest, verifyArtifactBytes } = require('./electron-trust');
28
+ const { containsOnDisk } = require('../utils/path-fence');
29
+
30
+ /** Best-effort cache root for downloadArtifact (first resolved root). */
31
+ function cacheRootFor(env = process.env) {
32
+ return resolveCacheRoots(env)[0];
33
+ }
34
+
35
+ /**
36
+ * CONTROLLED provision: fetch the zip ourselves with the SAME @electron/get
37
+ * api install.js uses, READ IT ONCE into memory, hash THOSE BYTES ourselves,
38
+ * extract THOSE BYTES offline, and let the caller verify isElectronUsable().
39
+ * No blind install.js spawn, and no path resolved a second time.
40
+ *
41
+ * C1 — THE PIN. `checksums` is what breaks the attack chain. Supplied, it makes
42
+ * @electron/get write a LOCAL SHASUMS256.txt from this table and never fetch one
43
+ * from the mirror, so a redirected download still has to produce bytes matching
44
+ * electron's own published sha256. Passed as the ONE entry for this artifact:
45
+ * an empty table is a hard throw upstream, and a table missing the requested
46
+ * name fails the download outright — which is why no anchor means no `checksums`
47
+ * key at all rather than an empty one. With no anchor, @electron/get falls back
48
+ * to its own remote SHASUMS256.txt fetch: weaker, but never a re-download loop.
49
+ *
50
+ * THE HATCH REACHES THIS ROUTE TOO. `AMICUS_ALLOW_UNVERIFIED_ELECTRON=1` used to
51
+ * be handed only to the cached-artifact gate, so the one case both docs describe
52
+ * — a machine whose electron bytes legitimately differ (a local rebuild, an
53
+ * internally-signed build on a corporate mirror) — still had its download pinned
54
+ * to the official digest and failed. MEASURED before this change: with the hatch
55
+ * set and nothing in any cache root, `checksums` was still sent and the call
56
+ * returned `{repaired:false}` with no reason at all. When the hatch is set the
57
+ * pin is dropped here and @electron/get falls back to the mirror's own
58
+ * SHASUMS256.txt, which is what a rebuild publishes. That is a real downgrade,
59
+ * so it is stated out loud on stderr every time rather than happening quietly —
60
+ * and it is reachable ONLY through a bare env name a repository cannot plant.
61
+ *
62
+ * F#2 — AMICUS HASHES WHAT IT DOWNLOADED, ITSELF. Sending `checksums` records
63
+ * that a table went out; it does not record that the bytes reaching the
64
+ * extractor matched it. @electron/get validates in its own temp dir and then
65
+ * RENAMES the artifact into the cache root, handing back THAT path — a path the
66
+ * same cache-dir writer the digest gate exists to stop can swap before amicus
67
+ * opens it. MEASURED before this change: with the swap fired inside amicus's own
68
+ * post-download window, `BYTES EXTRACTED: "POISONED-BYTES"` and
69
+ * `{"repaired":true}`.
70
+ *
71
+ * THE SECOND ROUND MOVED WHERE THAT HASH HAPPENS, because the first answer was
72
+ * not enough. It staged a private COPY and hashed the copy — and seat D1 showed
73
+ * that copy is discoverable and openable by the same uid, so the race simply
74
+ * moved onto the staged path. There is no copy now: the downloaded path is read
75
+ * ONCE into a Buffer, that Buffer is hashed, and that Buffer is extracted. The
76
+ * identical three steps run on the cache route (./electron-repair-cache), which
77
+ * is what makes `pinned` mean what its name says.
78
+ *
79
+ * F#6/F#8 — AND IT FAILS CLOSED. When the bytes could not be read at all, this
80
+ * route used to extract the unread path anyway while the only line on screen
81
+ * said they would not be extracted. It refuses, in the cache route's own words.
82
+ *
83
+ * F3 — AND AN UNPINNED SUCCESS IS MARKED, NOT ONLY LOGGED (seats C1 + B3). Both
84
+ * `CHANGELOG.md` and `docs/troubleshooting.md` promise that an artifact no
85
+ * published digest covers is "extracted and marked `unverified`". v4.9.5 kept
86
+ * that promise on the CACHE route only. `repairElectron` folds `unverified:true`
87
+ * into its result whenever `pinned` is false.
88
+ * @returns {Promise<{pinned:boolean, refused?:object}>} pinned:false = these bytes
89
+ * were vouched for only by the mirror that served them; `refused` = a result
90
+ * shape the caller must return as-is, nothing was extracted.
91
+ */
92
+ async function controlledProvision({
93
+ electronDir, platform, arch, version, anchor, downloadArtifact, extract,
94
+ fs, spawn, env = process.env, downloadMs = 480000, policy = {}, log = () => {},
95
+ }) {
96
+ const fileName = artifactFileName({ version, platform, arch });
97
+ let digest = expectedDigest(anchor, fileName);
98
+ if (digest && policy.allowUnverified) {
99
+ log('[amicus] WARNING: AMICUS_ALLOW_UNVERIFIED_ELECTRON=1 — downloading without the published');
100
+ log(`[amicus] sha256 pin for ${fileName}; its digest comes from the mirror you are using.`);
101
+ digest = null;
102
+ } else if (!digest) {
103
+ log(`[amicus] NOTE: no published sha256 for ${fileName} (this electron package ships no`);
104
+ log('[amicus] checksums.json entry for it), so the download could not be pinned: its bytes');
105
+ log('[amicus] are checked against the SHASUMS256.txt the mirror itself serves. The result');
106
+ log('[amicus] is reported as unverified.');
107
+ }
108
+ // D5: @electron/get runs IN THIS PROCESS and reads the same repo-plantable
109
+ // npm_config_electron_* / npm_package_config_electron_* names install.js does.
110
+ // The scrub is held across the synchronous prefix ONLY, and the promise is
111
+ // returned unawaited so no unrelated caller ever sees a scrubbed environment.
112
+ //
113
+ // WHAT THAT DOES AND DOES NOT COVER, MEASURED against the installed 5.0.0 and
114
+ // re-measured by tests/electron-env-scrub-get5-contract.test.js on every run
115
+ // (round 3, seat B1 — the previous claim here was reasoned, never run):
116
+ // every read that decides the ARTIFACT's URL lands inside the window on BOTH
117
+ // routes (20 of 20, 0 after the restore), so a planted mirror cannot move this
118
+ // download. When `digest` is null there is no `checksums` table, and
119
+ // @electron/get then recursively downloads SHASUMS256.txt AFTER awaits with
120
+ // the environment restored — 13 planted reads, measured. That is
121
+ // availability-only: the zip's URL is already fixed, so the planted mirror can
122
+ // only serve a checksum file that disagrees with official bytes and FAIL the
123
+ // download. See electron-env-scrub.js for the three closures rejected and why.
124
+ //
125
+ // IT SCRUBS `process.env`, NOT THIS FUNCTION'S `env` ARGUMENT, and that is the
126
+ // point. `env` is an injectable input to cache-root RESOLUTION; the env
127
+ // @electron/get actually reads is `process.env`, and the library offers no way
128
+ // to change that. Threading `env` here would make the control silently do
129
+ // nothing for any caller that passed a synthetic one — a guard aimed at a
130
+ // surface its target never reads.
131
+ const zip = await withScrubbedRepoEnv(() => downloadArtifact({
132
+ version,
133
+ artifactName: 'electron',
134
+ // MEASURED, and stated because the surrounding prose used to claim
135
+ // otherwise: `force` is DEAD in @electron/get 5.0.0 — `effectiveCacheMode`
136
+ // never reads it — so this call can return a CACHE PATH with no network
137
+ // fetch at all. It is left in place because removing it changes nothing
138
+ // today and a later version may honour it again. What makes this route
139
+ // sound is not freshness: it is that amicus reads and hashes whatever path
140
+ // comes back, in its own memory.
141
+ force: true,
142
+ cacheRoot: cacheRootFor(env),
143
+ platform,
144
+ arch,
145
+ ...(digest ? { checksums: { [fileName]: digest } } : {}),
146
+ downloadOptions: { signal: AbortSignal.timeout(downloadMs) }, // 5.x native fetch: bound stalled downloads, free the lock
147
+ }));
148
+ // READ THE BYTES ONCE, and never resolve that path again. Everything after
149
+ // this line acts on a Buffer in amicus's own heap.
150
+ const held = readArtifactBytes({ zip, fs });
151
+ if (!held.bytes) {
152
+ return { pinned: false, refused: refuseUnreadableArtifact({ fileName, zip, why: held.why, detail: held.detail, log }) };
153
+ }
154
+ const gate = verifyArtifactBytes({ bytes: held.bytes, anchor, fileName, policy, log });
155
+ if (!gate.allowed) { return { pinned: false, refused: rejectDownloadedZip({ gate, fileName, log }) }; }
156
+ // C2, on BOTH routes — F3 is the standing reminder of what a rule wired to one
157
+ // provision route and not the other costs. See ./electron-native-rescue for the
158
+ // trigger boundary; `rescue.used` is folded into `pinned` below.
159
+ const rescue = {};
160
+ const extractOrRescue = withNativeRescue({ extract, gate, policy, rescue, platform, fs, spawn, log });
161
+ await extractBytesToDist({ bytes: held.bytes, electronDir, platform, extract: extractOrRescue, fs });
162
+ // BOTH halves, deliberately. `digest` says a `checksums` table went out, so a
163
+ // hatch-dropped pin still reports unverified even when the anchor happens to
164
+ // agree; `verdict === 'verified'` says amicus itself hashed these exact bytes
165
+ // and they matched. Either half alone has been wrong: F3's first cut reported
166
+ // the table, and the table alone is what seat F#2 showed does not describe the
167
+ // bytes that reach the extractor.
168
+ //
169
+ // `!rescue.used` IS IMPLIED TODAY, AND IS STATED ANYWAY — said out loud so it
170
+ // is not mistaken for a measured control. A rescue requires the hatch, and the
171
+ // hatch has already set `digest` to null above, so `pinned` is false on every
172
+ // hatch-on provision whether or not a rescue ran: on THIS route the term
173
+ // changes no observable result. It is here because the property it encodes —
174
+ // a tree a child process extracted from a path was never pinned — must not
175
+ // depend on that coupling holding. On the CACHE route the same term is
176
+ // load-bearing and observable (electron-repair-cache.js :: repairFromCache).
177
+ return { pinned: !!digest && gate.verdict === 'verified' && !rescue.used };
178
+ }
179
+
180
+ /**
181
+ * May this refused artifact be deleted? STRICTLY NARROWER than the unconditional
182
+ * `fs.rmSync` on the corrupt-extract path: the basename must be exactly the
183
+ * artifact we asked for, AND the file must resolve inside a resolved cache root.
184
+ * `containsOnDisk` realpaths both sides and returns false on any error, so an
185
+ * unresolvable path is refused rather than trusted — deleting at an
186
+ * attacker-influenceable path is the one thing a poisoned cache could otherwise
187
+ * turn into a weapon.
188
+ *
189
+ * Cost if it returns a wrong false: the mismatched zip stays and is re-downloaded
190
+ * once per provision. An availability cost, never a safety one — the gate above
191
+ * still refuses to extract it.
192
+ */
193
+ function mayDeleteRejectedZip({ zip, fileName, env = process.env }) {
194
+ if (path.basename(zip) !== fileName) { return false; }
195
+ return resolveCacheRoots(env).some((root) => containsOnDisk(root, zip));
196
+ }
197
+
198
+ /**
199
+ * THERE IS NO LAST-RESORT INSTALLER ANY MORE (council seat B1, BLOCKER,
200
+ * confirmed 4 of 4). `runInstaller` spawned `<electronDir>/install.js`, which
201
+ * did its OWN download and its OWN extraction, outside every control on this
202
+ * page — and it was reached from `catch (provisionErr)` for ANY throw, so
203
+ * inducing one failure was enough to route around the whole gate.
204
+ *
205
+ * It was worse than "a bypass". install.js pins with
206
+ * `require('./checksums.json')` — the checksums of `<electronDir>`, the very
207
+ * directory `doctor --fix` located by SCANNING npx caches. That is the
208
+ * ANCHORFROMTARGET hole `resolveAnchor`'s rung 1 exists to close, and
209
+ * electron-trust.js records it as MEASURED-exploitable. It also extracted
210
+ * unbounded, with no stall protection and no path-traversal classification, and
211
+ * its success came back as a plain `{repaired: isElectronUsable()}` — bytes
212
+ * amicus never saw, labelled exactly like bytes it hashed.
213
+ *
214
+ * ITS ONE CLAIMED JUSTIFICATION WAS MEASURED FALSE. The case for keeping it was
215
+ * "amicus's own tree cannot resolve @electron/get but electron's can". On this
216
+ * machine, both resolve the SAME hoisted copy:
217
+ * amicus require.resolve('@electron/get') -> node_modules/@electron/get/dist/index.js
218
+ * electron createRequire(electron/package.json).resolve(...) -> node_modules/@electron/get/dist/index.js
219
+ * Every other trigger — a network failure, a mirror checksum failure, an abort
220
+ * timeout, an extract failure — makes install.js do the same download the same
221
+ * way with weaker checks. That is not a fallback; it is a bypass with a retry's
222
+ * reputation. A failed provision is now reported honestly instead:
223
+ * `ensureElectron` already turns that into "the GUI is unavailable, headless
224
+ * runs and the council work", clears its single-flight guard so the next launch
225
+ * retries, and points at `doctor --fix`.
226
+ *
227
+ * `scrubbedChildEnv` died with it (there is no child to build an env for). The
228
+ * enumeration it encoded survives as `isRepoPlantedName` in electron-env-scrub,
229
+ * which is what the IN-PROCESS download scrub uses.
230
+ */
231
+
232
+ module.exports = { cacheRootFor, controlledProvision, mayDeleteRejectedZip };