amicus 4.9.5 → 4.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,15 @@
1
1
  /**
2
- * Electron CONTROLLED provision — the pinned download, and what happens to a
3
- * cached artifact the digest gate refuses.
2
+ * Electron CONTROLLED provision — the pinned download, and the fence that says
3
+ * whether a refused cache artifact may be deleted.
4
4
  *
5
5
  * Split out of electron-install.js because that file sits at the repo's 300-line
6
- * gate and cannot grow. The require arrow is
7
- * electron-install -> electron-provision -> electron-trust and must never point
8
- * back, so `extractFromCache` arrives as an argument rather than an import.
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.
9
13
  *
10
14
  * @module sidecar/electron-provision
11
15
  */
@@ -15,7 +19,12 @@
15
19
  const path = require('path');
16
20
 
17
21
  const { resolveCacheRoots } = require('./electron-cache');
18
- const { artifactFileName, expectedDigest } = require('./electron-trust');
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');
19
28
  const { containsOnDisk } = require('../utils/path-fence');
20
29
 
21
30
  /** Best-effort cache root for downloadArtifact (first resolved root). */
@@ -25,8 +34,9 @@ function cacheRootFor(env = process.env) {
25
34
 
26
35
  /**
27
36
  * CONTROLLED provision: fetch the zip ourselves with the SAME @electron/get
28
- * api install.js uses (downloadArtifact, force:true), extract offline, and let
29
- * the caller verify isElectronUsable(). No blind install.js spawn.
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.
30
40
  *
31
41
  * C1 — THE PIN. `checksums` is what breaks the attack chain. Supplied, it makes
32
42
  * @electron/get write a LOCAL SHASUMS256.txt from this table and never fetch one
@@ -48,11 +58,40 @@ function cacheRootFor(env = process.env) {
48
58
  * SHASUMS256.txt, which is what a rebuild publishes. That is a real downgrade,
49
59
  * so it is stated out loud on stderr every time rather than happening quietly —
50
60
  * and it is reachable ONLY through a bare env name a repository cannot plant.
51
- * @returns {Promise<void>}
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.
52
91
  */
53
92
  async function controlledProvision({
54
- electronDir, platform, arch, version, anchor, downloadArtifact, extract, extractFromCache,
55
- fs, env = process.env, downloadMs = 480000, policy = {}, log = () => {},
93
+ electronDir, platform, arch, version, anchor, downloadArtifact, extract,
94
+ fs, spawn, env = process.env, downloadMs = 480000, policy = {}, log = () => {},
56
95
  }) {
57
96
  const fileName = artifactFileName({ version, platform, arch });
58
97
  let digest = expectedDigest(anchor, fileName);
@@ -60,18 +99,82 @@ async function controlledProvision({
60
99
  log('[amicus] WARNING: AMICUS_ALLOW_UNVERIFIED_ELECTRON=1 — downloading without the published');
61
100
  log(`[amicus] sha256 pin for ${fileName}; its digest comes from the mirror you are using.`);
62
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.');
63
107
  }
64
- const zip = await downloadArtifact({
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({
65
132
  version,
66
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.
67
141
  force: true,
68
142
  cacheRoot: cacheRootFor(env),
69
143
  platform,
70
144
  arch,
71
145
  ...(digest ? { checksums: { [fileName]: digest } } : {}),
72
146
  downloadOptions: { signal: AbortSignal.timeout(downloadMs) }, // 5.x native fetch: bound stalled downloads, free the lock
73
- });
74
- await extractFromCache({ zip, electronDir, platform, extract, fs });
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 };
75
178
  }
76
179
 
77
180
  /**
@@ -93,87 +196,37 @@ function mayDeleteRejectedZip({ zip, fileName, env = process.env }) {
93
196
  }
94
197
 
95
198
  /**
96
- * Act on a REFUSED cached artifact: remove the poison when it is safe to, say
97
- * plainly what happened, and hand back the result shape a cacheOnly caller
98
- * returns. Deletion happens ONLY on `mismatch` a `no-digest` artifact is not
99
- * evidence of anything, and an `unreadable` one is a file we could not even hash.
100
- * @returns {{repaired:false, integrity:string, reason:string}}
101
- */
102
- function rejectCachedZip({ gate, zip, fileName, env = process.env, fs, log = () => {} }) {
103
- let removed = false;
104
- if (gate.verdict === 'mismatch' && mayDeleteRejectedZip({ zip, fileName, env })) {
105
- try {
106
- fs.rmSync(zip, { force: true });
107
- removed = true;
108
- } catch { /* a cache we cannot write is not a reason to fail the repair */ }
109
- }
110
- const what = gate.verdict === 'mismatch'
111
- ? `sha256 ${gate.actual} does not match the published ${gate.expected}`
112
- : gate.reason;
113
- log(`[amicus] Electron artifact REFUSED: ${fileName}`);
114
- log(`[amicus] ${zip}`);
115
- log(`[amicus] ${what}`);
116
- log('[amicus] This is what a swapped mirror or a planted cache file looks like. It is ALSO');
117
- log('[amicus] what a truncated download, a failing disk, or a mirror serving a REBUILT');
118
- log('[amicus] electron looks like — amicus cannot tell them apart.');
119
- // ORDER MATTERS. The advice comes BEFORE the removal notice, and says what to
120
- // do about a file that is already gone: a hand-seeded air-gapped cache is the
121
- // one place the refused artifact was also the ONLY copy, and being told about
122
- // the hatch after "The file has been removed." is being told too late to use
123
- // it. The delete itself is required (a poisoned zip must not survive to be
124
- // re-offered); the words around it are what make it recoverable.
125
- log('[amicus] If you deliberately run a REBUILT electron, set');
126
- log('[amicus] AMICUS_ALLOW_UNVERIFIED_ELECTRON=1 BEFORE provisioning again — it accepts these');
127
- log('[amicus] bytes on the cache path and drops the digest pin on the download path.');
128
- log(`[amicus] ${removed
129
- ? 'The file has been removed: re-copy it from the machine that downloaded it (or let'
130
- + '\n[amicus] amicus download it again) once that variable is set.'
131
- : 'The file was left in place.'}`);
132
- log('[amicus] Headless runs and the council work without the GUI.');
133
- return {
134
- repaired: false,
135
- integrity: gate.verdict,
136
- reason: `Cached electron artifact ${fileName} was REFUSED: ${what}.`
137
- + `${removed ? ' It has been removed.' : ' It was left in place.'}`,
138
- };
139
- }
140
-
141
- /** The terminal path-traversal refusal `robustExtract` throws (unzip.js C4). */
142
- function isUnsafeArchive(err) {
143
- return !!err && err.code === 'UNZIP_UNSAFE_ARCHIVE';
144
- }
145
-
146
- /**
147
- * C4 AT THE CALL SITE. unzip.js classifies extract-zip's path-traversal refusals
148
- * as terminal so the same archive is never handed to an OS extractor that has no
149
- * such check. That invariant held only INSIDE unzip.js: both of repairElectron's
150
- * catch blocks used to swallow the refusal without reading `err.code` and launder
151
- * it back into exactly the retry the control forbids — the network path spawned
152
- * `node <electronDir>/install.js`, which re-downloads and re-extracts through
153
- * @electron-internal/extract-zip with no amicus supervision (the forbidden move,
154
- * one stack frame up), and the cache path deleted the zip through the UNFENCED
155
- * `fs.rmSync` and told the user it "was corrupt and removed" — a security refusal
156
- * reported as corruption. MEASURED both, before this change.
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.
157
204
  *
158
- * So the refusal ends here: no retry, no fallback extractor, and no delete. The
159
- * archive is left where it is, because a refused archive is evidence, and
160
- * `err.message` already carries the path and extract-zip's own reason.
161
- * @returns {{repaired:false, integrity:'unsafe-archive', reason:string}}
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.
162
230
  */
163
- function refuseUnsafeArchive({ err, fileName, log = () => {} }) {
164
- const detail = (err && err.message) || 'the archive tried to write outside its destination';
165
- log(`[amicus] Electron artifact REFUSED (unsafe archive): ${fileName}`);
166
- log(`[amicus] ${detail}`);
167
- log('[amicus] Entries in that zip tried to write OUTSIDE the destination directory. amicus');
168
- log('[amicus] will not retry it with another extractor, and has left the file in place.');
169
- log('[amicus] Headless runs and the council work without the GUI.');
170
- return {
171
- repaired: false,
172
- integrity: 'unsafe-archive',
173
- reason: `Electron artifact ${fileName} was REFUSED: ${detail}. It was NOT retried and NOT removed.`,
174
- };
175
- }
176
231
 
177
- module.exports = {
178
- cacheRootFor, controlledProvision, mayDeleteRejectedZip, rejectCachedZip, isUnsafeArchive, refuseUnsafeArchive,
179
- };
232
+ module.exports = { cacheRootFor, controlledProvision, mayDeleteRejectedZip };
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Electron artifact REFUSALS — the ways amicus declines to turn bytes into an
3
+ * Electron install, and the exact words it uses each time. The two NOTICES the
4
+ * native-extractor rescue speaks are the same kind of thing and were briefly
5
+ * here; they live in `./electron-rescue-notice` because this file sits at the
6
+ * repo's 300-line gate and they pushed it over. Same seam either side of the
7
+ * split: the words live in these two files, the policy does not.
8
+ *
9
+ * SPLIT OUT of electron-provision.js (v4.9.6, second council round) because that
10
+ * file sits under the repo's 300-line gate and the round added two more
11
+ * refusals: a DOWNLOADED artifact that fails amicus's own hash (F#2), and an
12
+ * artifact that could not be taken private at all (F#6/F#8). The split is along
13
+ * a real seam — everything here composes a message and returns a result shape,
14
+ * and nothing here decides policy. The one `rmSync` in the file is fenced by an
15
+ * answer its caller computed (`mayDelete`), never by anything derived here.
16
+ *
17
+ * F5 (council seat B5). Everything below that reaches stderr or a returned
18
+ * `reason` goes through the house sanitizer first, because two of its inputs are
19
+ * written by the attacker: an unsafe archive's refusal text carries the ARCHIVE'S
20
+ * OWN entry name, and a cached artifact's path carries a `<sha>` directory name
21
+ * read out of a cache root anyone can write. Unsanitized, either could embed ANSI
22
+ * escapes, a newline plus a forged `[amicus] …` line, or a right-to-left override
23
+ * that renders the rest of the sentence backwards — in the one message a user
24
+ * reads when amicus is telling them something is wrong.
25
+ *
26
+ * NEAR-LEAF MODULE: `utils/text-sanitize` only. electron-install.js and
27
+ * electron-provision.js both require it, which adds no cycle.
28
+ *
29
+ * @module sidecar/electron-refuse
30
+ */
31
+
32
+ 'use strict';
33
+
34
+ const { collapseExcerpt } = require('../utils/text-sanitize');
35
+
36
+ /**
37
+ * A path gets a longer cap than an error excerpt: 200 characters truncates a real
38
+ * npx-cache path, and a path the user cannot copy is not much use in a refusal.
39
+ */
40
+ const PATH_EXCERPT_CHARS = 320;
41
+
42
+ /**
43
+ * What each of `readArtifactBytes`'s refusals MEANS, in one clause the user can
44
+ * act on. Kept here rather than in the custody module so every user-facing
45
+ * sentence in this subsystem is written in one file.
46
+ */
47
+ const UNREADABLE_REASON = {
48
+ unreadable: 'could not be opened or read at all',
49
+ 'not-a-file': 'is not a regular file',
50
+ empty: 'is empty',
51
+ 'too-large': 'is far larger than any electron artifact',
52
+ 'short-read': 'ended early while amicus was reading it',
53
+ grew: 'changed size while amicus was reading it',
54
+ };
55
+
56
+ /** The terminal path-traversal refusal `robustExtract` throws (unzip.js C4). */
57
+ function isUnsafeArchive(err) {
58
+ return !!err && err.code === 'UNZIP_UNSAFE_ARCHIVE';
59
+ }
60
+
61
+ /**
62
+ * C4 AT THE CALL SITE. unzip.js classifies extract-zip's path-traversal refusals
63
+ * as terminal so the same archive is never handed to an OS extractor that has no
64
+ * such check. That invariant held only INSIDE unzip.js: both of repairElectron's
65
+ * catch blocks used to swallow the refusal without reading `err.code` and launder
66
+ * it back into exactly the retry the control forbids — the network path spawned
67
+ * `node <electronDir>/install.js`, which re-downloads and re-extracts through
68
+ * @electron-internal/extract-zip with no amicus supervision (the forbidden move,
69
+ * one stack frame up), and the cache path deleted the zip through the UNFENCED
70
+ * `fs.rmSync` and told the user it "was corrupt and removed" — a security refusal
71
+ * reported as corruption. MEASURED both, before this change.
72
+ *
73
+ * So the refusal ends here: no retry, no fallback extractor, and no delete. The
74
+ * archive is left where it is, because a refused archive is evidence, and
75
+ * `err.message` already carries the path and extract-zip's own reason.
76
+ * @returns {{repaired:false, integrity:'unsafe-archive', reason:string}}
77
+ */
78
+ function refuseUnsafeArchive({ err, fileName, log = () => {} }) {
79
+ // F5: this message quotes the ARCHIVE'S OWN entry name back at the user. The
80
+ // house sanitizer runs before it reaches stderr or the returned reason.
81
+ const detail = collapseExcerpt((err && err.message) || 'the archive tried to write outside its destination');
82
+ log(`[amicus] Electron artifact REFUSED (unsafe archive): ${fileName}`);
83
+ log(`[amicus] ${detail}`);
84
+ log('[amicus] Entries in that zip tried to write OUTSIDE the destination directory. amicus');
85
+ log('[amicus] will not retry it with another extractor, and has left the file in place.');
86
+ log('[amicus] Headless runs and the council work without the GUI.');
87
+ return {
88
+ repaired: false,
89
+ integrity: 'unsafe-archive',
90
+ reason: `Electron artifact ${fileName} was REFUSED: ${detail}. It was NOT retried and NOT removed.`,
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Act on a REFUSED cached artifact: remove the poison when it is safe to, say
96
+ * plainly what happened, and hand back the result shape a cacheOnly caller
97
+ * returns. Deletion happens ONLY on `mismatch` — a `no-digest` artifact is not
98
+ * evidence of anything, and an `unreadable` one is a file we could not even hash.
99
+ *
100
+ * `zip` is the ORIGINAL cache path, which is also the ONLY copy of these bytes
101
+ * anyone can reach: staging COPIES, so the artifact never left the cache and
102
+ * this `rmSync` is the whole delete, exactly as it was in v4.9.5. (The first cut
103
+ * of the F1 work made it conditional on a `moved` flag the stage object carried;
104
+ * seats F#4/F#5 caught the sibling branch where that silently removed nothing.)
105
+ *
106
+ * `mayDelete` IS THE FENCE'S ANSWER, PASSED IN, not re-derived here — the fence
107
+ * realpaths the artifact, so it has to be asked while the file is still where the
108
+ * caller found it.
109
+ * @returns {{repaired:false, integrity:string, reason:string}}
110
+ */
111
+ function rejectCachedZip({ gate, zip, fileName, mayDelete = false, fs, log = () => {} }) {
112
+ let removed = false;
113
+ if (gate.verdict === 'mismatch' && mayDelete) {
114
+ try {
115
+ fs.rmSync(zip, { force: true });
116
+ removed = true;
117
+ } catch { /* a cache we cannot write is not a reason to fail the repair */ }
118
+ }
119
+ // F5: `gate.reason` is an fs error string (an `unreadable` verdict) and `zip` is
120
+ // an attacker-influenced cache path; the digests are 64-hex by construction.
121
+ const what = gate.verdict === 'mismatch'
122
+ ? `sha256 ${gate.actual} does not match the published ${gate.expected}`
123
+ : collapseExcerpt(gate.reason);
124
+ log(`[amicus] Electron artifact REFUSED: ${fileName}`);
125
+ log(`[amicus] ${collapseExcerpt(zip, PATH_EXCERPT_CHARS)}`);
126
+ log(`[amicus] ${what}`);
127
+ log('[amicus] This is what a swapped mirror or a planted cache file looks like. It is ALSO');
128
+ log('[amicus] what a truncated download, a failing disk, or a mirror serving a REBUILT');
129
+ log('[amicus] electron looks like — amicus cannot tell them apart.');
130
+ // ORDER MATTERS. The advice comes BEFORE the removal notice, and says what to
131
+ // do about a file that is already gone: a hand-seeded air-gapped cache is the
132
+ // one place the refused artifact was also the ONLY copy, and being told about
133
+ // the hatch after "The file has been removed." is being told too late to use
134
+ // it. The delete itself is required (a poisoned zip must not survive to be
135
+ // re-offered); the words around it are what make it recoverable.
136
+ log('[amicus] If you deliberately run a REBUILT electron, set');
137
+ log('[amicus] AMICUS_ALLOW_UNVERIFIED_ELECTRON=1 BEFORE provisioning again — it accepts these');
138
+ log('[amicus] bytes on the cache path and drops the digest pin on the download path.');
139
+ log(`[amicus] ${removed
140
+ ? 'The file has been removed: re-copy it from the machine that downloaded it (or let'
141
+ + '\n[amicus] amicus download it again) once that variable is set.'
142
+ : 'The file was left in place.'}`);
143
+ log('[amicus] Headless runs and the council work without the GUI.');
144
+ return {
145
+ repaired: false,
146
+ integrity: gate.verdict,
147
+ reason: `Cached electron artifact ${fileName} was REFUSED: ${what}.`
148
+ + `${removed ? ' It has been removed.' : ' It was left in place.'}`,
149
+ };
150
+ }
151
+
152
+ /**
153
+ * F#2 — a DOWNLOADED artifact that amicus hashed for itself and refused.
154
+ *
155
+ * WHY THERE IS A SECOND GATE AT ALL, when `checksums` already went out.
156
+ * @electron/get validates in its own temp dir and then RENAMES the artifact into
157
+ * the cache root, returning THAT path (5.0.0, dist/index.js lines 148-160 and
158
+ * Cache.js lines 60-76) — so the last bytes it vouched for live at a path the
159
+ * same cache-dir writer controls. On a cache HIT it is looser still: `force` is
160
+ * dead in 5.0.0 (the only read guard is `shouldTryReadCache(effectiveCacheMode(...))`,
161
+ * and amicus sends no `cacheMode`), so it validates a file AT ITS CACHE PATH and
162
+ * hands that back. Either way the vouching happened somewhere amicus does not
163
+ * control, which is the whole reason the bytes are read into memory and
164
+ * re-hashed here. (They are not COPIED anywhere: the private staging directory
165
+ * that used to hold them was deleted in this change, its privacy claim having
166
+ * been measured false.)
167
+ *
168
+ * NOTHING IS DELETED. The refused artifact stays where @electron/get put it, and
169
+ * the CACHE route removes it on the next run through the fence that was written
170
+ * for exactly that (`mayDeleteRejectedZip` + `rejectCachedZip`). Deleting from
171
+ * here would add a second, differently-fenced unlink for no gain: the number of
172
+ * downloads is the same either way.
173
+ * @returns {{repaired:false, integrity:string, reason:string}}
174
+ */
175
+ function rejectDownloadedZip({ gate, fileName, log = () => {} }) {
176
+ const what = gate.verdict === 'mismatch'
177
+ ? `sha256 ${gate.actual} does not match the published ${gate.expected}`
178
+ : collapseExcerpt(gate.reason);
179
+ log(`[amicus] DOWNLOADED electron artifact REFUSED: ${fileName}`);
180
+ log(`[amicus] ${what}`);
181
+ // WHAT THIS LINE MAY CLAIM. It used to promise the download was hashed inside
182
+ // a PRIVATE staging directory — a containment property that was DELETED in
183
+ // this same change, because the council measured it false (mkdtempSync yields
184
+ // mode 666 on Windows and the chmod(0o700) was skipped on win32, and a spinner
185
+ // found the fixed `amicus-electron-stage-` prefix on its FIRST readdir). The
186
+ // docs describing that copy were fixed and this string was missed; nothing
187
+ // pinned it, so nothing caught it. It now states the property that is
188
+ // actually true, and it is pinned by a test.
189
+ log('[amicus] amicus read these bytes ONCE, into its own memory, and hashed THAT buffer — the');
190
+ log('[amicus] same buffer it would have extracted. There is no copy on disk and no path in play');
191
+ log('[amicus] after the read. These bytes were NOT extracted and no Electron was installed from');
192
+ log('[amicus] them. Check what ELECTRON_MIRROR points at.');
193
+ log('[amicus] Headless runs and the council work without the GUI.');
194
+ return {
195
+ repaired: false,
196
+ integrity: gate.verdict,
197
+ reason: `Downloaded electron artifact ${fileName} was REFUSED: ${what}. It was NOT extracted.`,
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Bytes amicus could not take into custody are never extracted.
203
+ *
204
+ * REPLACES `refuseUnstagedArtifact`, and the replacement is not cosmetic. The
205
+ * old refusal existed because staging COPIED the artifact into `os.tmpdir()`,
206
+ * so its whole vocabulary was about temp: "could not be copied into a private
207
+ * directory", "free up space in the temp directory". Council finding A3 caught
208
+ * the consequence — a cache entry deleted by a concurrent repair mid-copy was
209
+ * reported as "no space, or an unwritable temp directory", advice that could
210
+ * not possibly help. There is no copy and no temp directory any more; the only
211
+ * way to fail here is that the ARTIFACT ITSELF could not be read, and `why`
212
+ * says which way.
213
+ *
214
+ * The first cut of the staging work FAILED OPEN in this position: when the
215
+ * artifact could not be taken private the download route quietly extracted the
216
+ * unstaged cache path anyway, while the one line on screen said the bytes would
217
+ * not be extracted (seats F#6/F#8, MEASURED both shapes). Both routes refuse
218
+ * here now, and `integrity` is set so `scripts/postinstall.js` prints the reason
219
+ * instead of its generic "provisions on first use" notice.
220
+ *
221
+ * F5: `zip` is an attacker-influenced cache path (its `<sha>` directory name
222
+ * came out of a `readdirSync` of a directory anyone can write) and `detail` is
223
+ * an fs error string, so both are sanitized before reaching stderr or the
224
+ * returned reason.
225
+ * @param {object} o
226
+ * @param {string} o.fileName
227
+ * @param {string} o.zip
228
+ * @param {string} o.why one of readArtifactBytes's named refusals
229
+ * @param {string} [o.detail]
230
+ * @returns {{repaired:false, integrity:'unreadable', reason:string}}
231
+ */
232
+ function refuseUnreadableArtifact({ fileName, zip, why, detail = '', log = () => {} }) {
233
+ const where = collapseExcerpt(zip, PATH_EXCERPT_CHARS);
234
+ const what = UNREADABLE_REASON[why] || 'could not be read';
235
+ log(`[amicus] Electron artifact NOT extracted: ${fileName}`);
236
+ log(`[amicus] ${where}`);
237
+ log(`[amicus] ${what}${detail ? ` (${collapseExcerpt(detail)})` : ''}`);
238
+ log('[amicus] amicus reads an artifact ONCE, into memory, and hashes and extracts THOSE');
239
+ log('[amicus] bytes. It could not read these, so it has nothing it could vouch for and');
240
+ log('[amicus] has extracted nothing. The file was left exactly where it is.');
241
+ log('[amicus] Headless runs and the council work without the GUI.');
242
+ return {
243
+ repaired: false,
244
+ integrity: 'unreadable',
245
+ reason: `Electron artifact ${fileName} ${what}, so it was NOT extracted: ${where}.`
246
+ + ' It was left in place.',
247
+ };
248
+ }
249
+
250
+ module.exports = {
251
+ isUnsafeArchive, refuseUnsafeArchive, rejectCachedZip, rejectDownloadedZip, refuseUnreadableArtifact,
252
+ PATH_EXCERPT_CHARS,
253
+ };