amicus 4.9.5 → 4.9.7

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,175 @@
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
+ * `cwd` IS THE INCOMING TREE, and it is a containment measure with an honest
84
+ * label: MEASURED NEUTRAL, not measured needed. Across 18 paired runs no shipped
85
+ * strategy wrote anything cwd-relative, so this changes no observed behaviour.
86
+ * It costs one line and adds no decision surface, and it converts a hypothetical
87
+ * cwd-relative write from "the user's own repo, under npx, forever" into "the
88
+ * incoming tree, which `extractBytesToDist`'s `finally` deletes unconditionally
89
+ * and `sweepPromoteLitter` takes if a kill skipped that". `unzip.js ::
90
+ * robustExtract`'s native loop deliberately does NOT get the same treatment: its
91
+ * `dir` is not inside a tree amicus deletes unconditionally, so binding a cwd
92
+ * there would point a child's working directory at the user's install.
93
+ *
94
+ * The verdicts are unzip.js's, because they were right there: a spawn error or
95
+ * an external signal-kill (`status: null` — SIGKILL, an OOM) is a FAILURE even
96
+ * if files landed, a non-zero exit is a failure, and a clean exit that produced
97
+ * nothing is a failure. Every failure cleans up after itself so the next
98
+ * strategy starts from an empty directory.
99
+ * @returns {string|null} the strategy name that worked, or null
100
+ */
101
+ function runNativePlan({ zip, dir, cwd, platform, fs, spawn, maxMs, log }) {
102
+ const failures = [];
103
+ for (const strat of nativeUnzipPlan(zip, dir, platform)) {
104
+ let res;
105
+ try {
106
+ res = spawn(strat.cmd, strat.args, {
107
+ stdio: 'ignore', windowsHide: true, timeout: maxMs, cwd,
108
+ });
109
+ } catch (e) {
110
+ failures.push(`${strat.name}: spawn ${(e && e.code) || (e && e.message) || 'threw'}`);
111
+ continue;
112
+ }
113
+ if (res && (res.error || res.signal)) {
114
+ failures.push(`${strat.name}: ${res.error ? (res.error.code || res.error.message) : `killed by ${res.signal}`}`);
115
+ } else if (res && typeof res.status === 'number' && res.status !== 0) {
116
+ failures.push(`${strat.name}: exit ${res.status}`);
117
+ } else if (dirNonEmpty(fs, dir)) {
118
+ return strat.name;
119
+ } else {
120
+ failures.push(`${strat.name}: produced no files`);
121
+ }
122
+ cleanDir(fs, dir);
123
+ }
124
+ log(`[amicus] the native-extractor rescue did not recover this archive (${collapseExcerpt(failures.join('; ') || 'no native strategy available')}).`);
125
+ return null;
126
+ }
127
+
128
+ /**
129
+ * Write the verified buffer beside the incoming `dist`, hand that path to the
130
+ * native plan, and leave the extracted tree where the existing promote sequence
131
+ * will find it — so a rescue lands in `dist/` by the SAME single rename, with the
132
+ * same litter sweep, and this module never touches the promote at all.
133
+ *
134
+ * `namesComplete`/`namesChecked` are REQUIRED and deliberately have no defaults:
135
+ * they drive a disclosure, and a caller that forgot to thread them must not get
136
+ * the reassuring branch by omission. `announceNativeRescue` treats `undefined` as
137
+ * "not complete" for the same reason.
138
+ *
139
+ * `flag: 'wx'` is a real control and a small one: `O_EXCL` refuses to write
140
+ * through a name that already exists, INCLUDING a symlink someone pre-planted at
141
+ * it. It does nothing about a substitution AFTER the write — that window is the
142
+ * whole cost of the rescue and is stated in the notice, not engineered away.
143
+ * The copy is deleted as soon as the child is done; the `finally` in
144
+ * `extractBytesToDist` removes the whole incoming tree regardless.
145
+ * @returns {string|null} the strategy name that recovered the archive, or null
146
+ */
147
+ function nativeRescue({ bytes, dir, reason, namesComplete, namesChecked, platform, fs, spawn, maxMs, log }) {
148
+ const incoming = path.dirname(dir);
149
+ if (!path.basename(incoming).startsWith(INCOMING_PREFIX)) {
150
+ log(`[amicus] the native-extractor rescue was NOT attempted: ${collapseExcerpt(dir, PATH_EXCERPT_CHARS)} is not inside an amicus incoming directory.`);
151
+ return null;
152
+ }
153
+ const zip = path.join(incoming, RESCUE_ZIP);
154
+ announceNativeRescue({ zip, reason, namesComplete, namesChecked, log });
155
+ try {
156
+ fs.writeFileSync(zip, bytes, { flag: 'wx', mode: 0o600 });
157
+ } catch (e) {
158
+ log(`[amicus] the native-extractor rescue could not write the archive out: ${collapseExcerpt((e && e.message) || String(e))}`);
159
+ return null;
160
+ }
161
+ try {
162
+ // The failed extractor's partial tree is evidence of nothing and would be
163
+ // promoted as if it were a rescue. It goes before the child runs.
164
+ cleanDir(fs, dir);
165
+ const strategy = runNativePlan({ zip, dir, cwd: incoming, platform, fs, spawn, maxMs, log });
166
+ if (strategy) {
167
+ log(`[amicus] recovered via the native extractor (${strategy}). These bytes were NOT re-hashed; the result is marked unverified.`);
168
+ }
169
+ return strategy;
170
+ } finally {
171
+ try { fs.rmSync(zip, { force: true }); } catch { /* the incoming tree is removed anyway */ }
172
+ }
173
+ }
174
+
175
+ module.exports = { nativeRescue, RESCUE_ZIP, INCOMING_PREFIX };
@@ -0,0 +1,270 @@
1
+ /**
2
+ * THE NATIVE-EXTRACTOR RESCUE — the one way an archive amicus's own extractor
3
+ * cannot parse still becomes an install, what it costs, and why it is reachable
4
+ * from exactly one place.
5
+ *
6
+ * ── THE FINDING (council run 34182994208, major, deepseek seat) ───────────
7
+ * "Deleting the native-unzip fallback and the last-resort install.js spawn
8
+ * weakens the air-gapped recovery path to a permanent no-rescue failure for
9
+ * archives yauzl cannot parse." True as filed. `zip-stall-bound.js` had already
10
+ * written the loss down honestly — "an archive yauzl cannot parse but a native
11
+ * extractor could is a failed repair plus a re-download" — and on a machine with
12
+ * no network to re-download from, that is the end of the road.
13
+ *
14
+ * ── WHY IT CANNOT SIMPLY BE PUT BACK, WHICH IS THE WHOLE JUSTIFICATION ────
15
+ * Every native extractor — `tar`, `Expand-Archive`, `ditto`, `unzip` — takes a
16
+ * PATH. Using one therefore means writing bytes down and letting a child process
17
+ * open them: the SECOND PATH RESOLUTION the custody design exists to eliminate
18
+ * (`electron-custody.js` carries the three refuted remedies and the measurements
19
+ * that killed them). The custody property — **amicus never itself writes, or
20
+ * reports as verified, bytes it did not hash** — and a path-taking extractor
21
+ * cannot both hold on the same run. There is no clever version of this: the two
22
+ * properties are in direct contradiction.
23
+ *
24
+ * That trade is acceptable ONLY under a flag whose existing documented meaning is
25
+ * already "I accept Electron bytes amicus cannot vouch for", and it must not be
26
+ * reachable any other way. `AMICUS_ALLOW_UNVERIFIED_ELECTRON=1` is that flag, and
27
+ * it is read in exactly one place (`electron-trust.js :: electronTrustPolicy`),
28
+ * from a bare environment name a repository's `.npmrc` cannot plant. Custody
29
+ * stays absolute by default. `runInstaller` is NOT coming back — it did its own
30
+ * download with its own anchor, which is a different and much worse thing (see
31
+ * `electron-provision.js`).
32
+ *
33
+ * ── THE TRIGGER BOUNDARY, WHICH MATTERS MORE THAN THE MECHANISM ───────────
34
+ * "Fall back when extraction fails" would re-arm the laundering bug this cluster
35
+ * already fixed once (C4): a security refusal handed to a tool with no such
36
+ * check. So the trigger is ONE extractor verdict, `UNZIP_BUFFER_FAILED` — the
37
+ * code whose defined meaning is "the ARCHIVE is bad", and already the only
38
+ * failure that licences an eviction (`electron-repair-cache.js ::
39
+ * EVICTS_THE_ARTIFACT`). The rescue and the eviction now share one trigger.
40
+ *
41
+ * YES the extractor positively identified the ARCHIVE as unreadable.
42
+ * NO `UNZIP_UNSAFE_ARCHIVE` — a path-traversal REFUSAL. Terminal by design;
43
+ * C4 exists precisely to stop a refusal being retried through a tool
44
+ * amicus does not control, and the hatch is not even MENTIONED on that
45
+ * exit — advertising it there would be the same laundering with a human
46
+ * in the loop.
47
+ * NO `UNZIP_BUFFER_STALLED` — the bound exists to STOP work, not to hand it
48
+ * to someone else, and a stall is a verdict about nothing.
49
+ * NO a digest MISMATCH. The bytes are known wrong; there is nothing to
50
+ * rescue. This is the exclusion that is easy to get wrong, because the
51
+ * same flag that opens the rescue ALSO downgrades a mismatch refusal to a
52
+ * warning (`electron-trust.js :: verifyArtifactBytes`) — so with the hatch
53
+ * set, contradicted bytes really do reach the extractor. `gate.verdict`
54
+ * is checked here for that one reason.
55
+ * NO `UNZIP_DEST_FAILED` — not the archive's fault, and a native extractor
56
+ * writing to the same full or unwritable destination fails identically.
57
+ * NO `UNZIP_BUFFER_UNAVAILABLE`, and NO an error carrying no `code` at all.
58
+ * Neither is a parse failure, and a rule that fails OPEN on the shapes
59
+ * nobody enumerated is the shape D2 was filed against. Fail closed.
60
+ *
61
+ * AND A VERDICT IS NOT THE WHOLE QUESTION. Every line above keys on the refusal
62
+ * yauzl FORMED, and yauzl checks an entry's size before its name — so an archive
63
+ * whose first entry breaks the extraction arrives as `UNZIP_BUFFER_FAILED` with
64
+ * its traversal entry never looked at. One flag bit, MEASURED, moves the same
65
+ * archive from the terminal class into the rescuable one. So the boundary also
66
+ * asks what names the archive DECLARES (`hostileName`), and a name yauzl would
67
+ * have refused is treated as the refusal it would have raised. What that scan
68
+ * cannot see is written down there rather than papered over.
69
+ *
70
+ * ── AND `no-digest` IS ALLOWED, DELIBERATELY ──────────────────────────────
71
+ * Only `mismatch` is excluded. `no-digest` means nobody ever published a digest
72
+ * for this artifact — an Electron package predating `checksums.json` — which is
73
+ * exactly the old, air-gapped machine the finding is about. Nothing contradicts
74
+ * those bytes; refusing them would delete the rescue for its main case.
75
+ *
76
+ * ── WHAT A RESCUE CAN NEVER DO: REPORT CLEAN ──────────────────────────────
77
+ * `rescue.used` is set when the rescue runs, and both routes fold it into their
78
+ * `unverified` mark, so a rescued install is never reported as verified even
79
+ * when the artifact's own sha256 matched. It has to be: the bytes in `dist/` were
80
+ * placed there by a child process reading a path, not by amicus writing what it
81
+ * hashed. Reporting that as verified is the exact overclaim the custody property
82
+ * forbids.
83
+ *
84
+ * ── OFFERED-BUT-UNARMED: THE OFFER IS A PROMISE ──────────────────────────
85
+ * With the hatch OFF, a parse failure prints an offer naming the flag and telling
86
+ * an air-gapped user to set it and provision again. The FIRST cut of C2 printed
87
+ * that and then let the same `UNZIP_BUFFER_FAILED` reach the cache route's
88
+ * eviction, which DELETED the artifact on the way out — MEASURED end to end: the
89
+ * ten-line offer on stderr, `reason: "… was corrupt and removed"`, the zip gone,
90
+ * and the re-run the message asks for ending in `No cached electron zip found`.
91
+ * On the machine the whole finding is about, the offer named a rescue that its
92
+ * own run had just made impossible.
93
+ *
94
+ * So `rescue.offered` is set HERE, where the promise is made, and
95
+ * `electron-repair-cache.js` keeps the artifact when it is set. The cost is the
96
+ * availability cost that module already accepts wherever its delete fence says
97
+ * no: an unreadable zip survives and is re-downloaded once per provision. That
98
+ * is the right way round — a copy nobody can read costs one download; a copy
99
+ * that is gone costs the only rescue there was.
100
+ *
101
+ * @module sidecar/electron-native-rescue
102
+ */
103
+
104
+ 'use strict';
105
+
106
+ const { spawnSync } = require('child_process');
107
+
108
+ // The cap comes from `unzip.js`, which is byte-for-byte unchanged: this is a
109
+ // re-wiring of a caller, not a change to that module.
110
+ const { MAX_MS } = require('./unzip');
111
+ // The mechanism this file decides about: writing the buffer down, walking the
112
+ // platform's plan, sweeping up. `RESCUE_ZIP` and `INCOMING_PREFIX` are re-exported
113
+ // below because they name what a rescue leaves on disk, which is this module's
114
+ // subject even though the code that writes them is next door.
115
+ const { nativeRescue, RESCUE_ZIP, INCOMING_PREFIX } = require('./electron-native-plan');
116
+ // The offer a parse failure gets when the hatch is off. Every other user-facing
117
+ // sentence in this subsystem is written in the same two files.
118
+ const { offerNativeRescue } = require('./electron-rescue-notice');
119
+ // The read-only name walk the boundary consults before it trusts a verdict.
120
+ const { scanEntryNames } = require('./zip-name-scan');
121
+ const { scanLocalNames } = require('./zip-local-name-scan');
122
+ const { collapseExcerpt } = require('../utils/text-sanitize');
123
+
124
+ /** The ONE extractor verdict a rescue may act on. See the docblock's boundary. */
125
+ const RESCUE_TRIGGER = 'UNZIP_BUFFER_FAILED';
126
+
127
+ /** True for the ONE failure class the owner authorised a rescue for. */
128
+ function isRescuableFailure(err) {
129
+ return !!err && err.code === RESCUE_TRIGGER;
130
+ }
131
+
132
+ /**
133
+ * A parse failure's archive, asked what ENTRY NAMES it declares.
134
+ *
135
+ * THE EXCLUSION ABOVE KEYS ON A REFUSAL, AND A REFUSAL HAS TO BE FORMED. yauzl
136
+ * checks an entry's size before its name, so an archive whose FIRST entry breaks
137
+ * the extraction never reaches the traversal entry behind it and arrives here as
138
+ * `UNZIP_BUFFER_FAILED` — the one class a rescue acts on. MEASURED: one flag bit
139
+ * moves the same archive from `UNZIP_UNSAFE_ARCHIVE` into the rescue, traversal
140
+ * entry and all (`zip-name-scan.js` carries both measurements and the two
141
+ * Windows tools' own refusals, which are what stopped the escape that run).
142
+ *
143
+ * So the boundary asks about the ENTRIES, not only the verdict, and a name yauzl
144
+ * would have refused becomes the refusal yauzl would have raised: terminal,
145
+ * unadvertised, left in place, exactly as if the archive had had nothing wrong
146
+ * with it but that entry.
147
+ *
148
+ * BOTH TABLES, BECAUSE THE STRATEGIES DO NOT AGREE ON WHICH ONE THEY READ (B3).
149
+ * Through v4.9.6 this asked the CENTRAL directory only, so an archive that blinds
150
+ * yauzl there — a truncation, or any of four ONE-FIELD forgeries of a COMPLETE
151
+ * end-of-central-directory record — declared no names amicus could see and went
152
+ * to the native extractor anyway. MEASURED: seven such archives carrying
153
+ * `../../../PWNED-BY-NATIVE.txt` reached a real spawn, and on two the rescue ran
154
+ * to COMPLETION and promoted. Only the Windows tools' own `..` guards stopped the
155
+ * escape — the exact reliance this module says amicus will not make.
156
+ * And the tables can DISAGREE: on an archive declaring one name locally and
157
+ * another centrally, `tar.exe` wrote the LOCAL name while `Expand-Archive` wrote
158
+ * the CENTRAL one. So a refusal in EITHER table refuses the archive.
159
+ *
160
+ * THE RESIDUALS THAT REMAIN. Neither walk sees a SYMLINK whose target escapes:
161
+ * that is a payload, not a name. And an archive that defeats BOTH walks still
162
+ * reaches the extractor — rarer than before, but not impossible — so the notice
163
+ * printed before the spawn now says WHICH names were checked, rather than letting
164
+ * the user assume they all were. `docs/configuration.md` says the same thing to
165
+ * the user who has to decide whether to set the flag.
166
+ *
167
+ * WHEN A NAME CHECK CANNOT SEE IT, the only check left is the extractor's own —
168
+ * and that claim is now RE-MEASURED on every CI run rather than asserted once
169
+ * (`tests/sidecar/native-extractor-containment.test.js`, 12 escape shapes per
170
+ * strategy). `tar.exe`, `Expand-Archive` and Info-ZIP `unzip` all contain their
171
+ * own escapes; GNU `tar` cannot read a zip at all; `ditto` is the one strategy
172
+ * still unmeasured, and that suite measures it the first time it runs on a Mac.
173
+ * What a failed strategy can still leave behind is ONLY a write to an ABSOLUTE
174
+ * path outside the incoming tree: everything else the rescue writes lives under
175
+ * that tree, which `extractBytesToDist`'s `finally` deletes unconditionally (B2).
176
+ * @param {{central:object, local:object}} seen the two walks' results
177
+ * @returns {Error|null} a terminal UNZIP_UNSAFE_ARCHIVE, or null
178
+ */
179
+ function hostileName(seen, log) {
180
+ const refusal = seen.central.refusal || seen.local.refusal;
181
+ if (!refusal) { return null; }
182
+ log('[amicus] REFUSING to rescue this archive: amicus could not read it, and while asking what');
183
+ log('[amicus] it contains it found an entry that tries to write OUTSIDE the destination:');
184
+ log(`[amicus] ${collapseExcerpt(refusal)}`);
185
+ log('[amicus] A native extractor may have no such check, so it is not offered this archive.');
186
+ return Object.assign(
187
+ new Error(`refusing to extract this archive: ${collapseExcerpt(refusal)}`),
188
+ { code: 'UNZIP_UNSAFE_ARCHIVE' },
189
+ );
190
+ }
191
+
192
+ /**
193
+ * Wrap a buffer extractor so a PARSE FAILURE — and nothing else — may be rescued
194
+ * by the native plan when the hatch is set.
195
+ *
196
+ * ONE WRAPPER, WIRED AT BOTH CALL SITES. `gate` and `policy` are only known after
197
+ * the digest gate has run, which is why this is composed inside each route rather
198
+ * than once in `repairElectron`; F3 is the standing reminder of what happens when
199
+ * a rule lands on one provision route and not the other, so a test asserts both.
200
+ *
201
+ * @param {object} o
202
+ * @param {function} o.extract the buffer extractor being wrapped
203
+ * @param {object} o.gate verifyArtifactBytes's result (its `verdict` is read)
204
+ * @param {object} o.policy electronTrustPolicy's result (the hatch)
205
+ * @param {object} o.rescue OUT: `{used, strategy}` when a rescue ran, and
206
+ * `{offered:true}` when one was named but not armed — the caller must not then
207
+ * discard the artifact the offer points at
208
+ * @returns {function} an extractor with the same (bytes, {dir}) signature
209
+ */
210
+ function withNativeRescue({
211
+ extract, gate = {}, policy = {}, rescue = {}, platform = process.platform,
212
+ fs, spawn = spawnSync, maxMs = MAX_MS, log = () => {},
213
+ }) {
214
+ return async (bytes, o) => {
215
+ try {
216
+ return await extract(bytes, o);
217
+ } catch (err) {
218
+ // Every class but one leaves through here untouched and unadvertised.
219
+ if (!isRescuableFailure(err)) { throw err; }
220
+ // ...and the one class that IS rescuable is asked what names it declares
221
+ // first, because the exclusion above keys on the refusal yauzl FORMED and
222
+ // an earlier bad entry stops it forming one. See `hostileName`.
223
+ const seen = { central: await scanEntryNames(bytes), local: scanLocalNames(bytes) };
224
+ const hostile = hostileName(seen, log);
225
+ if (hostile) { throw hostile; }
226
+ if (!policy.allowUnverified) {
227
+ // `offered` IS THE OFFER'S RECEIPT, and the cache route is required to
228
+ // honour it: see the docblock's OFFERED-BUT-UNARMED section.
229
+ rescue.offered = true;
230
+ offerNativeRescue({ reason: (err && err.message) || '', log });
231
+ throw err;
232
+ }
233
+ if (gate.verdict === 'mismatch') {
234
+ log('[amicus] the native-extractor rescue was NOT attempted: these bytes contradict the published sha256, so there is nothing to rescue.');
235
+ throw err;
236
+ }
237
+ const strategy = nativeRescue({
238
+ bytes,
239
+ dir: o.dir,
240
+ reason: (err && err.message) || '',
241
+ // WHAT THE NOTICE MAY CLAIM. Three states, not two: a real artifact
242
+ // truncated by a few KB has BOTH walks incomplete while the local walk
243
+ // read and cleared every name it found, so `central.read || local.complete`
244
+ // would print "nothing checked its entries" over 73 checked entries.
245
+ // BOTH, NOT EITHER. The two tables carry DIFFERENT names and the two
246
+ // strategies read different ones, so a disjunction cannot mean "every
247
+ // name was checked". MEASURED: a local walk stopped at entry 1 with a
248
+ // readable, benign central directory reported TRUE and printed nothing,
249
+ // while `tar.exe` reached a `../../../` entry only the local table had.
250
+ namesComplete: seen.central.read && seen.local.complete,
251
+ namesChecked: seen.local.names,
252
+ platform,
253
+ fs,
254
+ spawn,
255
+ maxMs,
256
+ log,
257
+ });
258
+ // A rescue that failed leaves the ORIGINAL classified error in flight, so
259
+ // a genuinely bad archive is still evicted exactly as it was before.
260
+ if (!strategy) { throw err; }
261
+ rescue.used = true;
262
+ rescue.strategy = strategy;
263
+ return { strategy, rescued: true };
264
+ }
265
+ };
266
+ }
267
+
268
+ module.exports = {
269
+ withNativeRescue, isRescuableFailure, RESCUE_TRIGGER, RESCUE_ZIP, INCOMING_PREFIX,
270
+ };