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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +86 -0
- package/README.md +1 -1
- package/docs/ROADMAP.md +4 -4
- package/docs/architecture-map.md +31 -7
- package/docs/configuration.md +91 -11
- package/docs/troubleshooting.md +96 -3
- package/docs/usage.md +1 -1
- package/package.json +2 -1
- package/scripts/postinstall.js +24 -3
- package/src/sidecar/electron-cache.js +41 -1
- package/src/sidecar/electron-custody.js +180 -0
- package/src/sidecar/electron-ensure.js +25 -4
- package/src/sidecar/electron-env-scrub.js +233 -0
- package/src/sidecar/electron-install.js +115 -125
- package/src/sidecar/electron-layout.js +300 -0
- package/src/sidecar/electron-native-plan.js +157 -0
- package/src/sidecar/electron-native-rescue.js +231 -0
- package/src/sidecar/electron-provision.js +148 -95
- package/src/sidecar/electron-refuse.js +253 -0
- package/src/sidecar/electron-repair-cache.js +212 -0
- package/src/sidecar/electron-rescue-notice.js +78 -0
- package/src/sidecar/electron-trust.js +45 -118
- package/src/sidecar/unzip.js +13 -3
- package/src/sidecar/zip-entry-write.js +268 -0
- package/src/sidecar/zip-from-buffer.js +220 -0
- package/src/sidecar/zip-name-scan.js +141 -0
- package/src/sidecar/zip-stall-bound.js +144 -0
- package/src/utils/doctor-electron-mcp-check.js +9 -2
|
@@ -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 {
|
|
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 {
|
|
28
|
-
const {
|
|
29
|
-
const {
|
|
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 {
|
|
168
|
-
* @param {
|
|
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:
|
|
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
|
-
|| ((
|
|
187
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
270
|
-
//
|
|
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
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
|
|
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 =
|
|
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
|
|
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
|
+
};
|