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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +238 -0
- package/README.md +1 -1
- package/docs/ROADMAP.md +4 -4
- package/docs/architecture-map.md +36 -7
- package/docs/configuration.md +119 -11
- package/docs/electron-testing.md +133 -0
- package/docs/troubleshooting.md +103 -3
- package/docs/usage.md +1 -1
- package/package.json +2 -1
- package/scripts/postinstall.js +24 -3
- package/src/sidecar/electron-cache.js +41 -1
- package/src/sidecar/electron-custody.js +180 -0
- package/src/sidecar/electron-ensure.js +25 -4
- package/src/sidecar/electron-env-scrub.js +233 -0
- package/src/sidecar/electron-exe-rel.js +131 -0
- package/src/sidecar/electron-install.js +120 -135
- package/src/sidecar/electron-layout.js +300 -0
- package/src/sidecar/electron-native-plan.js +175 -0
- package/src/sidecar/electron-native-rescue.js +270 -0
- package/src/sidecar/electron-provision.js +148 -95
- package/src/sidecar/electron-refuse.js +253 -0
- package/src/sidecar/electron-repair-cache.js +212 -0
- package/src/sidecar/electron-rescue-notice.js +95 -0
- package/src/sidecar/electron-trust.js +45 -118
- package/src/sidecar/unzip.js +13 -3
- package/src/sidecar/zip-entry-write.js +268 -0
- package/src/sidecar/zip-from-buffer.js +231 -0
- package/src/sidecar/zip-local-name-scan.js +238 -0
- package/src/sidecar/zip-name-scan.js +146 -0
- package/src/sidecar/zip-stall-bound.js +144 -0
- package/src/utils/doctor-electron-mcp-check.js +9 -2
|
@@ -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
|
+
};
|