amicus 4.9.6 → 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 +152 -0
- package/README.md +1 -1
- package/docs/ROADMAP.md +3 -3
- package/docs/architecture-map.md +5 -0
- package/docs/configuration.md +43 -15
- package/docs/electron-testing.md +133 -0
- package/docs/troubleshooting.md +14 -7
- package/docs/usage.md +1 -1
- package/package.json +1 -1
- package/src/sidecar/electron-exe-rel.js +131 -0
- package/src/sidecar/electron-install.js +7 -12
- package/src/sidecar/electron-layout.js +31 -31
- package/src/sidecar/electron-native-plan.js +23 -5
- package/src/sidecar/electron-native-rescue.js +55 -16
- package/src/sidecar/electron-rescue-notice.js +18 -1
- package/src/sidecar/zip-from-buffer.js +16 -5
- package/src/sidecar/zip-local-name-scan.js +238 -0
- package/src/sidecar/zip-name-scan.js +5 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHICH exe a package resolves through, and whether a `dist/` HOLDS one.
|
|
3
|
+
*
|
|
4
|
+
* ONE RULE, ONE HOME. `resolveElectronBinary` (electron-install.js) decides what
|
|
5
|
+
* amicus will SPAWN; `promoteDist`'s retirement guard (electron-layout.js)
|
|
6
|
+
* decides what amicus may DELETE. Until v4.9.7 only the first one read
|
|
7
|
+
* `path.txt` — the second asked whether `dist/` held THIS HOST'S default exe —
|
|
8
|
+
* and a package cross-installed through `npm_config_platform` holds a different
|
|
9
|
+
* basename, so a failed promote destroyed a working tree (A1). Two copies of a
|
|
10
|
+
* rule are free to drift; this module exists so there is one.
|
|
11
|
+
*
|
|
12
|
+
* WHICH VALUE `promoteDist`'S GUARD MAY READ, since getting this wrong is how
|
|
13
|
+
* the fix would have been as blind as the defect:
|
|
14
|
+
* - `raw`, the bytes `path.txt` held BEFORE the promote's step 0 — YES.
|
|
15
|
+
* - `replaced` (electron-layout.js) — NO. It is nulled in exactly the branch
|
|
16
|
+
* the guard most needs a name for, and it is untrimmed, so feeding it to
|
|
17
|
+
* `path.join` fails OPEN on a trailing newline.
|
|
18
|
+
* - A RE-READ of `path.txt` at the guard — NO, and this is the sharp one.
|
|
19
|
+
* Step 0 has already written `platformExe` there, so a re-reading guard
|
|
20
|
+
* reads its own writer's value and learns nothing. MEASURED: at the guard
|
|
21
|
+
* the file says `electron.exe` even for a package cross-installed as
|
|
22
|
+
* `electron`, and the tree is deleted exactly as before the fix. That is
|
|
23
|
+
* failure mode #21, the echoed read-back.
|
|
24
|
+
*
|
|
25
|
+
* `ELECTRON_OVERRIDE_DIST_PATH` IS DELIBERATELY NOT PART OF THIS RULE, and
|
|
26
|
+
* `promoteDist` gains no `env`. The guard governs a DELETE of `distDir` and
|
|
27
|
+
* nothing else, so the only question is what THAT tree holds; under an override
|
|
28
|
+
* `resolveElectronBinary` does not look in `dist/` at all. Ignoring it can only
|
|
29
|
+
* make the guard readier to find an exe — the fail-CLOSED direction.
|
|
30
|
+
*
|
|
31
|
+
* TRUE LEAF: `path` only, with `fs` injected by the caller — so both callers can
|
|
32
|
+
* require it with no risk of a cycle.
|
|
33
|
+
*
|
|
34
|
+
* @module sidecar/electron-exe-rel
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
'use strict';
|
|
38
|
+
|
|
39
|
+
const path = require('path');
|
|
40
|
+
|
|
41
|
+
/** Platform exe basename, matching electron's getPlatformPath(). */
|
|
42
|
+
function platformExe(platform) {
|
|
43
|
+
switch (platform) {
|
|
44
|
+
case 'mas':
|
|
45
|
+
case 'darwin':
|
|
46
|
+
return path.join('Electron.app', 'Contents', 'MacOS', 'Electron');
|
|
47
|
+
case 'win32':
|
|
48
|
+
return 'electron.exe';
|
|
49
|
+
default:
|
|
50
|
+
return 'electron';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The relative exe path a package RESOLVES through, from `path.txt`'s RAW bytes.
|
|
56
|
+
*
|
|
57
|
+
* `resolveElectronBinary`'s rule, stated once: TRIM, and fall back to
|
|
58
|
+
* `platformExe` when the file is absent, unreadable OR blank. `null` is the
|
|
59
|
+
* caller's "the read threw".
|
|
60
|
+
*
|
|
61
|
+
* THE TRIM AND THE BLANK ARM ARE BOTH LOAD-BEARING, and the A1 filing named
|
|
62
|
+
* neither — it said "absent or unreadable". MEASURED: a guard that skips the
|
|
63
|
+
* trim deletes a real `dist/electron.exe` under a `path.txt` of
|
|
64
|
+
* `"electron.exe\n"`, because `existsSync(join(dist, 'electron.exe\n'))` is
|
|
65
|
+
* false on Windows; and one that returns a blank value names `dist/` ITSELF,
|
|
66
|
+
* which exists, so it would refuse every promote forever.
|
|
67
|
+
*/
|
|
68
|
+
function heldExeRel(raw, platform) {
|
|
69
|
+
const rel = typeof raw === 'string' ? raw.trim() : '';
|
|
70
|
+
return rel || platformExe(platform);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* WHICH executable `distDir` holds — under either name it could resolve
|
|
75
|
+
* through — or `null` for a tree that is not an install under any of them.
|
|
76
|
+
*
|
|
77
|
+
* A UNION, NEVER A REPLACEMENT, and the union is why this returns a NAME. The
|
|
78
|
+
* filing's literal rule ("judge by what `path.txt` names") was MEASURED to open
|
|
79
|
+
* three new holes it does not mention: a whitespace-only `path.txt`, one with a
|
|
80
|
+
* trailing newline, and a TRUNCATED one (`electr` — the shape `promoteDist`'s
|
|
81
|
+
* own best-effort put-back can leave) each turned a real `dist/electron.exe`
|
|
82
|
+
* into "not an install, delete it". So `platformExe` is not replaced by the
|
|
83
|
+
* `path.txt` name; it is joined by it, and the set of trees this licenses
|
|
84
|
+
* deleting can only ever SHRINK.
|
|
85
|
+
*
|
|
86
|
+
* ARM 1 IS THE PRE-FIX RULE, BYTE FOR BYTE, and it runs first and
|
|
87
|
+
* unconditionally. That ordering is the guarantee: no tree the shipped guard
|
|
88
|
+
* protects today can be deleted by this one.
|
|
89
|
+
*
|
|
90
|
+
* ARM 2 CARRIES TWO BOUNDS THE FIRST DOES NOT NEED.
|
|
91
|
+
* CONTAINED — a `path.txt` of `..`, `.`, `''` or `../SIBLING` joins to
|
|
92
|
+
* something that EXISTS outside `dist/` (all MEASURED true), which would
|
|
93
|
+
* refuse every promote forever while claiming `dist/` held an exe it never
|
|
94
|
+
* held. The predicate is `zip-entry-write.js :: writeSymlink`'s, verbatim —
|
|
95
|
+
* including the `path.sep`, whose absence MEASURABLY fails OPEN: a legal
|
|
96
|
+
* `dist/..electron.exe` reads as escaping and the tree is deleted.
|
|
97
|
+
* A FILE, NOT A DIRECTORY — every natural truncation of the darwin name
|
|
98
|
+
* (`Electron.app`, `Electron.app/Contents`, `Electron.app/Contents/MacOS`) is
|
|
99
|
+
* a real DIRECTORY in a real tree, and `existsSync` says true for all three.
|
|
100
|
+
* Accepting one would wedge the self-heal permanently on the AV-quarantine
|
|
101
|
+
* shape it exists for, printing "dist/ holds a usable Electron.app".
|
|
102
|
+
*
|
|
103
|
+
* A throwing `existsSync` (only an injected fs does this) reads as "I could not
|
|
104
|
+
* establish that this tree is empty", which refuses. Fail closed.
|
|
105
|
+
*
|
|
106
|
+
* @param {object} o
|
|
107
|
+
* @param {string} o.distDir
|
|
108
|
+
* @param {string|null} o.raw path.txt's bytes BEFORE any writer touched them
|
|
109
|
+
* @param {string} o.platform
|
|
110
|
+
* @param {object} o.fs
|
|
111
|
+
* @returns {string|null} the exe path, relative to `distDir`, that was found
|
|
112
|
+
*/
|
|
113
|
+
function distHeldExe({ distDir, raw, platform, fs }) {
|
|
114
|
+
const fallback = platformExe(platform);
|
|
115
|
+
// ARM 1 — the pre-fix rule, unchanged and first.
|
|
116
|
+
try { if (fs.existsSync(path.join(distDir, fallback))) { return fallback; } } catch { return fallback; }
|
|
117
|
+
const held = heldExeRel(raw, platform);
|
|
118
|
+
if (held === fallback) { return null; }
|
|
119
|
+
// ARM 2 — the name path.txt gives, contained and required to be a file.
|
|
120
|
+
const full = path.join(distDir, held);
|
|
121
|
+
const inside = path.relative(distDir, full);
|
|
122
|
+
if (inside === '' || inside === '..' || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside)) { return null; }
|
|
123
|
+
try { return fs.statSync(full).isFile() ? held : null; } catch { return null; }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Write path.txt: the basename `electron/index.js` joins onto `dist/`. */
|
|
127
|
+
function writePathTxt({ electronDir, platform, fs }) {
|
|
128
|
+
fs.writeFileSync(path.join(electronDir, 'path.txt'), platformExe(platform));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = { platformExe, writePathTxt, heldExeRel, distHeldExe };
|
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
*
|
|
17
17
|
* AT THE SIZE GATE, so pieces live next door: `./electron-custody` reads the
|
|
18
18
|
* artifact into memory once, `./zip-from-buffer` extracts what was read,
|
|
19
|
-
* `./electron-
|
|
19
|
+
* `./electron-exe-rel` holds the ONE `path.txt` rule (`platformExe`/`heldExeRel`/
|
|
20
|
+
* `writePathTxt`/`distHeldExe`), shared with `promoteDist`'s retirement guard since
|
|
21
|
+
* v4.9.7 (A1); `./electron-layout` holds `promoteDist`/`extractBytesToDist`
|
|
20
22
|
* (`platformExe` re-exported here for `ei.platformExe`), `./electron-refuse`
|
|
21
23
|
* holds the refusal messages, `./electron-repair-cache` the whole cached-artifact
|
|
22
24
|
* route, `./electron-provision` the pinned download. The arrow points one way out
|
|
@@ -32,7 +34,7 @@ const { cachedZip } = require('./electron-cache');
|
|
|
32
34
|
const { isSafeArtifactName } = require('./electron-custody');
|
|
33
35
|
const { avHint, verifyExtractOutcome: verifyQuarantine } = require('./electron-quarantine');
|
|
34
36
|
const { acquireRepairLock } = require('./electron-lock');
|
|
35
|
-
const { platformExe } = require('./electron-
|
|
37
|
+
const { platformExe, heldExeRel } = require('./electron-exe-rel');
|
|
36
38
|
const { controlledProvision } = require('./electron-provision');
|
|
37
39
|
const { repairFromCache } = require('./electron-repair-cache');
|
|
38
40
|
const { isUnsafeArchive, refuseUnsafeArchive } = require('./electron-refuse');
|
|
@@ -61,16 +63,9 @@ function defaultElectronDir() {
|
|
|
61
63
|
* @returns {string|null} resolved exe path, or null if path.txt is unreadable.
|
|
62
64
|
*/
|
|
63
65
|
function resolveElectronBinary({ electronDir = defaultElectronDir(), env = process.env, platform = process.platform, fs = fsDefault } = {}) {
|
|
64
|
-
let
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
exeRel = fs.readFileSync(pathFile, 'utf-8').trim();
|
|
68
|
-
} catch {
|
|
69
|
-
exeRel = '';
|
|
70
|
-
}
|
|
71
|
-
if (!exeRel) {
|
|
72
|
-
exeRel = platformExe(platform);
|
|
73
|
-
}
|
|
66
|
+
let raw = null;
|
|
67
|
+
try { raw = fs.readFileSync(path.join(electronDir, 'path.txt'), 'utf-8'); } catch { /* absent or unreadable */ }
|
|
68
|
+
const exeRel = heldExeRel(raw, platform); // the ONE rule (electron-exe-rel.js)
|
|
74
69
|
const override = env.ELECTRON_OVERRIDE_DIST_PATH;
|
|
75
70
|
if (override) {
|
|
76
71
|
return path.join(override, exeRel);
|
|
@@ -18,8 +18,11 @@
|
|
|
18
18
|
* caller already hashed and writes `dist/` by extract-into-incoming + promote
|
|
19
19
|
* (see `promoteDist`).
|
|
20
20
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
21
|
+
* NEAR-LEAF: `path` and `crypto`, plus the true leaf `./electron-exe-rel` (the
|
|
22
|
+
* one `path.txt` rule, shared with `resolveElectronBinary` since v4.9.7 — A1),
|
|
23
|
+
* with `fs` and the extractor injected by the caller — so electron-provision.js
|
|
24
|
+
* requires it too without any risk of a cycle. `platformExe` is re-exported
|
|
25
|
+
* from here because it was defined here through v4.9.6.
|
|
23
26
|
*
|
|
24
27
|
* @module sidecar/electron-layout
|
|
25
28
|
*/
|
|
@@ -28,24 +31,7 @@
|
|
|
28
31
|
|
|
29
32
|
const crypto = require('crypto');
|
|
30
33
|
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
|
-
}
|
|
34
|
+
const { platformExe, writePathTxt, distHeldExe } = require('./electron-exe-rel');
|
|
49
35
|
|
|
50
36
|
/**
|
|
51
37
|
* The two litter prefixes this module creates, and how long one may survive.
|
|
@@ -154,19 +140,26 @@ function sweepPromoteLitter({
|
|
|
154
140
|
* and the caller then downloaded 138 MB and repeated the same promote.
|
|
155
141
|
*
|
|
156
142
|
* So the in-place removal now happens ONLY when there is nothing to lose: a
|
|
157
|
-
* `dist/` that holds
|
|
158
|
-
* the user nothing they had. When the old
|
|
159
|
-
* promote REFUSES and that tree is untouched —
|
|
160
|
-
* strictly better than a working GUI becoming no GUI.
|
|
143
|
+
* `dist/` that holds NEITHER the exe `path.txt` names NOR `platformExe` is not
|
|
144
|
+
* an install, and destroying it costs the user nothing they had. When the old
|
|
145
|
+
* tree DOES hold an executable, the promote REFUSES and that tree is untouched —
|
|
146
|
+
* the repair fails, which is strictly better than a working GUI becoming no GUI.
|
|
161
147
|
*
|
|
162
148
|
* THE GUARANTEE, stated so it is checkable:
|
|
163
|
-
* **A promote never removes a `dist/` that
|
|
164
|
-
*
|
|
149
|
+
* **A promote never removes a `dist/` that HELD an executable — under the name
|
|
150
|
+
* `path.txt` gives it, or `platformExe` when `path.txt` is absent, unreadable
|
|
151
|
+
* or blank — unless the new tree is already in its place.**
|
|
152
|
+
* "HELD" means a FILE INSIDE `dist/`; `electron-exe-rel.js :: distHeldExe` owns
|
|
153
|
+
* that rule and carries what each of its bounds was measured to cost (A1).
|
|
165
154
|
* The one exit that can still leave a user without a usable `dist/` is both
|
|
166
155
|
* renames failing after step 1 SUCCEEDED. The old tree is then whole and
|
|
167
156
|
* undeleted at `.amicus-retired-<hex>`, and the thrown message names it so the
|
|
168
157
|
* user can rename it back.
|
|
169
158
|
*
|
|
159
|
+
* WHICH VALUE THE GUARD READS (A1): `raw`, captured BEFORE step 0 — not
|
|
160
|
+
* `replaced`, and never a re-read. `electron-exe-rel.js` carries why, the
|
|
161
|
+
* `ELECTRON_OVERRIDE_DIST_PATH` ruling, and the measurements behind both.
|
|
162
|
+
*
|
|
170
163
|
* `path.txt` IS WRITTEN FIRST (B2). Writing it LAST made "dist but no path.txt"
|
|
171
164
|
* unobservable only while that write SUCCEEDED, and it ran after the old tree was
|
|
172
165
|
* retired AND DELETED, so one ENOSPC/EPERM/AV-locked 12-byte write left a `dist/`
|
|
@@ -189,8 +182,10 @@ function sweepPromoteLitter({
|
|
|
189
182
|
function promoteDist({ electronDir, incomingDist, platform, fs }) {
|
|
190
183
|
const distDir = path.join(electronDir, 'dist');
|
|
191
184
|
const pathFile = path.join(electronDir, 'path.txt');
|
|
192
|
-
let
|
|
193
|
-
|
|
185
|
+
let raw = null; // path.txt BEFORE step 0 overwrites it
|
|
186
|
+
let unreadable = false; // a read that failed for a reason other than ENOENT
|
|
187
|
+
try { raw = fs.readFileSync(pathFile, 'utf8'); } catch (e) { unreadable = !e || e.code !== 'ENOENT'; }
|
|
188
|
+
let replaced = raw; // step 0's overwritten DIFFERENT value
|
|
194
189
|
try {
|
|
195
190
|
if (replaced === platformExe(platform)) { replaced = null; } else {
|
|
196
191
|
try { writePathTxt({ electronDir, platform, fs }); } catch (e) {
|
|
@@ -205,10 +200,15 @@ function promoteDist({ electronDir, incomingDist, platform, fs }) {
|
|
|
205
200
|
retiredExists = true;
|
|
206
201
|
} catch (e) {
|
|
207
202
|
// The old tree cannot be moved. Removing it in place is irreversible, so
|
|
208
|
-
// it is allowed only when the tree is
|
|
209
|
-
if (
|
|
203
|
+
// it is allowed only when the tree is an install under NEITHER name (A1).
|
|
204
|
+
if (unreadable) {
|
|
205
|
+
throw new Error(`${(e && e.message) || e} — path.txt could not be read, so which exe `
|
|
206
|
+
+ 'dist/ holds is unknown and it was left exactly as it was');
|
|
207
|
+
}
|
|
208
|
+
const held = distHeldExe({ distDir, raw, platform, fs });
|
|
209
|
+
if (held) {
|
|
210
210
|
throw new Error(`${(e && e.message) || e} — the existing dist/ holds a usable `
|
|
211
|
-
+ `${
|
|
211
|
+
+ `${held} and was left exactly as it was`);
|
|
212
212
|
}
|
|
213
213
|
fs.rmSync(distDir, { recursive: true, force: true });
|
|
214
214
|
}
|
|
@@ -80,6 +80,17 @@ function cleanDir(fs, dir) {
|
|
|
80
80
|
/**
|
|
81
81
|
* Walk the platform's native plan until one strategy leaves files in `dir`.
|
|
82
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
|
+
*
|
|
83
94
|
* The verdicts are unzip.js's, because they were right there: a spawn error or
|
|
84
95
|
* an external signal-kill (`status: null` — SIGKILL, an OOM) is a FAILURE even
|
|
85
96
|
* if files landed, a non-zero exit is a failure, and a clean exit that produced
|
|
@@ -87,12 +98,14 @@ function cleanDir(fs, dir) {
|
|
|
87
98
|
* strategy starts from an empty directory.
|
|
88
99
|
* @returns {string|null} the strategy name that worked, or null
|
|
89
100
|
*/
|
|
90
|
-
function runNativePlan({ zip, dir, platform, fs, spawn, maxMs, log }) {
|
|
101
|
+
function runNativePlan({ zip, dir, cwd, platform, fs, spawn, maxMs, log }) {
|
|
91
102
|
const failures = [];
|
|
92
103
|
for (const strat of nativeUnzipPlan(zip, dir, platform)) {
|
|
93
104
|
let res;
|
|
94
105
|
try {
|
|
95
|
-
res = spawn(strat.cmd, strat.args, {
|
|
106
|
+
res = spawn(strat.cmd, strat.args, {
|
|
107
|
+
stdio: 'ignore', windowsHide: true, timeout: maxMs, cwd,
|
|
108
|
+
});
|
|
96
109
|
} catch (e) {
|
|
97
110
|
failures.push(`${strat.name}: spawn ${(e && e.code) || (e && e.message) || 'threw'}`);
|
|
98
111
|
continue;
|
|
@@ -118,6 +131,11 @@ function runNativePlan({ zip, dir, platform, fs, spawn, maxMs, log }) {
|
|
|
118
131
|
* will find it — so a rescue lands in `dist/` by the SAME single rename, with the
|
|
119
132
|
* same litter sweep, and this module never touches the promote at all.
|
|
120
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
|
+
*
|
|
121
139
|
* `flag: 'wx'` is a real control and a small one: `O_EXCL` refuses to write
|
|
122
140
|
* through a name that already exists, INCLUDING a symlink someone pre-planted at
|
|
123
141
|
* it. It does nothing about a substitution AFTER the write — that window is the
|
|
@@ -126,14 +144,14 @@ function runNativePlan({ zip, dir, platform, fs, spawn, maxMs, log }) {
|
|
|
126
144
|
* `extractBytesToDist` removes the whole incoming tree regardless.
|
|
127
145
|
* @returns {string|null} the strategy name that recovered the archive, or null
|
|
128
146
|
*/
|
|
129
|
-
function nativeRescue({ bytes, dir, reason, platform, fs, spawn, maxMs, log }) {
|
|
147
|
+
function nativeRescue({ bytes, dir, reason, namesComplete, namesChecked, platform, fs, spawn, maxMs, log }) {
|
|
130
148
|
const incoming = path.dirname(dir);
|
|
131
149
|
if (!path.basename(incoming).startsWith(INCOMING_PREFIX)) {
|
|
132
150
|
log(`[amicus] the native-extractor rescue was NOT attempted: ${collapseExcerpt(dir, PATH_EXCERPT_CHARS)} is not inside an amicus incoming directory.`);
|
|
133
151
|
return null;
|
|
134
152
|
}
|
|
135
153
|
const zip = path.join(incoming, RESCUE_ZIP);
|
|
136
|
-
announceNativeRescue({ zip, reason, log });
|
|
154
|
+
announceNativeRescue({ zip, reason, namesComplete, namesChecked, log });
|
|
137
155
|
try {
|
|
138
156
|
fs.writeFileSync(zip, bytes, { flag: 'wx', mode: 0o600 });
|
|
139
157
|
} catch (e) {
|
|
@@ -144,7 +162,7 @@ function nativeRescue({ bytes, dir, reason, platform, fs, spawn, maxMs, log }) {
|
|
|
144
162
|
// The failed extractor's partial tree is evidence of nothing and would be
|
|
145
163
|
// promoted as if it were a rescue. It goes before the child runs.
|
|
146
164
|
cleanDir(fs, dir);
|
|
147
|
-
const strategy = runNativePlan({ zip, dir, platform, fs, spawn, maxMs, log });
|
|
165
|
+
const strategy = runNativePlan({ zip, dir, cwd: incoming, platform, fs, spawn, maxMs, log });
|
|
148
166
|
if (strategy) {
|
|
149
167
|
log(`[amicus] recovered via the native extractor (${strategy}). These bytes were NOT re-hashed; the result is marked unverified.`);
|
|
150
168
|
}
|
|
@@ -118,6 +118,7 @@ const { nativeRescue, RESCUE_ZIP, INCOMING_PREFIX } = require('./electron-native
|
|
|
118
118
|
const { offerNativeRescue } = require('./electron-rescue-notice');
|
|
119
119
|
// The read-only name walk the boundary consults before it trusts a verdict.
|
|
120
120
|
const { scanEntryNames } = require('./zip-name-scan');
|
|
121
|
+
const { scanLocalNames } = require('./zip-local-name-scan');
|
|
121
122
|
const { collapseExcerpt } = require('../utils/text-sanitize');
|
|
122
123
|
|
|
123
124
|
/** The ONE extractor verdict a rescue may act on. See the docblock's boundary. */
|
|
@@ -144,27 +145,46 @@ function isRescuableFailure(err) {
|
|
|
144
145
|
* unadvertised, left in place, exactly as if the archive had had nothing wrong
|
|
145
146
|
* with it but that entry.
|
|
146
147
|
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
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
|
|
156
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
|
|
157
177
|
* @returns {Error|null} a terminal UNZIP_UNSAFE_ARCHIVE, or null
|
|
158
178
|
*/
|
|
159
|
-
|
|
160
|
-
const
|
|
161
|
-
if (!
|
|
179
|
+
function hostileName(seen, log) {
|
|
180
|
+
const refusal = seen.central.refusal || seen.local.refusal;
|
|
181
|
+
if (!refusal) { return null; }
|
|
162
182
|
log('[amicus] REFUSING to rescue this archive: amicus could not read it, and while asking what');
|
|
163
183
|
log('[amicus] it contains it found an entry that tries to write OUTSIDE the destination:');
|
|
164
|
-
log(`[amicus] ${collapseExcerpt(
|
|
184
|
+
log(`[amicus] ${collapseExcerpt(refusal)}`);
|
|
165
185
|
log('[amicus] A native extractor may have no such check, so it is not offered this archive.');
|
|
166
186
|
return Object.assign(
|
|
167
|
-
new Error(`refusing to extract this archive: ${collapseExcerpt(
|
|
187
|
+
new Error(`refusing to extract this archive: ${collapseExcerpt(refusal)}`),
|
|
168
188
|
{ code: 'UNZIP_UNSAFE_ARCHIVE' },
|
|
169
189
|
);
|
|
170
190
|
}
|
|
@@ -200,7 +220,8 @@ function withNativeRescue({
|
|
|
200
220
|
// ...and the one class that IS rescuable is asked what names it declares
|
|
201
221
|
// first, because the exclusion above keys on the refusal yauzl FORMED and
|
|
202
222
|
// an earlier bad entry stops it forming one. See `hostileName`.
|
|
203
|
-
const
|
|
223
|
+
const seen = { central: await scanEntryNames(bytes), local: scanLocalNames(bytes) };
|
|
224
|
+
const hostile = hostileName(seen, log);
|
|
204
225
|
if (hostile) { throw hostile; }
|
|
205
226
|
if (!policy.allowUnverified) {
|
|
206
227
|
// `offered` IS THE OFFER'S RECEIPT, and the cache route is required to
|
|
@@ -214,7 +235,25 @@ function withNativeRescue({
|
|
|
214
235
|
throw err;
|
|
215
236
|
}
|
|
216
237
|
const strategy = nativeRescue({
|
|
217
|
-
bytes,
|
|
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,
|
|
218
257
|
});
|
|
219
258
|
// A rescue that failed leaves the ORIGINAL classified error in flight, so
|
|
220
259
|
// a genuinely bad archive is still evicted exactly as it was before.
|
|
@@ -62,9 +62,26 @@ function offerNativeRescue({ reason, log = () => {} }) {
|
|
|
62
62
|
* instead of reassuring anyone about it — the rescue is not safe, and the words a
|
|
63
63
|
* user reads while it happens have to say so.
|
|
64
64
|
*/
|
|
65
|
-
function announceNativeRescue({
|
|
65
|
+
function announceNativeRescue({
|
|
66
|
+
zip, reason, namesComplete, namesChecked = 0, log = () => {},
|
|
67
|
+
}) {
|
|
66
68
|
log('[amicus] AMICUS_ALLOW_UNVERIFIED_ELECTRON=1 — running the NATIVE-EXTRACTOR RESCUE.');
|
|
67
69
|
log(`[amicus] amicus could not read the archive itself: ${collapseExcerpt(reason)}`);
|
|
70
|
+
// THREE STATES, NOT TWO, and the middle one is the common one: a real artifact
|
|
71
|
+
// truncated by a few KB leaves both walks incomplete while the local walk still
|
|
72
|
+
// read and cleared every name it reached. Saying "nothing checked its entries"
|
|
73
|
+
// there would be a FALSE disclosure on the shape this rescue exists for.
|
|
74
|
+
// `namesComplete` is undefined-means-no on purpose (see `nativeRescue`).
|
|
75
|
+
if (!namesComplete && namesChecked > 0) {
|
|
76
|
+
log(`[amicus] IT CHECKED ${namesChecked} ENTRY NAMES AND COULD NOT CONFIRM IT SAW THEM ALL:`);
|
|
77
|
+
log('[amicus] the archive stopped amicus part-way through its own tables, so an entry that');
|
|
78
|
+
log('[amicus] writes OUTSIDE dist/ could sit past the point it reached.');
|
|
79
|
+
} else if (!namesComplete) {
|
|
80
|
+
log('[amicus] AND IT COULD NOT READ THE ENTRY NAMES EITHER: neither this archive\'s central');
|
|
81
|
+
log('[amicus] directory nor its local file headers could be walked, so NOTHING checked its');
|
|
82
|
+
log('[amicus] entries for paths that write OUTSIDE dist/. The extractor below is the only');
|
|
83
|
+
log('[amicus] check left.');
|
|
84
|
+
}
|
|
68
85
|
log('[amicus] THE WINDOW THIS OPENS, stated plainly. amicus has written the bytes it hashed to');
|
|
69
86
|
log(`[amicus] ${collapseExcerpt(zip, PATH_EXCERPT_CHARS)}`);
|
|
70
87
|
log('[amicus] and is about to hand that PATH to a native extractor it does not control. Between');
|
|
@@ -40,11 +40,22 @@
|
|
|
40
40
|
* extraction root is REFUSED here and is not by extract-zip, and it is resolved
|
|
41
41
|
* against the REALPATH of the directory the link lands in because the lexical
|
|
42
42
|
* `path.dirname` was measured to be defeated outright by a chain of
|
|
43
|
-
* directory-symlink entries earlier in the same archive.
|
|
44
|
-
*
|
|
45
|
-
* is the only electron artifact with real
|
|
46
|
-
*
|
|
47
|
-
*
|
|
43
|
+
* directory-symlink entries earlier in the same archive. It is refused in the
|
|
44
|
+
* same `Out of bound path` wording and exercised against synthetic archives
|
|
45
|
+
* here; the darwin `.app` bundle is the only electron artifact with real
|
|
46
|
+
* symlinks, and since v4.9.7 `.github/workflows/darwin-bundle.yml` runs this
|
|
47
|
+
* path over the REAL artifact on a real Mac. The v4.9.6 worry that the check
|
|
48
|
+
* might REJECT a working layout is refuted by measurement: the real
|
|
49
|
+
* `electron-v43.1.1-darwin-arm64.zip` declares 585 records and 14 symlinks,
|
|
50
|
+
* every target relative, none carrying a `..` component, none absolute, and 0
|
|
51
|
+
* of the 585 entry names traversing a symlinked component. The linux artifacts
|
|
52
|
+
* hold ZERO symlink entries, so `writeSymlink` is unreachable there at all.
|
|
53
|
+
*
|
|
54
|
+
* `root = fs.realpathSync(dir)` below is load-bearing for that answer and no
|
|
55
|
+
* Windows probe would ever show it: on macOS the extraction root usually sits
|
|
56
|
+
* under `/var`, which is itself a symlink to `/private/var`, so comparing a
|
|
57
|
+
* resolved target against an UNRESOLVED root would read every link in a real
|
|
58
|
+
* `.app` as an escape.
|
|
48
59
|
*
|
|
49
60
|
* ── ERROR CODES ARE A CAUSAL CLAIM ───────────────────────────────────────
|
|
50
61
|
* `UNZIP_BUFFER_FAILED` = the ARCHIVE is bad. `UNZIP_DEST_FAILED` = the
|