amicus 4.9.4 → 4.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,33 +1,44 @@
1
1
  /**
2
2
  * Electron self-heal primitive (#53, #59).
3
3
  *
4
- * Electron is an optionalDependency (^28.0.0). A flaky / interrupted extract
5
- * or Windows Defender quarantining electron.exe — can leave the package's
6
- * path.txt on disk while dist/<exe> is MISSING, so the GUI silently fails.
4
+ * Electron is an optionalDependency (^28.0.0). A flaky / interrupted extract
5
+ * or Windows Defender quarantining electron.exe — can leave the package's
6
+ * path.txt on disk while dist/<exe> is MISSING, so the GUI silently fails. This
7
+ * module is the keystone the rest of the self-heal cluster (#54-#57) imports; it
8
+ * wires itself into no caller, and everything that downloads, extracts, spawns or
9
+ * locks is dependency-INJECTABLE so tests never hit the network or extract a real
10
+ * binary.
7
11
  *
8
- * This module is the keystone the rest of the self-heal cluster (#54-#57)
9
- * imports. It does NOT wire itself into any caller. Everything that downloads,
10
- * extracts, spawns, or locks is dependency-INJECTABLE so tests never hit the
11
- * network or extract a real binary.
12
+ * Layout (npm `electron`): path.txt -> the exe basename, dist/<exe> -> the binary.
13
+ * #59: ELECTRON_OVERRIDE_DIST_PATH moves the exe to <override>/<exe> (mirrors
14
+ * electron/index.js + install.js semantics). Cache layout (@electron/get):
15
+ * <cacheRoot>/<sha256>/electron-v<ver>-<platform>-<arch>.zip
12
16
  *
13
- * Layout reference (npm `electron` package):
14
- * node_modules/electron/path.txt -> "electron.exe" (the exe basename)
15
- * node_modules/electron/dist/<exe> -> the actual binary
16
- * #59: when ELECTRON_OVERRIDE_DIST_PATH is set, the exe lives in that dir
17
- * instead of <pkg>/dist (mirrors electron/index.js + install.js semantics).
18
- * Cache layout (@electron/get): <cacheRoot>/<sha256>/electron-v<ver>-<platform>-<arch>.zip
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.
19
24
  */
20
25
 
21
26
  'use strict';
22
27
 
23
28
  const fsDefault = require('fs');
24
29
  const path = require('path');
25
- const { spawnSync } = require('child_process');
26
30
 
27
- const { resolveCacheRoots } = require('./electron-cache');
31
+ const { cachedZip } = require('./electron-cache');
32
+ const { isSafeArtifactName } = require('./electron-custody');
28
33
  const { avHint, verifyExtractOutcome: verifyQuarantine } = require('./electron-quarantine');
29
34
  const { acquireRepairLock } = require('./electron-lock');
30
- 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');
31
42
 
32
43
  /** Self-heal progress line to stderr (visible during first-GUI provision). */
33
44
  function stderrLog(msg) {
@@ -43,19 +54,6 @@ function defaultElectronDir() {
43
54
  }
44
55
  }
45
56
 
46
- /** Platform exe basename, matching electron's getPlatformPath(). */
47
- function platformExe(platform) {
48
- switch (platform) {
49
- case 'mas':
50
- case 'darwin':
51
- return path.join('Electron.app', 'Contents', 'MacOS', 'Electron');
52
- case 'win32':
53
- return 'electron.exe';
54
- default:
55
- return 'electron';
56
- }
57
- }
58
-
59
57
  /**
60
58
  * Resolve the on-disk electron exe path from the package layout.
61
59
  * Mirrors ELECTRON_OVERRIDE_DIST_PATH semantics (#59): when set, the exe is
@@ -93,73 +91,6 @@ function isElectronUsable({ electronDir = defaultElectronDir(), env = process.en
93
91
  }
94
92
  }
95
93
 
96
- /**
97
- * Locate a previously-downloaded electron zip in the env-configurable cache
98
- * roots. Walks <root>/<sha>/electron-v<ver>-<platform>-<arch>.zip.
99
- * @returns {string|null} absolute zip path, or null when no cache hit.
100
- */
101
- function cachedZip({ version, platform = process.platform, arch = process.arch, env = process.env, fs = fsDefault } = {}) {
102
- const zipName = `electron-v${version}-${platform}-${arch}.zip`;
103
- for (const root of resolveCacheRoots(env)) {
104
- let shaDirs;
105
- try {
106
- shaDirs = fs.readdirSync(root);
107
- } catch {
108
- continue;
109
- }
110
- for (const sha of shaDirs) {
111
- const candidate = path.join(root, sha, zipName);
112
- try {
113
- if (fs.existsSync(candidate)) {
114
- return candidate;
115
- }
116
- } catch {
117
- /* ignore unreadable subdir */
118
- }
119
- }
120
- }
121
- return null;
122
- }
123
-
124
- /** Restore path.txt so electron/index.js resolves the freshly-extracted exe. */
125
- function writePathTxt({ electronDir, platform, fs }) {
126
- fs.writeFileSync(path.join(electronDir, 'path.txt'), platformExe(platform));
127
- }
128
-
129
- /** Extract a cached zip into <electronDir>/dist offline. */
130
- async function extractFromCache({ zip, electronDir, platform, extract, fs }) {
131
- const distDir = path.join(electronDir, 'dist');
132
- fs.mkdirSync(distDir, { recursive: true });
133
- await extract(zip, { dir: distDir });
134
- writePathTxt({ electronDir, platform, fs });
135
- }
136
-
137
- /** Best-effort cache root for downloadArtifact (first resolved root). */
138
- function cacheRootFor(env = process.env) {
139
- return resolveCacheRoots(env)[0];
140
- }
141
-
142
- /**
143
- * CONTROLLED provision: fetch the zip ourselves with the SAME @electron/get
144
- * api install.js uses (downloadArtifact, force:true), extract offline, and let
145
- * the caller verify isElectronUsable(). No blind install.js spawn.
146
- * @returns {Promise<void>}
147
- */
148
- async function controlledProvision({
149
- electronDir, platform, arch, version, downloadArtifact, extract, fs, env = process.env, downloadMs = 480000,
150
- }) {
151
- const zip = await downloadArtifact({
152
- version,
153
- artifactName: 'electron',
154
- force: true,
155
- cacheRoot: cacheRootFor(env),
156
- platform,
157
- arch,
158
- downloadOptions: { signal: AbortSignal.timeout(downloadMs) }, // 5.x native fetch: bound stalled downloads, free the lock
159
- });
160
- await extractFromCache({ zip, electronDir, platform, extract, fs });
161
- }
162
-
163
94
  /** Bind the fs-aware probes for the post-extract AV-quarantine verify. */
164
95
  function verifyExtractOutcome({ electronDir, platform, fs }) {
165
96
  return verifyQuarantine({
@@ -169,30 +100,25 @@ function verifyExtractOutcome({ electronDir, platform, fs }) {
169
100
  });
170
101
  }
171
102
 
172
- /** Drive electron's own install.js with force_no_cache semantics. */
173
- function runInstaller({ electronDir, force, spawn }) {
174
- const installScript = path.join(electronDir, 'install.js');
175
- const env = { ...process.env };
176
- if (force) {
177
- env.force_no_cache = 'true';
178
- }
179
- return spawn(process.execPath, [installScript], { env, stdio: 'ignore' });
180
- }
181
-
182
103
  /**
183
104
  * Heal a broken electron install.
184
105
  *
185
106
  * @param {object} opts
186
107
  * @param {boolean} [opts.cacheOnly] never hit the network; return
187
108
  * {deferred,reason} when there is no cached zip.
188
- * @param {boolean} [opts.force] force a fresh (no-cache) installer download.
189
- * @param {number} [opts.timeoutMs] best-effort installer timeout.
190
- * @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,
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.
191
118
  * @returns {Promise<{repaired?:boolean, deferred?:boolean, contended?:boolean, reason?:string}>}
192
119
  */
193
120
  async function repairElectron({
194
121
  cacheOnly = false,
195
- force = false,
196
122
  timeoutMs,
197
123
  electronDir = defaultElectronDir(),
198
124
  platform = process.platform,
@@ -201,13 +127,22 @@ async function repairElectron({
201
127
  deps = {},
202
128
  } = {}) {
203
129
  const fs = deps.fs || fsDefault;
204
- // 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.
205
143
  const extract = deps.extract
206
- || ((zipPath, o) => robustExtract(zipPath, { ...o, platform, deps: { fs, log: stderrLog } }));
207
- // Default-bound the last-resort installer spawn (8 min) so a first-GUI-use
208
- // provision that reaches runInstaller without an explicit timeoutMs can't hang
209
- // the holder — the caller's timeoutMs still wins when provided.
210
- 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;
211
146
  const findZip = deps.cachedZip || ((o) => cachedZip(o));
212
147
  const acquireLock = deps.acquireLock || ((o) => acquireRepairLock({ ...o, fs }));
213
148
  // Lazy: import the ESM-only @electron/get only on the network path, so cacheOnly
@@ -217,13 +152,26 @@ async function repairElectron({
217
152
  : async () => (await import('@electron/get')).downloadArtifact;
218
153
 
219
154
  if (!version) {
220
- try {
221
- version = require(path.join(electronDir, 'package.json')).version;
222
- } catch {
223
- version = undefined;
224
- }
155
+ try { version = require(path.join(electronDir, 'package.json')).version; } catch { version = undefined; }
225
156
  }
226
157
 
158
+ // The digest anchor and the trust policy, resolved ONCE for both routes. NOTE
159
+ // `version` is deliberately NOT passed to resolveAnchor: it may have just been
160
+ // read out of electronDir's own package.json above, and letting an untrusted
161
+ // directory pick which anchor judges its bytes is the ANCHORFROMTARGET hole.
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
+ }
172
+ const policy = electronTrustPolicy(process.env);
173
+ const anchor = resolveAnchor({ electronDir, fs, selfElectronDir: deps.selfElectronDir });
174
+
227
175
  // Single-flight: bail out gracefully if another caller is already repairing.
228
176
  let lock;
229
177
  try {
@@ -235,55 +183,97 @@ async function repairElectron({
235
183
  throw e;
236
184
  }
237
185
 
186
+ let refusal = null; // a cache refusal the caller must still hear about if the download also fails
238
187
  try {
239
- // 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.
240
191
  const zip = findZip({ version, platform, arch, env: process.env, fs });
241
192
  if (zip) {
242
- try {
243
- await extractFromCache({ zip, electronDir, platform, extract, fs });
244
- // Non-throwing extract w/ absent exe = the AV-quarantine signature.
245
- return verifyExtractOutcome({ electronDir, platform, fs });
246
- } catch (extractErr) {
247
- // Corrupt cached artifact: delete the bad zip so it can't poison the
248
- // cache, then fall through to a forced fresh download (unless offline).
249
- try { fs.rmSync(zip, { force: true }); } catch { /* ignore */ }
250
- if (cacheOnly) {
251
- return {
252
- repaired: false,
253
- reason: `Cached electron zip for v${version} (${platform}-${arch}) was corrupt and removed; deferring re-download.${avHint(platform)}`,
254
- };
255
- }
256
- // else: drop into the controlled download below.
257
- }
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;
258
200
  } else if (cacheOnly) {
259
- return {
260
- deferred: true,
261
- reason: `No cached electron zip found for v${version} (${platform}-${arch}); deferring download.${avHint(platform)}`,
262
- };
201
+ return { deferred: true, reason: `No cached electron zip found for v${version} (${platform}-${arch}); deferring download.${avHint(platform)}` };
263
202
  }
264
203
 
265
- // Attempt 2 (online): CONTROLLED download+extract instead of a blind
266
- // install.js spawn — fetch via the SAME @electron/get api install.js uses,
267
- // extract offline, then report the REAL usability. A structurally-successful
268
- // download that produced no usable exe is a FAILURE (no false success; #53).
269
- let controlledExtracted = false;
204
+ // Attempt 2 (online): CONTROLLED download+extract instead of a blind install.js
205
+ // spawn — the SAME @electron/get api install.js uses, extracted offline, then the
206
+ // REAL usability reported. A download that produced no usable exe is a FAILURE (#53).
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;
270
214
  try {
271
215
  const downloadArtifact = await resolveDownloadArtifact();
272
- await controlledProvision({
273
- electronDir, platform, arch, version, downloadArtifact, extract, fs, env: process.env, downloadMs: timeoutMs,
216
+ const result = await controlledProvision({
217
+ electronDir, platform, arch, version, anchor, downloadArtifact, extract,
218
+ fs, spawn, env: process.env, downloadMs: timeoutMs, policy, log: stderrLog,
274
219
  });
275
- controlledExtracted = true; // download + extract returned without throwing
276
- } catch {
277
- // Controlled download/extract failed (network, checksum, unzip). Try the
278
- // installer as a LAST resort it can NEVER short-circuit the honest
279
- // verify below; we always return isElectronUsable().
280
- try { runInstaller({ electronDir, force, spawn }); } catch { /* ignore */ }
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;
236
+ } catch (provisionErr) {
237
+ // C4: an unsafe archive is terminal — it is never retried through another
238
+ // extractor, and it is not reported as an ordinary failure.
239
+ if (isUnsafeArchive(provisionErr)) { return refuseUnsafeArchive({ err: provisionErr, fileName, log: stderrLog }); }
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(' ') };
281
261
  }
282
262
  // A NON-throwing controlled extract that left no usable exe is the
283
- // AV-quarantine signature — surface it actionably (no false success, no
284
- // loop). A controlled FAILURE only reports plain repaired:false.
285
- if (controlledExtracted) { return verifyExtractOutcome({ electronDir, platform, fs }); }
286
- return { repaired: isElectronUsable({ electronDir, platform, fs }) };
263
+ // AV-quarantine signature — surface it actionably (no false success, no loop).
264
+ const out = provision
265
+ ? verifyExtractOutcome({ electronDir, platform, fs })
266
+ : { repaired: isElectronUsable({ electronDir, platform, fs }) };
267
+ // A refusal the download did not rescue must reach doctor and the postinstall
268
+ // notice; plain {repaired:false} is what made a REFUSED artifact read as an
269
+ // ordinary "not provisioned" everywhere outside the cacheOnly path.
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 }; }
276
+ return out;
287
277
  } finally {
288
278
  try { lock.release(); } catch { /* ignore */ }
289
279
  }