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.
@@ -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-layout` holds `platformExe`/`writePathTxt`/`extractBytesToDist`
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-layout');
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 exeRel;
65
- const pathFile = path.join(electronDir, 'path.txt');
66
- try {
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
- * TRUE LEAF: `path` only, with `fs` and the extractor injected by the caller —
22
- * so electron-provision.js requires it too without any risk of a cycle.
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 no `platformExe` is not an install, and destroying it costs
158
- * the user nothing they had. When the old tree DOES hold an executable, the
159
- * promote REFUSES and that tree is untouched — the repair fails, which is
160
- * strictly better than a working GUI becoming no GUI.
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 held an executable unless the new
164
- * tree is already in its place.**
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 replaced = null; // step 0's overwritten DIFFERENT value
193
- try { replaced = fs.readFileSync(pathFile, 'utf8'); } catch { /* absent or unreadable */ }
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 not an install anyway.
209
- if (fs.existsSync(path.join(distDir, platformExe(platform)))) {
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
- + `${platformExe(platform)} and was left exactly as it was`);
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, { stdio: 'ignore', windowsHide: true, timeout: maxMs });
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
- * 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
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
- async function hostileName(bytes, log) {
160
- const seen = await scanEntryNames(bytes);
161
- if (!seen.refusal) { return null; }
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(seen.refusal)}`);
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(seen.refusal)}`),
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 hostile = await hostileName(bytes, log);
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, dir: o.dir, reason: (err && err.message) || '', platform, fs, spawn, maxMs, log,
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({ zip, reason, log = () => {} }) {
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. That is a behaviour
44
- * change on a shape amicus cannot test on this machine (the darwin `.app` bundle
45
- * is the only electron artifact with real symlinks), so it is refused in the
46
- * same `Out of bound path` wording, exercised against synthetic archives, and
47
- * named in the report as unverified on macOS.
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