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