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.
@@ -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 };
@@ -0,0 +1,231 @@
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 { collapseExcerpt } = require('../utils/text-sanitize');
122
+
123
+ /** The ONE extractor verdict a rescue may act on. See the docblock's boundary. */
124
+ const RESCUE_TRIGGER = 'UNZIP_BUFFER_FAILED';
125
+
126
+ /** True for the ONE failure class the owner authorised a rescue for. */
127
+ function isRescuableFailure(err) {
128
+ return !!err && err.code === RESCUE_TRIGGER;
129
+ }
130
+
131
+ /**
132
+ * A parse failure's archive, asked what ENTRY NAMES it declares.
133
+ *
134
+ * THE EXCLUSION ABOVE KEYS ON A REFUSAL, AND A REFUSAL HAS TO BE FORMED. yauzl
135
+ * checks an entry's size before its name, so an archive whose FIRST entry breaks
136
+ * the extraction never reaches the traversal entry behind it and arrives here as
137
+ * `UNZIP_BUFFER_FAILED` — the one class a rescue acts on. MEASURED: one flag bit
138
+ * moves the same archive from `UNZIP_UNSAFE_ARCHIVE` into the rescue, traversal
139
+ * entry and all (`zip-name-scan.js` carries both measurements and the two
140
+ * Windows tools' own refusals, which are what stopped the escape that run).
141
+ *
142
+ * So the boundary asks about the ENTRIES, not only the verdict, and a name yauzl
143
+ * would have refused becomes the refusal yauzl would have raised: terminal,
144
+ * unadvertised, left in place, exactly as if the archive had had nothing wrong
145
+ * with it but that entry.
146
+ *
147
+ * THE RESIDUAL, STATED RATHER THAN ENGINEERED AWAY. The scan reads the central
148
+ * directory; an archive whose central directory is unreadable — a truncated zip,
149
+ * the commonest thing this rescue exists for — declares no names it can see, and
150
+ * that archive still reaches the native extractor. Nothing here covers a SYMLINK
151
+ * whose target escapes either: that is a payload, not a name. In both cases the
152
+ * only remaining check is the extractor's own, which `tar` and `Expand-Archive`
153
+ * were MEASURED to have (`ditto` and Info-ZIP `unzip` are unmeasured), and
154
+ * `cleanDir` sweeps only inside `dir` — anything a native tool wrote outside it
155
+ * would survive a failed strategy. `docs/configuration.md` says the same thing to
156
+ * the user who has to decide whether to set the flag.
157
+ * @returns {Error|null} a terminal UNZIP_UNSAFE_ARCHIVE, or null
158
+ */
159
+ async function hostileName(bytes, log) {
160
+ const seen = await scanEntryNames(bytes);
161
+ if (!seen.refusal) { return null; }
162
+ log('[amicus] REFUSING to rescue this archive: amicus could not read it, and while asking what');
163
+ log('[amicus] it contains it found an entry that tries to write OUTSIDE the destination:');
164
+ log(`[amicus] ${collapseExcerpt(seen.refusal)}`);
165
+ log('[amicus] A native extractor may have no such check, so it is not offered this archive.');
166
+ return Object.assign(
167
+ new Error(`refusing to extract this archive: ${collapseExcerpt(seen.refusal)}`),
168
+ { code: 'UNZIP_UNSAFE_ARCHIVE' },
169
+ );
170
+ }
171
+
172
+ /**
173
+ * Wrap a buffer extractor so a PARSE FAILURE — and nothing else — may be rescued
174
+ * by the native plan when the hatch is set.
175
+ *
176
+ * ONE WRAPPER, WIRED AT BOTH CALL SITES. `gate` and `policy` are only known after
177
+ * the digest gate has run, which is why this is composed inside each route rather
178
+ * than once in `repairElectron`; F3 is the standing reminder of what happens when
179
+ * a rule lands on one provision route and not the other, so a test asserts both.
180
+ *
181
+ * @param {object} o
182
+ * @param {function} o.extract the buffer extractor being wrapped
183
+ * @param {object} o.gate verifyArtifactBytes's result (its `verdict` is read)
184
+ * @param {object} o.policy electronTrustPolicy's result (the hatch)
185
+ * @param {object} o.rescue OUT: `{used, strategy}` when a rescue ran, and
186
+ * `{offered:true}` when one was named but not armed — the caller must not then
187
+ * discard the artifact the offer points at
188
+ * @returns {function} an extractor with the same (bytes, {dir}) signature
189
+ */
190
+ function withNativeRescue({
191
+ extract, gate = {}, policy = {}, rescue = {}, platform = process.platform,
192
+ fs, spawn = spawnSync, maxMs = MAX_MS, log = () => {},
193
+ }) {
194
+ return async (bytes, o) => {
195
+ try {
196
+ return await extract(bytes, o);
197
+ } catch (err) {
198
+ // Every class but one leaves through here untouched and unadvertised.
199
+ if (!isRescuableFailure(err)) { throw err; }
200
+ // ...and the one class that IS rescuable is asked what names it declares
201
+ // first, because the exclusion above keys on the refusal yauzl FORMED and
202
+ // an earlier bad entry stops it forming one. See `hostileName`.
203
+ const hostile = await hostileName(bytes, log);
204
+ if (hostile) { throw hostile; }
205
+ if (!policy.allowUnverified) {
206
+ // `offered` IS THE OFFER'S RECEIPT, and the cache route is required to
207
+ // honour it: see the docblock's OFFERED-BUT-UNARMED section.
208
+ rescue.offered = true;
209
+ offerNativeRescue({ reason: (err && err.message) || '', log });
210
+ throw err;
211
+ }
212
+ if (gate.verdict === 'mismatch') {
213
+ log('[amicus] the native-extractor rescue was NOT attempted: these bytes contradict the published sha256, so there is nothing to rescue.');
214
+ throw err;
215
+ }
216
+ const strategy = nativeRescue({
217
+ bytes, dir: o.dir, reason: (err && err.message) || '', platform, fs, spawn, maxMs, log,
218
+ });
219
+ // A rescue that failed leaves the ORIGINAL classified error in flight, so
220
+ // a genuinely bad archive is still evicted exactly as it was before.
221
+ if (!strategy) { throw err; }
222
+ rescue.used = true;
223
+ rescue.strategy = strategy;
224
+ return { strategy, rescued: true };
225
+ }
226
+ };
227
+ }
228
+
229
+ module.exports = {
230
+ withNativeRescue, isRescuableFailure, RESCUE_TRIGGER, RESCUE_ZIP, INCOMING_PREFIX,
231
+ };