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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +171 -3
- package/README.md +1 -1
- package/docs/ROADMAP.md +6 -4
- package/docs/architecture-map.md +31 -3
- package/docs/configuration.md +97 -0
- package/docs/troubleshooting.md +130 -0
- package/docs/usage.md +1 -1
- package/package.json +2 -1
- package/scripts/postinstall.js +28 -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 +149 -159
- 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 +232 -0
- 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 +226 -0
- package/src/sidecar/unzip.js +52 -2
- 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
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE MECHANICS OF A RESCUE: write the verified buffer down, walk the platform's
|
|
3
|
+
* native plan against it, and leave the extracted tree where the promote will
|
|
4
|
+
* find it. Nothing here decides WHETHER a rescue may happen — that is
|
|
5
|
+
* `./electron-native-rescue`, which is the only caller.
|
|
6
|
+
*
|
|
7
|
+
* SPLIT OUT of that module (v4.9.6, C2 round 4) when the trigger boundary grew
|
|
8
|
+
* the entry-name scan and the file passed the repo's 300-line gate. The seam is
|
|
9
|
+
* the one its own docblock names: "THE TRIGGER BOUNDARY, WHICH MATTERS MORE THAN
|
|
10
|
+
* THE MECHANISM". The boundary stayed; the mechanism moved here.
|
|
11
|
+
*
|
|
12
|
+
* WHAT IT COSTS, once, so it is not restated in every function: the rescue
|
|
13
|
+
* writes bytes amicus hashed to a path and hands that path to a child process.
|
|
14
|
+
* Between those two moments a same-user writer can substitute the file, and what
|
|
15
|
+
* the child extracts is promoted WITHOUT being hashed again. `announceNativeRescue`
|
|
16
|
+
* says exactly that on stderr before the first spawn, and the caller marks the
|
|
17
|
+
* result unverified. It is the trade the hatch buys, not a safe operation.
|
|
18
|
+
*
|
|
19
|
+
* NEAR-LEAF: `./unzip` (the plan and the cap, byte-for-byte unchanged),
|
|
20
|
+
* `./electron-rescue-notice`, `./electron-refuse` and `../utils/text-sanitize`.
|
|
21
|
+
* Nothing requires it back.
|
|
22
|
+
*
|
|
23
|
+
* @module sidecar/electron-native-plan
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
const path = require('path');
|
|
29
|
+
|
|
30
|
+
// The plan and the cap come from `unzip.js`, which is byte-for-byte unchanged:
|
|
31
|
+
// this is a re-wiring of a caller, not a change to that module.
|
|
32
|
+
const { nativeUnzipPlan } = require('./unzip');
|
|
33
|
+
const { PATH_EXCERPT_CHARS } = require('./electron-refuse');
|
|
34
|
+
const { announceNativeRescue } = require('./electron-rescue-notice');
|
|
35
|
+
const { collapseExcerpt } = require('../utils/text-sanitize');
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The directory `electron-layout.js :: extractBytesToDist` extracts into is
|
|
39
|
+
* `<electronDir>/.amicus-incoming-<hex>/dist`, so its PARENT is the private
|
|
40
|
+
* incoming tree that the promote renames out of and the `finally` deletes. The
|
|
41
|
+
* rescue writes its zip there — beside `dist`, never inside it, because
|
|
42
|
+
* `promoteDist` renames the whole `dist` directory into place and would carry a
|
|
43
|
+
* 138 MB stray zip with it.
|
|
44
|
+
*
|
|
45
|
+
* CHECKED, NOT ASSUMED. Deriving a write location from a caller's argument is
|
|
46
|
+
* how a stray file lands somewhere nobody expected, so the prefix is verified
|
|
47
|
+
* before a byte is written and the rescue refuses otherwise. The coupling is
|
|
48
|
+
* also pinned end-to-end by tests/electron-native-rescue.test.js, which runs a
|
|
49
|
+
* real `extractBytesToDist` and asserts the zip landed in the incoming tree.
|
|
50
|
+
*/
|
|
51
|
+
const INCOMING_PREFIX = '.amicus-incoming-';
|
|
52
|
+
|
|
53
|
+
/** The name the rescue writes the verified buffer under, inside that tree. */
|
|
54
|
+
const RESCUE_ZIP = 'rescue-artifact.zip';
|
|
55
|
+
/** True if `dir` exists and holds at least one entry (unzip.js's layer 3). */
|
|
56
|
+
function dirNonEmpty(fs, dir) {
|
|
57
|
+
try {
|
|
58
|
+
return fs.readdirSync(dir).length > 0;
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Remove everything inside `dir` (best-effort).
|
|
66
|
+
*
|
|
67
|
+
* LOAD-BEARING, not tidiness: the extractor that just failed may have left a
|
|
68
|
+
* PARTIAL tree there, and `dirNonEmpty` would then read those leftovers as a
|
|
69
|
+
* successful rescue and promote them into `dist/`. unzip.js cleans for the same
|
|
70
|
+
* reason before its own native strategies.
|
|
71
|
+
*/
|
|
72
|
+
function cleanDir(fs, dir) {
|
|
73
|
+
try {
|
|
74
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
75
|
+
fs.rmSync(path.join(dir, entry), { recursive: true, force: true });
|
|
76
|
+
}
|
|
77
|
+
} catch { /* best-effort */ }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Walk the platform's native plan until one strategy leaves files in `dir`.
|
|
82
|
+
*
|
|
83
|
+
* The verdicts are unzip.js's, because they were right there: a spawn error or
|
|
84
|
+
* an external signal-kill (`status: null` — SIGKILL, an OOM) is a FAILURE even
|
|
85
|
+
* if files landed, a non-zero exit is a failure, and a clean exit that produced
|
|
86
|
+
* nothing is a failure. Every failure cleans up after itself so the next
|
|
87
|
+
* strategy starts from an empty directory.
|
|
88
|
+
* @returns {string|null} the strategy name that worked, or null
|
|
89
|
+
*/
|
|
90
|
+
function runNativePlan({ zip, dir, platform, fs, spawn, maxMs, log }) {
|
|
91
|
+
const failures = [];
|
|
92
|
+
for (const strat of nativeUnzipPlan(zip, dir, platform)) {
|
|
93
|
+
let res;
|
|
94
|
+
try {
|
|
95
|
+
res = spawn(strat.cmd, strat.args, { stdio: 'ignore', windowsHide: true, timeout: maxMs });
|
|
96
|
+
} catch (e) {
|
|
97
|
+
failures.push(`${strat.name}: spawn ${(e && e.code) || (e && e.message) || 'threw'}`);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (res && (res.error || res.signal)) {
|
|
101
|
+
failures.push(`${strat.name}: ${res.error ? (res.error.code || res.error.message) : `killed by ${res.signal}`}`);
|
|
102
|
+
} else if (res && typeof res.status === 'number' && res.status !== 0) {
|
|
103
|
+
failures.push(`${strat.name}: exit ${res.status}`);
|
|
104
|
+
} else if (dirNonEmpty(fs, dir)) {
|
|
105
|
+
return strat.name;
|
|
106
|
+
} else {
|
|
107
|
+
failures.push(`${strat.name}: produced no files`);
|
|
108
|
+
}
|
|
109
|
+
cleanDir(fs, dir);
|
|
110
|
+
}
|
|
111
|
+
log(`[amicus] the native-extractor rescue did not recover this archive (${collapseExcerpt(failures.join('; ') || 'no native strategy available')}).`);
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Write the verified buffer beside the incoming `dist`, hand that path to the
|
|
117
|
+
* native plan, and leave the extracted tree where the existing promote sequence
|
|
118
|
+
* will find it — so a rescue lands in `dist/` by the SAME single rename, with the
|
|
119
|
+
* same litter sweep, and this module never touches the promote at all.
|
|
120
|
+
*
|
|
121
|
+
* `flag: 'wx'` is a real control and a small one: `O_EXCL` refuses to write
|
|
122
|
+
* through a name that already exists, INCLUDING a symlink someone pre-planted at
|
|
123
|
+
* it. It does nothing about a substitution AFTER the write — that window is the
|
|
124
|
+
* whole cost of the rescue and is stated in the notice, not engineered away.
|
|
125
|
+
* The copy is deleted as soon as the child is done; the `finally` in
|
|
126
|
+
* `extractBytesToDist` removes the whole incoming tree regardless.
|
|
127
|
+
* @returns {string|null} the strategy name that recovered the archive, or null
|
|
128
|
+
*/
|
|
129
|
+
function nativeRescue({ bytes, dir, reason, platform, fs, spawn, maxMs, log }) {
|
|
130
|
+
const incoming = path.dirname(dir);
|
|
131
|
+
if (!path.basename(incoming).startsWith(INCOMING_PREFIX)) {
|
|
132
|
+
log(`[amicus] the native-extractor rescue was NOT attempted: ${collapseExcerpt(dir, PATH_EXCERPT_CHARS)} is not inside an amicus incoming directory.`);
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
const zip = path.join(incoming, RESCUE_ZIP);
|
|
136
|
+
announceNativeRescue({ zip, reason, log });
|
|
137
|
+
try {
|
|
138
|
+
fs.writeFileSync(zip, bytes, { flag: 'wx', mode: 0o600 });
|
|
139
|
+
} catch (e) {
|
|
140
|
+
log(`[amicus] the native-extractor rescue could not write the archive out: ${collapseExcerpt((e && e.message) || String(e))}`);
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
// The failed extractor's partial tree is evidence of nothing and would be
|
|
145
|
+
// promoted as if it were a rescue. It goes before the child runs.
|
|
146
|
+
cleanDir(fs, dir);
|
|
147
|
+
const strategy = runNativePlan({ zip, dir, platform, fs, spawn, maxMs, log });
|
|
148
|
+
if (strategy) {
|
|
149
|
+
log(`[amicus] recovered via the native extractor (${strategy}). These bytes were NOT re-hashed; the result is marked unverified.`);
|
|
150
|
+
}
|
|
151
|
+
return strategy;
|
|
152
|
+
} finally {
|
|
153
|
+
try { fs.rmSync(zip, { force: true }); } catch { /* the incoming tree is removed anyway */ }
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
module.exports = { nativeRescue, RESCUE_ZIP, INCOMING_PREFIX };
|