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.
@@ -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 };