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.
@@ -13,20 +13,34 @@
13
13
  * #59: ELECTRON_OVERRIDE_DIST_PATH moves the exe to <override>/<exe> (mirrors
14
14
  * electron/index.js + install.js semantics). Cache layout (@electron/get):
15
15
  * <cacheRoot>/<sha256>/electron-v<ver>-<platform>-<arch>.zip
16
+ *
17
+ * AT THE SIZE GATE, so pieces live next door: `./electron-custody` reads the
18
+ * artifact into memory once, `./zip-from-buffer` extracts what was read,
19
+ * `./electron-exe-rel` holds the ONE `path.txt` rule (`platformExe`/`heldExeRel`/
20
+ * `writePathTxt`/`distHeldExe`), shared with `promoteDist`'s retirement guard since
21
+ * v4.9.7 (A1); `./electron-layout` holds `promoteDist`/`extractBytesToDist`
22
+ * (`platformExe` re-exported here for `ei.platformExe`), `./electron-refuse`
23
+ * holds the refusal messages, `./electron-repair-cache` the whole cached-artifact
24
+ * route, `./electron-provision` the pinned download. The arrow points one way out
25
+ * of this file and never back.
16
26
  */
17
27
 
18
28
  'use strict';
19
29
 
20
30
  const fsDefault = require('fs');
21
31
  const path = require('path');
22
- const { spawnSync } = require('child_process');
23
32
 
24
- const { resolveCacheRoots } = require('./electron-cache');
33
+ const { cachedZip } = require('./electron-cache');
34
+ const { isSafeArtifactName } = require('./electron-custody');
25
35
  const { avHint, verifyExtractOutcome: verifyQuarantine } = require('./electron-quarantine');
26
36
  const { acquireRepairLock } = require('./electron-lock');
27
- const { controlledProvision, isUnsafeArchive, refuseUnsafeArchive, rejectCachedZip } = require('./electron-provision');
28
- const { artifactFileName, electronTrustPolicy, resolveAnchor, scrubbedChildEnv, verifyArtifact } = require('./electron-trust');
29
- const { robustExtract } = require('./unzip');
37
+ const { platformExe, heldExeRel } = require('./electron-exe-rel');
38
+ const { controlledProvision } = require('./electron-provision');
39
+ const { repairFromCache } = require('./electron-repair-cache');
40
+ const { isUnsafeArchive, refuseUnsafeArchive } = require('./electron-refuse');
41
+ const { artifactFileName, electronTrustPolicy, resolveAnchor } = require('./electron-trust');
42
+ const { extractZipBuffer } = require('./zip-from-buffer');
43
+ const { collapseExcerpt } = require('../utils/text-sanitize');
30
44
 
31
45
  /** Self-heal progress line to stderr (visible during first-GUI provision). */
32
46
  function stderrLog(msg) {
@@ -42,19 +56,6 @@ function defaultElectronDir() {
42
56
  }
43
57
  }
44
58
 
45
- /** Platform exe basename, matching electron's getPlatformPath(). */
46
- function platformExe(platform) {
47
- switch (platform) {
48
- case 'mas':
49
- case 'darwin':
50
- return path.join('Electron.app', 'Contents', 'MacOS', 'Electron');
51
- case 'win32':
52
- return 'electron.exe';
53
- default:
54
- return 'electron';
55
- }
56
- }
57
-
58
59
  /**
59
60
  * Resolve the on-disk electron exe path from the package layout.
60
61
  * Mirrors ELECTRON_OVERRIDE_DIST_PATH semantics (#59): when set, the exe is
@@ -62,16 +63,9 @@ function platformExe(platform) {
62
63
  * @returns {string|null} resolved exe path, or null if path.txt is unreadable.
63
64
  */
64
65
  function resolveElectronBinary({ electronDir = defaultElectronDir(), env = process.env, platform = process.platform, fs = fsDefault } = {}) {
65
- let exeRel;
66
- const pathFile = path.join(electronDir, 'path.txt');
67
- try {
68
- exeRel = fs.readFileSync(pathFile, 'utf-8').trim();
69
- } catch {
70
- exeRel = '';
71
- }
72
- if (!exeRel) {
73
- exeRel = platformExe(platform);
74
- }
66
+ let raw = null;
67
+ try { raw = fs.readFileSync(path.join(electronDir, 'path.txt'), 'utf-8'); } catch { /* absent or unreadable */ }
68
+ const exeRel = heldExeRel(raw, platform); // the ONE rule (electron-exe-rel.js)
75
69
  const override = env.ELECTRON_OVERRIDE_DIST_PATH;
76
70
  if (override) {
77
71
  return path.join(override, exeRel);
@@ -92,47 +86,6 @@ function isElectronUsable({ electronDir = defaultElectronDir(), env = process.en
92
86
  }
93
87
  }
94
88
 
95
- /**
96
- * Locate a previously-downloaded electron zip in the env-configurable cache
97
- * roots. Walks <root>/<sha>/electron-v<ver>-<platform>-<arch>.zip.
98
- * @returns {string|null} absolute zip path, or null when no cache hit.
99
- */
100
- function cachedZip({ version, platform = process.platform, arch = process.arch, env = process.env, fs = fsDefault } = {}) {
101
- const zipName = `electron-v${version}-${platform}-${arch}.zip`;
102
- for (const root of resolveCacheRoots(env)) {
103
- let shaDirs;
104
- try {
105
- shaDirs = fs.readdirSync(root);
106
- } catch {
107
- continue;
108
- }
109
- for (const sha of shaDirs) {
110
- const candidate = path.join(root, sha, zipName);
111
- try {
112
- if (fs.existsSync(candidate)) {
113
- return candidate;
114
- }
115
- } catch {
116
- /* ignore unreadable subdir */
117
- }
118
- }
119
- }
120
- return null;
121
- }
122
-
123
- /** Restore path.txt so electron/index.js resolves the freshly-extracted exe. */
124
- function writePathTxt({ electronDir, platform, fs }) {
125
- fs.writeFileSync(path.join(electronDir, 'path.txt'), platformExe(platform));
126
- }
127
-
128
- /** Extract a cached zip into <electronDir>/dist offline. */
129
- async function extractFromCache({ zip, electronDir, platform, extract, fs }) {
130
- const distDir = path.join(electronDir, 'dist');
131
- fs.mkdirSync(distDir, { recursive: true });
132
- await extract(zip, { dir: distDir });
133
- writePathTxt({ electronDir, platform, fs });
134
- }
135
-
136
89
  /** Bind the fs-aware probes for the post-extract AV-quarantine verify. */
137
90
  function verifyExtractOutcome({ electronDir, platform, fs }) {
138
91
  return verifyQuarantine({
@@ -142,37 +95,25 @@ function verifyExtractOutcome({ electronDir, platform, fs }) {
142
95
  });
143
96
  }
144
97
 
145
- /**
146
- * Drive electron's own install.js with force_no_cache semantics. C3: the spawn env
147
- * is SCRUBBED — install.js honours `npm_config_electron_mirror` AND
148
- * `npm_config_electron_use_remote_checksums` (which turns its own bundled pin off),
149
- * so `{...process.env}` here would funnel a blocked attacker into an unpinned
150
- * downloader and undo the pin on the route above.
151
- */
152
- function runInstaller({ electronDir, force, spawn, platform, arch }) {
153
- const installScript = path.join(electronDir, 'install.js');
154
- const env = scrubbedChildEnv({ env: process.env, platform, arch });
155
- if (force) {
156
- env.force_no_cache = 'true';
157
- }
158
- return spawn(process.execPath, [installScript], { env, stdio: 'ignore' });
159
- }
160
-
161
98
  /**
162
99
  * Heal a broken electron install.
163
100
  *
164
101
  * @param {object} opts
165
102
  * @param {boolean} [opts.cacheOnly] never hit the network; return
166
103
  * {deferred,reason} when there is no cached zip.
167
- * @param {boolean} [opts.force] force a fresh (no-cache) installer download.
168
- * @param {number} [opts.timeoutMs] best-effort installer timeout.
169
- * @param {object} [opts.deps] injected { cachedZip, extract, spawn, acquireLock, fs,
104
+ * @param {number} [opts.timeoutMs] best-effort download timeout.
105
+ * @param {object} [opts.deps] injected { cachedZip, extract, acquireLock, fs,
170
106
  * selfElectronDir } — the last pins the digest anchor's top rung (null disables it).
107
+ *
108
+ * THERE IS NO `force` OPTION. There was, and it did nothing: it only ever set
109
+ * `force_no_cache` for the install.js spawn (now deleted), and `force` is DEAD
110
+ * in @electron/get 5.0.0 anyway — `effectiveCacheMode` never reads it, MEASURED.
111
+ * An accepted-but-inert flag is worse than no flag, so it is gone rather than
112
+ * documented.
171
113
  * @returns {Promise<{repaired?:boolean, deferred?:boolean, contended?:boolean, reason?:string}>}
172
114
  */
173
115
  async function repairElectron({
174
116
  cacheOnly = false,
175
- force = false,
176
117
  timeoutMs,
177
118
  electronDir = defaultElectronDir(),
178
119
  platform = process.platform,
@@ -181,12 +122,22 @@ async function repairElectron({
181
122
  deps = {},
182
123
  } = {}) {
183
124
  const fs = deps.fs || fsDefault;
184
- // Default extract: extract-zip bounded (idle/max) + native-unzip fallback (extract-zip-node24 stall).
125
+ // Default extract: from the BUFFER amicus already hashed, never from a name.
126
+ // `unzip.js` is byte-for-byte unchanged; the one thing still taken from it is
127
+ // its native PLAN, and only behind the hatch (see ./electron-native-rescue).
128
+ //
129
+ // WHAT CAME WITH IT AND WHAT DID NOT. Its idle + hard-cap STALL BOUND is
130
+ // reimplemented in `zip-from-buffer.js` with the same numbers, so this path is
131
+ // bounded again (it was not, for three commits). Its NATIVE OS unzip fallback
132
+ // is NOT the default and never can be: `tar`/`Expand-Archive`/`ditto`/`unzip`
133
+ // all take a path, and feeding one either the artifact or a temp copy of our
134
+ // Buffer undoes the custody property outright. C2 re-wires it as a RESCUE for
135
+ // ONE failure class, reachable only with AMICUS_ALLOW_UNVERIFIED_ELECTRON=1 —
136
+ // the flag whose documented meaning is already "I accept bytes amicus cannot
137
+ // vouch for". `spawn` is threaded for it, and stays injectable for tests.
185
138
  const extract = deps.extract
186
- || ((zipPath, o) => robustExtract(zipPath, { ...o, platform, deps: { fs, log: stderrLog } }));
187
- // Default-bound (8 min) so a first-GUI-use provision that reaches runInstaller
188
- // without an explicit timeoutMs can't hang the holder; caller's value wins.
189
- const spawn = deps.spawn || ((cmd, args, o) => spawnSync(cmd, args, { ...o, timeout: timeoutMs || 480000 }));
139
+ || ((bytes, o) => extractZipBuffer(bytes, { ...o, deps: { fs, log: stderrLog } }));
140
+ const spawn = deps.spawn;
190
141
  const findZip = deps.cachedZip || ((o) => cachedZip(o));
191
142
  const acquireLock = deps.acquireLock || ((o) => acquireRepairLock({ ...o, fs }));
192
143
  // Lazy: import the ESM-only @electron/get only on the network path, so cacheOnly
@@ -204,6 +155,15 @@ async function repairElectron({
204
155
  // read out of electronDir's own package.json above, and letting an untrusted
205
156
  // directory pick which anchor judges its bytes is the ANCHORFROMTARGET hole.
206
157
  const fileName = artifactFileName({ version, platform, arch });
158
+ // F#3: `version` may have just been read out of an UNTRUSTED <electronDir>/
159
+ // package.json (doctor --fix supplies none, for a dir it found by scanning npx
160
+ // caches), and `fileName` is joined into paths downstream. MEASURED before the
161
+ // check, with a planted "43.1.1/../../victim": a path two levels outside the
162
+ // intended directory was written and a pre-existing file there was destroyed.
163
+ // Nothing downstream ever sees a name that is not a plain filename.
164
+ if (!isSafeArtifactName(fileName)) {
165
+ return { repaired: false, integrity: 'unsafe-name', reason: `Refusing to provision electron: ${collapseExcerpt(fileName, 160)} is not a usable artifact name.` };
166
+ }
207
167
  const policy = electronTrustPolicy(process.env);
208
168
  const anchor = resolveAnchor({ electronDir, fs, selfElectronDir: deps.selfElectronDir });
209
169
 
@@ -220,36 +180,18 @@ async function repairElectron({
220
180
 
221
181
  let refusal = null; // a cache refusal the caller must still hear about if the download also fails
222
182
  try {
223
- // Attempt 1: extract from cache (always preferred, fully offline).
183
+ // Attempt 1: extract from cache (always preferred, fully offline). The whole
184
+ // route lives in ./electron-repair-cache — read once into memory, hash THOSE
185
+ // bytes, extract THOSE bytes — and answers `done` when nothing is left to try.
224
186
  const zip = findZip({ version, platform, arch, env: process.env, fs });
225
187
  if (zip) {
226
- // C2: anything that can write the cache dir can swap these bytes, so HASH
227
- // BEFORE EXTRACT extractFromCache must be unreachable for an artifact the
228
- // anchor contradicts. A missing anchor is NOT a refusal (see verifyArtifact).
229
- const gate = verifyArtifact({ zip, anchor, fileName, policy, fs, log: stderrLog });
230
- if (!gate.allowed) {
231
- refusal = rejectCachedZip({ gate, zip, fileName, env: process.env, fs, log: stderrLog });
232
- if (cacheOnly) { return refusal; }
233
- // else: drop into the controlled download below.
234
- } else {
235
- try {
236
- await extractFromCache({ zip, electronDir, platform, extract, fs });
237
- // Non-throwing extract w/ absent exe = the AV-quarantine signature.
238
- const outcome = verifyExtractOutcome({ electronDir, platform, fs });
239
- return gate.verdict === 'no-digest' ? { ...outcome, unverified: true } : outcome;
240
- } catch (extractErr) {
241
- // C4 IS A CALL-SITE INVARIANT. A path-traversal refusal must not be
242
- // deleted-and-retried, nor reported as "corrupt" — it stops here.
243
- if (isUnsafeArchive(extractErr)) { return refuseUnsafeArchive({ err: extractErr, fileName, log: stderrLog }); }
244
- // Corrupt cached artifact: delete the bad zip so it can't poison the
245
- // cache, then fall through to a forced fresh download (unless offline).
246
- try { fs.rmSync(zip, { force: true }); } catch { /* ignore */ }
247
- if (cacheOnly) {
248
- return { repaired: false, reason: `Cached electron zip for v${version} (${platform}-${arch}) was corrupt and removed; deferring re-download.${avHint(platform)}` };
249
- }
250
- // else: drop into the controlled download below.
251
- }
252
- }
188
+ const attempt = await repairFromCache({
189
+ zip, fileName, anchor, policy, electronDir, platform, arch, version, cacheOnly,
190
+ extract, verifyOutcome: () => verifyExtractOutcome({ electronDir, platform, fs }),
191
+ fs, spawn, env: process.env, log: stderrLog,
192
+ });
193
+ if (attempt.done) { return attempt.result; }
194
+ refusal = attempt.refusal;
253
195
  } else if (cacheOnly) {
254
196
  return { deferred: true, reason: `No cached electron zip found for v${version} (${platform}-${arch}); deferring download.${avHint(platform)}` };
255
197
  }
@@ -257,32 +199,75 @@ async function repairElectron({
257
199
  // Attempt 2 (online): CONTROLLED download+extract instead of a blind install.js
258
200
  // spawn — the SAME @electron/get api install.js uses, extracted offline, then the
259
201
  // REAL usability reported. A download that produced no usable exe is a FAILURE (#53).
260
- let controlledExtracted = false;
202
+ // A non-null `provision` means download + extract returned without throwing;
203
+ // its `pinned:false` means the extracted bytes were vouched for only by the
204
+ // mirror — either no `checksums` went out (no anchor row, or the hatch dropped
205
+ // the pin) or amicus's own hash of the bytes it read did not say `verified`.
206
+ // F3 marks that, as both docs already promise it does.
207
+ let provision = null;
208
+ let provisionReason = null;
261
209
  try {
262
210
  const downloadArtifact = await resolveDownloadArtifact();
263
- await controlledProvision({
264
- electronDir, platform, arch, version, anchor, downloadArtifact, extract, extractFromCache,
265
- fs, env: process.env, downloadMs: timeoutMs, policy, log: stderrLog,
211
+ const result = await controlledProvision({
212
+ electronDir, platform, arch, version, anchor, downloadArtifact, extract,
213
+ fs, spawn, env: process.env, downloadMs: timeoutMs, policy, log: stderrLog,
266
214
  });
267
- controlledExtracted = true; // download + extract returned without throwing
215
+ // F3 (seat B4) FAIL CLOSED ON A SHAPE NOBODY RECOGNISES. This used to be
216
+ // `|| { pinned: false }`, described as "an unrecognisable return marks,
217
+ // never claims a pin". It did worse than that: a function that returned
218
+ // NOTHING became a successful unpinned provision, so `verifyExtractOutcome`
219
+ // ran on a directory nothing had written and reported the missing exe as
220
+ // the AV-QUARANTINE signature — "electron.exe was removed right after it
221
+ // was extracted", advice about an extraction that never happened. An
222
+ // unrecognised return is a FAILURE, reported through the same path a throw
223
+ // takes, because that path already says the true thing.
224
+ if (!result || typeof result !== 'object' || typeof result.pinned !== 'boolean') {
225
+ throw Object.assign(
226
+ new Error(`the controlled provision returned no usable result (${result === undefined ? 'undefined' : typeof result})`),
227
+ { code: 'PROVISION_NO_RESULT' },
228
+ );
229
+ }
230
+ provision = result;
268
231
  } catch (provisionErr) {
269
- // C4 again: an unsafe archive here must NOT reach runInstaller, which would
270
- // re-download and re-extract it through an extractor amicus does not drive.
232
+ // C4: an unsafe archive is terminal it is never retried through another
233
+ // extractor, and it is not reported as an ordinary failure.
271
234
  if (isUnsafeArchive(provisionErr)) { return refuseUnsafeArchive({ err: provisionErr, fileName, log: stderrLog }); }
272
- // Controlled download/extract failed (network, checksum, unzip). Try the
273
- // installer as a LAST resort it can NEVER short-circuit the honest
274
- // verify below; we always return isElectronUsable().
275
- try { runInstaller({ electronDir, force, spawn, platform, arch }); } catch { /* ignore */ }
235
+ // B1: this is where the last-resort install.js spawn used to be. It is gone
236
+ // (see electron-provision.js), so the failure is REPORTED rather than
237
+ // routed around. `provision` stays null and `out.repaired` is the honest
238
+ // stat of the exe, but the REASON must survive a bare {repaired:false}
239
+ // is what made a failed provision indistinguishable from "not provisioned".
240
+ provisionReason = collapseExcerpt((provisionErr && provisionErr.message) || String(provisionErr));
241
+ stderrLog(`[amicus] the controlled Electron download did not complete: ${provisionReason}`);
242
+ stderrLog('[amicus] Headless runs and the council work without the GUI.');
243
+ }
244
+ // F#2: the download hashed the bytes it read and refused them, or could not
245
+ // read them at all. Nothing was extracted, so there is no outcome to verify —
246
+ // return the refusal, carrying any cache refusal that preceded it.
247
+ if (provision && provision.refused) {
248
+ // A4/D4: this used to drop the cache refusal whenever the two shared an
249
+ // `integrity` class — so TWO mismatches (a poisoned cache entry AND a
250
+ // hostile mirror, the single most alarming pair this code can observe)
251
+ // reported only the second, and never told the user the cached artifact had
252
+ // also been refused and possibly deleted. Dedupe on the SENTENCE instead:
253
+ // identical text is noise, a different path or a different digest is not.
254
+ const carried = refusal && refusal.reason !== provision.refused.reason ? refusal.reason : null;
255
+ return { ...provision.refused, reason: [carried, provision.refused.reason].filter(Boolean).join(' ') };
276
256
  }
277
257
  // A NON-throwing controlled extract that left no usable exe is the
278
258
  // AV-quarantine signature — surface it actionably (no false success, no loop).
279
- const out = controlledExtracted
259
+ const out = provision
280
260
  ? verifyExtractOutcome({ electronDir, platform, fs })
281
261
  : { repaired: isElectronUsable({ electronDir, platform, fs }) };
282
262
  // A refusal the download did not rescue must reach doctor and the postinstall
283
263
  // notice; plain {repaired:false} is what made a REFUSED artifact read as an
284
264
  // ordinary "not provisioned" everywhere outside the cacheOnly path.
285
- if (!out.repaired && refusal) { return { ...out, integrity: refusal.integrity, reason: [refusal.reason, out.reason].filter(Boolean).join(' ') }; }
265
+ if (!out.repaired && (refusal || provisionReason)) {
266
+ const parts = [refusal && refusal.reason, provisionReason && `The controlled download failed: ${provisionReason}.`, out.reason];
267
+ return { ...out, ...(refusal ? { integrity: refusal.integrity } : {}), reason: parts.filter(Boolean).join(' ') };
268
+ }
269
+ // F3: the SAME mark the cache route already applies, on the route that omitted it.
270
+ if (out.repaired && provision && !provision.pinned) { return { ...out, unverified: true }; }
286
271
  return out;
287
272
  } finally {
288
273
  try { lock.release(); } catch { /* ignore */ }
@@ -0,0 +1,300 @@
1
+ /**
2
+ * The on-disk LAYOUT of an installed `electron` package: where the executable
3
+ * lives, and how VERIFIED BYTES become a `dist/`.
4
+ *
5
+ * Layout (npm `electron`): `path.txt` -> the exe basename, `dist/<exe>` -> the
6
+ * binary. A promote writes `path.txt` FIRST and puts back what it overwrote on
7
+ * every failure exit, so one that fails breaks nothing that resolved (`promoteDist`).
8
+ *
9
+ * SPLIT OUT of electron-install.js (v4.9.6, second council round): that file is
10
+ * at the repo's 300-line gate, and the round's repairs had to land inside
11
+ * `repairElectron` itself. These functions answer "where does the package keep
12
+ * its exe", which is a different question from "how do I heal a broken install".
13
+ *
14
+ * WHAT CHANGED IN THE SECOND ROUND. `extractFromCache({zip, ...})` is GONE. It
15
+ * took a PATH, which is the whole finding: the bytes that were hashed and the
16
+ * bytes an extractor re-opens at a path are not the same bytes when the attacker
17
+ * shares our uid. Its replacement, `extractBytesToDist`, takes a BUFFER the
18
+ * caller already hashed and writes `dist/` by extract-into-incoming + promote
19
+ * (see `promoteDist`).
20
+ *
21
+ * NEAR-LEAF: `path` and `crypto`, plus the true leaf `./electron-exe-rel` (the
22
+ * one `path.txt` rule, shared with `resolveElectronBinary` since v4.9.7 — A1),
23
+ * with `fs` and the extractor injected by the caller — so electron-provision.js
24
+ * requires it too without any risk of a cycle. `platformExe` is re-exported
25
+ * from here because it was defined here through v4.9.6.
26
+ *
27
+ * @module sidecar/electron-layout
28
+ */
29
+
30
+ 'use strict';
31
+
32
+ const crypto = require('crypto');
33
+ const path = require('path');
34
+ const { platformExe, writePathTxt, distHeldExe } = require('./electron-exe-rel');
35
+
36
+ /**
37
+ * The two litter prefixes this module creates, and how long one may survive.
38
+ *
39
+ * WHY A SWEEPER EXISTS AT ALL. Both trees are removed in the happy path — the
40
+ * retired one right after the swap, the incoming one in `extractBytesToDist`'s
41
+ * `finally` — and the docs said "a killed run leaves nothing to sweep up". That
42
+ * was MEASURED FALSE twice. A `finally` does not run for a SIGKILL, a Ctrl-C
43
+ * during `npm install`, a laptop lid close or an AV kill: a child SIGKILLed
44
+ * mid-extract left `.amicus-incoming-<hex>` holding a partial tree, and a
45
+ * subsequent `extractBytesToDist` on the same electronDir did not remove it. And
46
+ * the retired tree leaks on its own path: on Windows 11 / NTFS with a process
47
+ * running from `dist\\electron.exe`, `renameSync(dist, retired)` SUCCEEDS and the
48
+ * follow-up `rmSync(retired)` fails EPERM with every entry still present — so
49
+ * any repair that runs while an Electron is live off that tree strands the whole
50
+ * previous ~350 MB dist, and the code comment said "swept next time" naming a
51
+ * sweep that did not exist.
52
+ *
53
+ * Unlike the `amicus-electron-stage-*` litter this design replaced, these live
54
+ * INSIDE the electron package directory, where no OS temp cleaner ever reaches
55
+ * them. Ten interrupted provisions on a CI box was ten abandoned trees with no
56
+ * code path that would ever remove them.
57
+ *
58
+ * THE AGE RULE, and why it is not zero. A provision holds the per-electronDir
59
+ * repair lock, so in production nothing else is mid-extract in this directory —
60
+ * but `promoteDist` and `extractBytesToDist` are callable without that lock, and
61
+ * deleting a tree another process is actively writing is a worse failure than
62
+ * leaving one behind. So the sweep takes only what is older than
63
+ * `LITTER_MAX_AGE_MS`, which is the rule the deleted `sweepStaleStages` used and
64
+ * the rule the docs stated honestly before this design replaced them: the next
65
+ * provision sweeps any that is more than a day old.
66
+ */
67
+ const LITTER_PREFIXES = ['.amicus-incoming-', '.amicus-retired-'];
68
+ const LITTER_MAX_AGE_MS = 24 * 60 * 60 * 1000;
69
+
70
+ /**
71
+ * Remove abandoned incoming/retired trees from `electronDir`. BEST-EFFORT: a
72
+ * tree that cannot be stat'ed or removed is left for the next run, and nothing
73
+ * here can fail a provision.
74
+ *
75
+ * @param {object} o
76
+ * @param {string} o.electronDir
77
+ * @param {object} o.fs
78
+ * @param {number} [o.maxAgeMs]
79
+ * @param {number} [o.now]
80
+ * @param {string} [o.keep] an absolute path never to remove (this run's own)
81
+ * @returns {string[]} the names actually removed
82
+ */
83
+ function sweepPromoteLitter({
84
+ electronDir, fs, maxAgeMs = LITTER_MAX_AGE_MS, now = Date.now(), keep = null,
85
+ }) {
86
+ let names;
87
+ try {
88
+ names = fs.readdirSync(electronDir);
89
+ } catch {
90
+ return [];
91
+ }
92
+ const swept = [];
93
+ for (const name of names) {
94
+ if (!LITTER_PREFIXES.some((p) => name.startsWith(p))) { continue; }
95
+ const full = path.join(electronDir, name);
96
+ if (keep && full === keep) { continue; }
97
+ try {
98
+ if (now - fs.statSync(full).mtimeMs < maxAgeMs) { continue; }
99
+ fs.rmSync(full, { recursive: true, force: true });
100
+ swept.push(name);
101
+ } catch { /* a tree we cannot stat or remove waits for the next run */ }
102
+ }
103
+ return swept;
104
+ }
105
+
106
+ /**
107
+ * RETIRE AND SWAP. Move a freshly-extracted tree into place as `dist/`, and
108
+ * leave the previous one recoverable until the swap has actually happened.
109
+ *
110
+ * WHY NOT EXTRACT STRAIGHT INTO `dist/`, which is what every earlier cut did.
111
+ * Because a half-written `dist/` IS a shape the rest of the self-heal cluster
112
+ * reads as an install: `isElectronUsable` stats the exe, `verifyExtractOutcome`
113
+ * calls a non-throwing extract with no exe the AV-quarantine signature. A kill
114
+ * or an ENOSPC halfway through a 347 MB write used to leave exactly that, on top
115
+ * of whatever was there before. Extraction now lands in
116
+ * `<electronDir>/.amicus-incoming-<hex>/dist`, so a partial tree is never in the
117
+ * place anything looks, and `dist/` changes in ONE rename.
118
+ *
119
+ * ORDER, and what survives each failure:
120
+ * 0. write `path.txt` unless already right — a throw REFUSES, and puts it back
121
+ * 1. rename `dist` -> `.amicus-retired-<hex>` — old tree still whole, elsewhere
122
+ * 2. rename `<incoming>/dist` -> `dist` — the swap
123
+ * 3. on a step-2 failure, rename the retired tree BACK (rollback)
124
+ * 4. delete the retired tree, then the incoming directory (best-effort)
125
+ *
126
+ * WHAT A STEP-1 FAILURE MAY DO, AND THE CLAIM THAT WAS MEASURED FALSE. Step 1
127
+ * failing means the old tree cannot be MOVED (a Windows handle held on the live
128
+ * tree; an AV filter driver denying MoveFile on a tree holding a freshly written
129
+ * electron.exe — this module's most-documented field failure). This used to fall
130
+ * back to `rmSync(distDir)`, which is IRREVERSIBLE and has no rollback, while
131
+ * the docblock asserted "No exit path can leave the user with neither the old
132
+ * tree nor the new one".
133
+ *
134
+ * MEASURED FALSE (injected fs, every `renameSync` throwing EPERM, real
135
+ * `rmSync`): the catch deleted the working tree, `retiredExists` stayed false,
136
+ * the step-2 rename then failed with NO rollback, and `extractBytesToDist`'s
137
+ * `finally` deleted the new tree immediately afterwards —
138
+ * `{"threw":"EPERM","distExists":false,"userHasOldTree":false}`. A user who had
139
+ * a working GUI was left with an electron package holding no `dist/` at all,
140
+ * and the caller then downloaded 138 MB and repeated the same promote.
141
+ *
142
+ * So the in-place removal now happens ONLY when there is nothing to lose: a
143
+ * `dist/` that holds NEITHER the exe `path.txt` names NOR `platformExe` is not
144
+ * an install, and destroying it costs the user nothing they had. When the old
145
+ * tree DOES hold an executable, the promote REFUSES and that tree is untouched —
146
+ * the repair fails, which is strictly better than a working GUI becoming no GUI.
147
+ *
148
+ * THE GUARANTEE, stated so it is checkable:
149
+ * **A promote never removes a `dist/` that HELD an executable — under the name
150
+ * `path.txt` gives it, or `platformExe` when `path.txt` is absent, unreadable
151
+ * or blank — unless the new tree is already in its place.**
152
+ * "HELD" means a FILE INSIDE `dist/`; `electron-exe-rel.js :: distHeldExe` owns
153
+ * that rule and carries what each of its bounds was measured to cost (A1).
154
+ * The one exit that can still leave a user without a usable `dist/` is both
155
+ * renames failing after step 1 SUCCEEDED. The old tree is then whole and
156
+ * undeleted at `.amicus-retired-<hex>`, and the thrown message names it so the
157
+ * user can rename it back.
158
+ *
159
+ * WHICH VALUE THE GUARD READS (A1): `raw`, captured BEFORE step 0 — not
160
+ * `replaced`, and never a re-read. `electron-exe-rel.js` carries why, the
161
+ * `ELECTRON_OVERRIDE_DIST_PATH` ruling, and the measurements behind both.
162
+ *
163
+ * `path.txt` IS WRITTEN FIRST (B2). Writing it LAST made "dist but no path.txt"
164
+ * unobservable only while that write SUCCEEDED, and it ran after the old tree was
165
+ * retired AND DELETED, so one ENOSPC/EPERM/AV-locked 12-byte write left a `dist/`
166
+ * that `electron/index.js` cannot resolve — amicus's own resolver falls back to
167
+ * `platformExe`, that entry point does not. RULING on undoing the pre-write:
168
+ * usually nothing to undo — the old tree resolved through that same string, and an
169
+ * absent or EMPTY `path.txt` resolves through it in both resolvers — but it BREAKS
170
+ * on one naming a DIFFERENT basename (`npm_config_platform` cross-installs one).
171
+ * So step 0 sits INSIDE the same `try` as the swap, and its own refusal puts the
172
+ * value back too: the write TRUNCATES at open (MEASURED — a real 12-byte path.txt
173
+ * is 0 bytes after `openSync(p,'w')`, before any write can fail), so a refusal
174
+ * that skipped the put-back destroyed the value it existed to keep. Best-effort:
175
+ * a put-back that itself fails, or an UNREADABLE path.txt, loses the old basename.
176
+ * @param {object} o
177
+ * @param {string} o.electronDir
178
+ * @param {string} o.incomingDist the extracted tree to promote
179
+ * @param {string} o.platform
180
+ * @param {object} o.fs
181
+ */
182
+ function promoteDist({ electronDir, incomingDist, platform, fs }) {
183
+ const distDir = path.join(electronDir, 'dist');
184
+ const pathFile = path.join(electronDir, 'path.txt');
185
+ let raw = null; // path.txt BEFORE step 0 overwrites it
186
+ let unreadable = false; // a read that failed for a reason other than ENOENT
187
+ try { raw = fs.readFileSync(pathFile, 'utf8'); } catch (e) { unreadable = !e || e.code !== 'ENOENT'; }
188
+ let replaced = raw; // step 0's overwritten DIFFERENT value
189
+ try {
190
+ if (replaced === platformExe(platform)) { replaced = null; } else {
191
+ try { writePathTxt({ electronDir, platform, fs }); } catch (e) {
192
+ throw new Error(`${(e && e.message) || e} — path.txt could not be written, so the promote was refused and dist/ is exactly as it was`);
193
+ }
194
+ }
195
+ const retired = path.join(electronDir, `.amicus-retired-${crypto.randomBytes(6).toString('hex')}`);
196
+ let retiredExists = false;
197
+ if (fs.existsSync(distDir)) {
198
+ try {
199
+ fs.renameSync(distDir, retired);
200
+ retiredExists = true;
201
+ } catch (e) {
202
+ // The old tree cannot be moved. Removing it in place is irreversible, so
203
+ // it is allowed only when the tree is an install under NEITHER name (A1).
204
+ if (unreadable) {
205
+ throw new Error(`${(e && e.message) || e} — path.txt could not be read, so which exe `
206
+ + 'dist/ holds is unknown and it was left exactly as it was');
207
+ }
208
+ const held = distHeldExe({ distDir, raw, platform, fs });
209
+ if (held) {
210
+ throw new Error(`${(e && e.message) || e} — the existing dist/ holds a usable `
211
+ + `${held} and was left exactly as it was`);
212
+ }
213
+ fs.rmSync(distDir, { recursive: true, force: true });
214
+ }
215
+ }
216
+ try {
217
+ fs.renameSync(incomingDist, distDir);
218
+ } catch (e) {
219
+ if (retiredExists) {
220
+ try {
221
+ fs.renameSync(retired, distDir);
222
+ } catch {
223
+ // Both renames failed. The retired tree is WHOLE and is NOT deleted —
224
+ // say where it is, because this is the only exit that leaves a user
225
+ // without the dist/ they had.
226
+ throw new Error(`${(e && e.message) || e} — the previous dist/ is intact at `
227
+ + `${path.basename(retired)}; rename it back to dist/ to restore it`);
228
+ }
229
+ }
230
+ throw e;
231
+ }
232
+ if (retiredExists) { try { fs.rmSync(retired, { recursive: true, force: true }); } catch { /* sweepPromoteLitter takes it */ } }
233
+ } catch (e) { // EVERY exit undoes step 0 (see the RULING)
234
+ if (replaced !== null) { try { fs.writeFileSync(pathFile, replaced); } catch { /* best-effort; the tree is what matters */ } }
235
+ throw e;
236
+ }
237
+ }
238
+
239
+ /**
240
+ * A failure that is about the DESTINATION, not the archive.
241
+ *
242
+ * The distinction is load-bearing, and getting it wrong here was MEASURED on
243
+ * this very function: an EPERM from `renameSync` (a Windows handle held on the
244
+ * live `dist/`) propagated out untagged, `repairFromCache` read "an extract
245
+ * failure I cannot classify" as "the archive is bad", and DELETED the user's
246
+ * cached artifact while telling them it "was corrupt and removed". The archive
247
+ * was fine; the promote was not. Council finding D2 is the same shape one
248
+ * function away, and this is the second place it could bite.
249
+ */
250
+ function destinationFailure(e, what) {
251
+ const err = new Error(`could not ${what}: ${(e && e.message) || e}`);
252
+ err.code = 'UNZIP_DEST_FAILED';
253
+ return err;
254
+ }
255
+
256
+ /**
257
+ * Turn VERIFIED BYTES into `<electronDir>/dist`, offline.
258
+ *
259
+ * `bytes` is a Buffer whose sha256 the caller has ALREADY matched against the
260
+ * anchor. There is no `zip` parameter and no path to the artifact anywhere in
261
+ * this call, which is the property the whole change exists to establish:
262
+ * **amicus never itself writes bytes it did not hash.**
263
+ *
264
+ * `extract` is injected — `zip-from-buffer.extractZipBuffer` in production —
265
+ * and receives the Buffer, never a name.
266
+ *
267
+ * It also SWEEPS, first: the incoming tree is removed in a `finally`, which a
268
+ * kill does not run, and the retired tree's removal can fail EPERM while an
269
+ * Electron is live off it. Both leak inside the electron package directory,
270
+ * where no OS temp cleaner reaches them. See `sweepPromoteLitter`.
271
+ * @returns {Promise<void>}
272
+ */
273
+ async function extractBytesToDist({ bytes, electronDir, platform, extract, fs }) {
274
+ const incoming = path.join(electronDir, `.amicus-incoming-${crypto.randomBytes(6).toString('hex')}`);
275
+ const incomingDist = path.join(incoming, 'dist');
276
+ // A killed run's `finally` never ran, and an EPERM `rmSync` of a retired tree
277
+ // never finished. Take what they left before adding one more.
278
+ sweepPromoteLitter({ electronDir, fs, keep: incoming });
279
+ try {
280
+ try {
281
+ fs.mkdirSync(incomingDist, { recursive: true });
282
+ } catch (e) {
283
+ throw destinationFailure(e, `create ${incomingDist}`);
284
+ }
285
+ // NOT wrapped: the extractor classifies its own failures, and an archive
286
+ // failure must keep saying so — it is the one thing that may evict.
287
+ await extract(bytes, { dir: incomingDist });
288
+ try {
289
+ promoteDist({ electronDir, incomingDist, platform, fs });
290
+ } catch (e) {
291
+ throw destinationFailure(e, 'promote the extracted tree into dist/');
292
+ }
293
+ } finally {
294
+ try { fs.rmSync(incoming, { recursive: true, force: true }); } catch { /* litter, not a failure */ }
295
+ }
296
+ }
297
+
298
+ module.exports = {
299
+ platformExe, writePathTxt, promoteDist, extractBytesToDist, sweepPromoteLitter, LITTER_MAX_AGE_MS,
300
+ };