amicus 4.9.4 → 4.9.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +171 -3
- package/README.md +1 -1
- package/docs/ROADMAP.md +6 -4
- package/docs/architecture-map.md +31 -3
- package/docs/configuration.md +97 -0
- package/docs/troubleshooting.md +130 -0
- package/docs/usage.md +1 -1
- package/package.json +2 -1
- package/scripts/postinstall.js +28 -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-install.js +149 -159
- package/src/sidecar/electron-layout.js +300 -0
- package/src/sidecar/electron-native-plan.js +157 -0
- package/src/sidecar/electron-native-rescue.js +231 -0
- package/src/sidecar/electron-provision.js +232 -0
- package/src/sidecar/electron-refuse.js +253 -0
- package/src/sidecar/electron-repair-cache.js +212 -0
- package/src/sidecar/electron-rescue-notice.js +78 -0
- package/src/sidecar/electron-trust.js +226 -0
- package/src/sidecar/unzip.js +52 -2
- package/src/sidecar/zip-entry-write.js +268 -0
- package/src/sidecar/zip-from-buffer.js +220 -0
- package/src/sidecar/zip-name-scan.js +141 -0
- package/src/sidecar/zip-stall-bound.js +144 -0
- package/src/utils/doctor-electron-mcp-check.js +9 -2
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Electron artifact TRUST core — the digest anchor and the gate. (The third
|
|
3
|
+
* member of the cluster, the installer-spawn env scrub, lives in
|
|
4
|
+
* ./electron-env-scrub and is re-exported here; see below.)
|
|
5
|
+
*
|
|
6
|
+
* A hostile REPOSITORY (a clone the user opens, an unpacked sample) controls the
|
|
7
|
+
* `.npmrc` and `package.json` of the directory amicus's own docs tell people to
|
|
8
|
+
* run `npx -y amicus@latest` in. MEASURED (npm 11.16.0): that reaches the child
|
|
9
|
+
* as exactly two name shapes — `npm_config_<key lowercased>` and
|
|
10
|
+
* `npm_package_config_<key case-preserved>` — and NOTHING else. Bare `ELECTRON_*`
|
|
11
|
+
* and `AMICUS_*` names are out of its reach. THAT is the trust boundary this
|
|
12
|
+
* module encodes: the escape hatch can be a plain environment variable, and a
|
|
13
|
+
* machine-level `ELECTRON_MIRROR` can still be honoured, precisely because a
|
|
14
|
+
* repository cannot write either one.
|
|
15
|
+
*
|
|
16
|
+
* The control is the DIGEST, not the URL. Supplying `checksums` to
|
|
17
|
+
* `downloadArtifact` makes `@electron/get` write a LOCAL `SHASUMS256.txt` and
|
|
18
|
+
* never fetch one from the mirror (@electron/get 5.0.0, dist/index.js, lines
|
|
19
|
+
* 28-40), so an attacker who redirects the download still has to produce bytes that match
|
|
20
|
+
* electron's own published sha256. Blocking the URL itself is defence in depth on
|
|
21
|
+
* top of a control that already works, and is deliberately NOT built here.
|
|
22
|
+
*
|
|
23
|
+
* NEAR-LEAF MODULE: `crypto` + `path` + `fs`, plus `./electron-env-scrub`, which
|
|
24
|
+
* is itself a true leaf (no requires at all). The arrow is electron-install ->
|
|
25
|
+
* electron-provision -> electron-trust -> electron-env-scrub and must never point
|
|
26
|
+
* back; src/utils/path-fence.js:11-17 records what a cycle does to a destructured
|
|
27
|
+
* import in exactly this cluster.
|
|
28
|
+
*
|
|
29
|
+
* THE ENV SCRUB LIVES NEXT DOOR (v4.9.6 F2). `isRepoPlantedName` and
|
|
30
|
+
* `REPO_ENV_PREFIXES` moved to `./electron-env-scrub` when this file hit the
|
|
31
|
+
* 300-line gate with the F2 repair still to land, and are RE-EXPORTED here so
|
|
32
|
+
* existing import paths stay valid — the same shape as engine-log-parse.js
|
|
33
|
+
* re-exporting utils/text-sanitize.js. There is ONE implementation; these are
|
|
34
|
+
* the same function objects, not a second copy. (`scrubbedChildEnv` and
|
|
35
|
+
* `ELECTRON_INSTALL_TARGET_ENV` were deleted with the install.js spawn they
|
|
36
|
+
* served; see electron-provision.js for why that spawn is gone.)
|
|
37
|
+
*
|
|
38
|
+
* @module sidecar/electron-trust
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
'use strict';
|
|
42
|
+
|
|
43
|
+
const crypto = require('crypto');
|
|
44
|
+
const fsDefault = require('fs');
|
|
45
|
+
const path = require('path');
|
|
46
|
+
|
|
47
|
+
const { isRepoPlantedName, REPO_ENV_PREFIXES } = require('./electron-env-scrub');
|
|
48
|
+
|
|
49
|
+
/** A published sha256 is 64 LOWER-case hex characters. Anything else is not an anchor. */
|
|
50
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
51
|
+
|
|
52
|
+
/** Leading 'v' applied exactly once — matches @electron/get's normalizeVersion. */
|
|
53
|
+
function normalizeV(version) {
|
|
54
|
+
const v = String(version || '');
|
|
55
|
+
return v.startsWith('v') ? v : `v${v}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The amicus-namespaced trust policy. THE ONLY place the escape hatch is read.
|
|
60
|
+
* Never reads npm_config_* / npm_package_config_* / NPM_CONFIG_* — those are the
|
|
61
|
+
* attacker's channel, and a rule that read its own writer's surface would be no
|
|
62
|
+
* rule at all.
|
|
63
|
+
*
|
|
64
|
+
* `allowUnverified` is true for the string '1' and NOTHING else — 'true', 'yes',
|
|
65
|
+
* ' 1' are all false. A hatch that fails open on a typo is not a hatch.
|
|
66
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
67
|
+
* @returns {{ allowUnverified: boolean }}
|
|
68
|
+
*/
|
|
69
|
+
function electronTrustPolicy(env = process.env) {
|
|
70
|
+
return { allowUnverified: env.AMICUS_ALLOW_UNVERIFIED_ELECTRON === '1' };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** `electron-v43.1.1-win32-x64.zip`. Matches BOTH electron's checksums.json keys
|
|
74
|
+
* and @electron/get's getArtifactFileName + normalizeVersion. */
|
|
75
|
+
function artifactFileName({ version, platform, arch }) {
|
|
76
|
+
return `electron-${normalizeV(version)}-${platform}-${arch}.zip`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The electron package THIS amicus resolves — resolveAnchor rung 1. Duplicated
|
|
81
|
+
* (not imported from electron-install.defaultElectronDir) so this module stays a
|
|
82
|
+
* leaf and the require arrow keeps pointing one way.
|
|
83
|
+
* @returns {string|null}
|
|
84
|
+
*/
|
|
85
|
+
function selfElectronPackageDir() {
|
|
86
|
+
try {
|
|
87
|
+
return path.dirname(require.resolve('electron/package.json'));
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Parse one checksums.json into a table of ONLY well-formed rows. Never throws. */
|
|
94
|
+
function readChecksumTable(file, fs) {
|
|
95
|
+
try {
|
|
96
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
97
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { return null; }
|
|
98
|
+
const table = {};
|
|
99
|
+
for (const [name, digest] of Object.entries(parsed)) {
|
|
100
|
+
if (typeof digest === 'string' && HEX64.test(digest)) { table[name] = digest; }
|
|
101
|
+
}
|
|
102
|
+
return Object.keys(table).length > 0 ? table : null;
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Resolve the digest ANCHOR, offline. Precedence, highest first:
|
|
110
|
+
* 1. <selfElectronDir>/checksums.json — the RUNNING amicus's own table.
|
|
111
|
+
* 2. <electronDir>/checksums.json — ONLY when rung 1 offers no usable table.
|
|
112
|
+
*
|
|
113
|
+
* RUNG 1 IS LOAD-BEARING, not a convenience. `doctor --fix`
|
|
114
|
+
* (src/utils/doctor-electron-mcp-check.js:122-137) hands `repairElectron` an
|
|
115
|
+
* electronDir found by a FILESYSTEM SCAN of npx caches, so rung 2 on its own
|
|
116
|
+
* would read the anchor out of the same untrusted directory the bytes came from
|
|
117
|
+
* — the pin would vouch for the attacker's own zip. Rung 1 is the same published
|
|
118
|
+
* data out of a tree npm installed for amicus itself, before any hostile
|
|
119
|
+
* directory was visited.
|
|
120
|
+
*
|
|
121
|
+
* WHY RUNG 1 NO LONGER TESTS THE VERSION. It used to apply only when the self
|
|
122
|
+
* package's version equalled the requested one — but the requested `version` is
|
|
123
|
+
* itself read out of `<electronDir>/package.json` whenever the caller supplies
|
|
124
|
+
* none (electron-install.js `if (!version)`), and the ONE production caller,
|
|
125
|
+
* doctor --fix, supplies none. MEASURED on this tree: a planted
|
|
126
|
+
* `{"version":"99.0.0"}` demoted rung 1 by DATA alone, the scanned tree's own
|
|
127
|
+
* checksums.json then vouched for its own bytes, and repairElectron returned
|
|
128
|
+
* `{repaired:true}` after extracting POISONED-BYTES. A rule that reads its own
|
|
129
|
+
* selector off the surface it exists to distrust is not a rule. No version check
|
|
130
|
+
* is needed to keep a genuine version disagreement honest, because the table is
|
|
131
|
+
* keyed by the FULL artifact filename: a self table for 43.1.1 simply holds no
|
|
132
|
+
* `electron-v99.0.0-…zip` row, `expectedDigest` returns null, and the gate's
|
|
133
|
+
* `no-digest` verdict extracts-and-MARKS exactly as the brief requires — never a
|
|
134
|
+
* refusal, never a re-download loop.
|
|
135
|
+
*
|
|
136
|
+
* Rung 2 therefore survives for exactly one case: amicus's own electron package
|
|
137
|
+
* ships no readable checksums.json (an old electron, or the optionalDependency
|
|
138
|
+
* never installed). There the target's table is all there is, and it is still
|
|
139
|
+
* better than nothing against a truncated download.
|
|
140
|
+
*
|
|
141
|
+
* Pass `selfElectronDir: null` to disable rung 1.
|
|
142
|
+
* NEVER THROWS. Rejects a table whose values are not 64 lower-case hex.
|
|
143
|
+
* @returns {{ table: Record<string,string>, source: string } | null}
|
|
144
|
+
*/
|
|
145
|
+
function resolveAnchor({ electronDir, fs = fsDefault, selfElectronDir } = {}) {
|
|
146
|
+
const self = selfElectronDir === undefined ? selfElectronPackageDir() : selfElectronDir;
|
|
147
|
+
for (const dir of [self, electronDir]) {
|
|
148
|
+
if (!dir) { continue; }
|
|
149
|
+
const source = path.join(dir, 'checksums.json');
|
|
150
|
+
const table = readChecksumTable(source, fs);
|
|
151
|
+
if (table) { return { table, source }; }
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** @returns {string|null} the 64-hex digest for one artifact, or null. */
|
|
157
|
+
function expectedDigest(anchor, fileName) {
|
|
158
|
+
if (!anchor || !anchor.table) { return null; }
|
|
159
|
+
const digest = anchor.table[fileName];
|
|
160
|
+
return typeof digest === 'string' && HEX64.test(digest) ? digest : null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** sha256 of an artifact amicus already holds in its own heap. */
|
|
164
|
+
function sha256Bytes(bytes) {
|
|
165
|
+
return crypto.createHash('sha256').update(bytes).digest('hex');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* THE GATE, OVER BYTES. NEVER THROWS. `allowed` is the single decision bit.
|
|
170
|
+
*
|
|
171
|
+
* @returns {{verdict:'verified', allowed:true, actual:string}
|
|
172
|
+
* | {verdict:'mismatch', allowed:boolean, expected:string, actual:string}
|
|
173
|
+
* | {verdict:'no-digest', allowed:true}}
|
|
174
|
+
*
|
|
175
|
+
* IT TAKES A BUFFER, AND THE PATH FORM IS GONE. `verifyArtifact({zip, ...})` and
|
|
176
|
+
* `sha256File` were DELETED in the v4.9.6 second council round, not deprecated.
|
|
177
|
+
* Hashing a path and then handing that path to an extractor is the race three
|
|
178
|
+
* seats filed against v4.9.5, and hashing a private COPY of it is the race a
|
|
179
|
+
* fourth seat filed against the remedy — a same-uid attacker opens the copy too
|
|
180
|
+
* (MEASURED). Leaving a path-hashing gate exported and callable is an invitation
|
|
181
|
+
* to reintroduce it, and no caller is left that could legitimately want one. The
|
|
182
|
+
* bytes now arrive from `electron-custody.readArtifactBytes`, which reads them
|
|
183
|
+
* once through one descriptor, and the SAME Buffer is what `zip-from-buffer`
|
|
184
|
+
* extracts.
|
|
185
|
+
*
|
|
186
|
+
* `unreadable` disappeared with the path form. Unreadability is decided BEFORE
|
|
187
|
+
* any hashing now, by `readArtifactBytes`, and the caller refuses there — bytes
|
|
188
|
+
* that could not be read never reach this function, so there is no verdict for
|
|
189
|
+
* them to carry.
|
|
190
|
+
*
|
|
191
|
+
* `no-digest` is ALLOWED and merely marked. An electron package that predates
|
|
192
|
+
* `checksums.json` has no anchor through no fault of its own, and refusing it
|
|
193
|
+
* would push that machine into a permanent re-download loop for a file no
|
|
194
|
+
* download can improve. THE NOTE BELOW IS THE CACHE ROUTE'S stderr line, the one
|
|
195
|
+
* `docs/troubleshooting.md` promises; the download route prints its own.
|
|
196
|
+
*/
|
|
197
|
+
function verifyArtifactBytes({ bytes, anchor, fileName, policy = {}, log = () => {} }) {
|
|
198
|
+
const expected = expectedDigest(anchor, fileName);
|
|
199
|
+
if (!expected) {
|
|
200
|
+
log(`[amicus] NOTE: no published sha256 for ${fileName} (this electron package ships no`);
|
|
201
|
+
log('[amicus] checksums.json entry for it), so its bytes could not be verified.');
|
|
202
|
+
return { verdict: 'no-digest', allowed: true };
|
|
203
|
+
}
|
|
204
|
+
const actual = sha256Bytes(bytes);
|
|
205
|
+
if (actual === expected) { return { verdict: 'verified', allowed: true, actual }; }
|
|
206
|
+
if (policy.allowUnverified) {
|
|
207
|
+
log(`[amicus] WARNING: AMICUS_ALLOW_UNVERIFIED_ELECTRON=1 — accepting ${fileName} even though`);
|
|
208
|
+
log(`[amicus] its sha256 ${actual} does not match the published ${expected}.`);
|
|
209
|
+
log('[amicus] Unset that variable to fail closed.');
|
|
210
|
+
return { verdict: 'mismatch', allowed: true, expected, actual };
|
|
211
|
+
}
|
|
212
|
+
return { verdict: 'mismatch', allowed: false, expected, actual };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
module.exports = {
|
|
216
|
+
electronTrustPolicy,
|
|
217
|
+
resolveAnchor,
|
|
218
|
+
expectedDigest,
|
|
219
|
+
verifyArtifactBytes,
|
|
220
|
+
sha256Bytes,
|
|
221
|
+
artifactFileName,
|
|
222
|
+
normalizeV,
|
|
223
|
+
// RE-EXPORTED from ./electron-env-scrub — the same function objects, not copies.
|
|
224
|
+
isRepoPlantedName,
|
|
225
|
+
REPO_ENV_PREFIXES,
|
|
226
|
+
};
|
package/src/sidecar/unzip.js
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
* the exact electron zip the field box choked on.
|
|
18
18
|
* 3. Only report success when files actually landed on disk. The electron
|
|
19
19
|
* exe-stat verify stays upstream (electron-quarantine.verifyExtractOutcome).
|
|
20
|
+
* Layer 2 has ONE exception: a path-traversal REFUSAL is terminal and is never
|
|
21
|
+
* retried natively (UNSAFE_PATTERNS below).
|
|
20
22
|
*
|
|
21
23
|
* Everything network/spawn/timer-facing is dependency-INJECTABLE so tests never
|
|
22
24
|
* hit the real clock, spawn a real process, or extract a real binary.
|
|
@@ -28,6 +30,41 @@ const path = require('path');
|
|
|
28
30
|
const fsDefault = require('fs');
|
|
29
31
|
const { spawnSync } = require('child_process');
|
|
30
32
|
|
|
33
|
+
// F5: refusals quote the ARCHIVE'S OWN entry name, so every message built from
|
|
34
|
+
// one is sanitized before it reaches stderr or an Error a caller prints.
|
|
35
|
+
const { collapseExcerpt } = require('../utils/text-sanitize');
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A SECURITY REFUSAL IS A REFUSAL, NOT A RETRY (M9).
|
|
39
|
+
*
|
|
40
|
+
* Strategy 1 used to collapse a stall, a plain throw, and extract-zip's / yauzl's
|
|
41
|
+
* own path-traversal refusals into ONE branch that cleans the directory and re-runs
|
|
42
|
+
* the IDENTICAL archive through OS extractors amicus does not control — laundering
|
|
43
|
+
* a "this archive tried to escape its directory" into an unsupervised retry.
|
|
44
|
+
*
|
|
45
|
+
* These four strings are verified against the installed versions:
|
|
46
|
+
* extract-zip@2.0.1 raises `Out of bound path "<dir>" found while processing file <n>`
|
|
47
|
+
* yauzl@2.10.0 validateFileName returns the other three, raised as new Error(msg).
|
|
48
|
+
* NOTE: with strictFileNames unset (extract-zip's default) yauzl rewrites
|
|
49
|
+
* backslashes before validating, so `invalid characters in fileName: ` is not
|
|
50
|
+
* reachable through extract-zip today. Classified anyway — it costs one line and
|
|
51
|
+
* yauzl's defaults can change.
|
|
52
|
+
*
|
|
53
|
+
* DELIBERATELY NARROW. A stall must still fall back, or the Node-24 workaround
|
|
54
|
+
* this whole module exists for is destroyed.
|
|
55
|
+
*
|
|
56
|
+
* AND THE VERIFICATION NO LONGER DECAYS (v4.9.6 F4). "Verified against the
|
|
57
|
+
* installed versions" had nothing keeping it true; tests/sidecar/unzip-refusal-strings.js
|
|
58
|
+
* now drives the INSTALLED libraries into producing all four for real, and fails
|
|
59
|
+
* both on a reworded refusal and on a pattern no real message produces.
|
|
60
|
+
*/
|
|
61
|
+
const UNSAFE_PATTERNS = [
|
|
62
|
+
/^Out of bound path /,
|
|
63
|
+
/^absolute path: /,
|
|
64
|
+
/^invalid relative path: /,
|
|
65
|
+
/^invalid characters in fileName: /,
|
|
66
|
+
];
|
|
67
|
+
|
|
31
68
|
// No-progress window: if extract-zip reports no new entry for this long AND has
|
|
32
69
|
// not settled, treat it as the silent stall. Reset on every onEntry so a slow-
|
|
33
70
|
// but-progressing extract is never falsely aborted.
|
|
@@ -162,6 +199,8 @@ function runExtractZipBounded({ zip, dir, onEntry, extractZip, idleMs, maxMs, se
|
|
|
162
199
|
* @param {object} [opts.deps] injected { fs, extractZip, spawn, setTimeout, clearTimeout, log }
|
|
163
200
|
* @returns {Promise<{strategy:string, fallback?:boolean, extractZipReason?:string}>}
|
|
164
201
|
* @throws {Error} code 'UNZIP_ALL_FAILED' when no strategy produced files.
|
|
202
|
+
* @throws {Error} code 'UNZIP_UNSAFE_ARCHIVE' when the archive was REFUSED for a
|
|
203
|
+
* path-traversal attempt — terminal, with no native retry (see UNSAFE_PATTERNS).
|
|
165
204
|
*/
|
|
166
205
|
async function robustExtract(zip, opts = {}) {
|
|
167
206
|
const {
|
|
@@ -202,8 +241,18 @@ async function robustExtract(zip, opts = {}) {
|
|
|
202
241
|
return { strategy: 'extract-zip' };
|
|
203
242
|
}
|
|
204
243
|
|
|
244
|
+
// TERMINAL: the archive was REFUSED for trying to write outside `dir`. Never
|
|
245
|
+
// cleanDir (the partial output is evidence), never fall back — handing the same
|
|
246
|
+
// archive to tar/Expand-Archive would ask a tool with no such check to do what
|
|
247
|
+
// extract-zip just declined to.
|
|
248
|
+
if (!z.ok && UNSAFE_PATTERNS.some((p) => p.test(z.reason || ''))) {
|
|
249
|
+
const err = new Error(`refusing to extract ${zip}: ${collapseExcerpt(z.reason)}`);
|
|
250
|
+
err.code = 'UNZIP_UNSAFE_ARCHIVE';
|
|
251
|
+
throw err;
|
|
252
|
+
}
|
|
253
|
+
|
|
205
254
|
// extract-zip stalled / threw / produced nothing → clean partial output, go native.
|
|
206
|
-
const zipReason = z.ok ? 'extract-zip produced no files' : z.reason;
|
|
255
|
+
const zipReason = z.ok ? 'extract-zip produced no files' : collapseExcerpt(z.reason);
|
|
207
256
|
cleanDir(fs, dir);
|
|
208
257
|
log(`[amicus] extract-zip did not complete (${zipReason}); falling back to native unzip.`);
|
|
209
258
|
|
|
@@ -243,4 +292,5 @@ async function robustExtract(zip, opts = {}) {
|
|
|
243
292
|
throw err;
|
|
244
293
|
}
|
|
245
294
|
|
|
246
|
-
|
|
295
|
+
// UNSAFE_PATTERNS is exported for the F4 upstream-drift probe (see its docblock).
|
|
296
|
+
module.exports = { robustExtract, nativeUnzipPlan, IDLE_MS, MAX_MS, UNSAFE_PATTERNS };
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE ENTRY of an in-memory archive, and the classified failures every caller
|
|
3
|
+
* acts on.
|
|
4
|
+
*
|
|
5
|
+
* SPLIT OUT of zip-from-buffer.js in the v4.9.6 third round: that file reached
|
|
6
|
+
* the repo's 300-line gate when the symlink-target control and the stall bound
|
|
7
|
+
* grew, and the seam is real. This module answers "what does one archive entry
|
|
8
|
+
* become on disk, and whose fault is it when that fails"; zip-from-buffer.js
|
|
9
|
+
* answers "how is the archive driven, and when do we give up on it". Nothing
|
|
10
|
+
* here knows about yauzl's event loop and nothing there writes a byte.
|
|
11
|
+
*
|
|
12
|
+
* ── ERROR CODES ARE A CAUSAL CLAIM, AND THE CALLER DELETES ON ONE OF THEM ──
|
|
13
|
+
* `UNZIP_BUFFER_FAILED` = the ARCHIVE is bad; it is the ONLY code that lets
|
|
14
|
+
* `electron-repair-cache` evict a user's cached artifact. `UNZIP_DEST_FAILED` =
|
|
15
|
+
* the DESTINATION is bad (no space, a read-only dist/, a path too long, a
|
|
16
|
+
* machine that cannot represent a symlink). `UNZIP_BUFFER_UNAVAILABLE` =
|
|
17
|
+
* neither; yauzl would not load. Every throw in this module goes through one of
|
|
18
|
+
* the three constructors below, so no unclassified error can reach a caller
|
|
19
|
+
* that reads "unclassified" as "the archive is bad" — council finding D2, and
|
|
20
|
+
* the leak that reopened it (a raw stream error out of `collect`) is closed by
|
|
21
|
+
* `collect` tagging its own rejection.
|
|
22
|
+
*
|
|
23
|
+
* @module sidecar/zip-entry-write
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
const path = require('path');
|
|
29
|
+
const zlib = require('zlib');
|
|
30
|
+
const { pipeline } = require('stream/promises');
|
|
31
|
+
const { Transform } = require('stream');
|
|
32
|
+
|
|
33
|
+
const { collapseExcerpt } = require('../utils/text-sanitize');
|
|
34
|
+
|
|
35
|
+
/** @returns {Error} tagged with `code`, sanitized: every throw here is classified. */
|
|
36
|
+
function failure(code, message) {
|
|
37
|
+
return Object.assign(new Error(collapseExcerpt(message)), { code });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @returns {Error} an archive-is-bad failure: the caller MAY evict the artifact. */
|
|
41
|
+
const badArchive = (message) => failure('UNZIP_BUFFER_FAILED', message);
|
|
42
|
+
|
|
43
|
+
/** @returns {Error} a destination-is-bad failure: the caller must NOT evict anything. */
|
|
44
|
+
const badDestination = (message) => failure('UNZIP_DEST_FAILED', message);
|
|
45
|
+
|
|
46
|
+
/** @returns {Error} the TERMINAL path-traversal refusal, in extract-zip's own
|
|
47
|
+
* `Out of bound path ` wording so unzip.js's UNSAFE_PATTERNS still classifies it. */
|
|
48
|
+
const outOfBound = (where, fileName) => failure('UNZIP_UNSAFE_ARCHIVE', `Out of bound path "${where}" found while processing file ${fileName}`);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @returns {Error} yauzl would not load. NOT an archive failure and NOT a
|
|
52
|
+
* destination failure: the artifact is fine and so is the disk. unzip.js
|
|
53
|
+
* records the v4.5.2 outage where an undeclared `extract-zip` threw
|
|
54
|
+
* MODULE_NOT_FOUND out of a bare `require` and took a whole function with it;
|
|
55
|
+
* `yauzl` is declared for that reason, and this guard is what stops a hoisting
|
|
56
|
+
* surprise from turning into a DELETED cache entry.
|
|
57
|
+
*/
|
|
58
|
+
const extractorUnavailable = (message) => failure('UNZIP_BUFFER_UNAVAILABLE', `the in-memory zip extractor is unavailable: ${message}`);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Collect a readable fully into one Buffer (a symlink target is a few bytes).
|
|
62
|
+
*
|
|
63
|
+
* CLASSIFIED, like every other throw here. This was the ONE throw site that
|
|
64
|
+
* rejected with the RAW yauzl/stream error, which carries no `code` — and
|
|
65
|
+
* `electron-repair-cache` read an unclassified extract failure as "the archive
|
|
66
|
+
* is bad" and DELETED the user's cached artifact. A read error on a symlink
|
|
67
|
+
* target IS an archive-side failure, so it is named as one rather than left to
|
|
68
|
+
* be guessed at.
|
|
69
|
+
*/
|
|
70
|
+
function collect(stream, what) {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const chunks = [];
|
|
73
|
+
stream.on('data', (c) => chunks.push(c));
|
|
74
|
+
stream.on('error', (e) => reject(badArchive(`could not read ${what}: ${(e && e.message) || e}`)));
|
|
75
|
+
stream.on('end', () => resolve(Buffer.concat(chunks)));
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One entry's payload stream, decompressed by yauzl exactly as extract-zip gets it. */
|
|
80
|
+
function entryStream(zipfile, entry) {
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
zipfile.openReadStream(entry, (err, stream) => {
|
|
83
|
+
if (err) { reject(badArchive(`${entry.fileName}: ${(err && err.message) || err}`)); return; }
|
|
84
|
+
resolve(stream);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Write one entry, checking its CRC-32 against the archive's own declaration.
|
|
91
|
+
* An INTEGRITY check, not a security control (CRC-32 is linear and forgeable):
|
|
92
|
+
* it catches a broken zlib or bad RAM, and is meaningful only because the
|
|
93
|
+
* whole-buffer sha256 already ran on these exact bytes. yauzl checks no CRC at
|
|
94
|
+
* all; `validateEntrySizes: true` covers the length half.
|
|
95
|
+
*
|
|
96
|
+
* `onBytes` AND `signal` ARE THE STALL BOUND'S TWO HALVES (round 3, seat A1 +
|
|
97
|
+
* B2), and both live here because this is the only function that writes.
|
|
98
|
+
*
|
|
99
|
+
* `onBytes(n)` reports WRITE PROGRESS, which is what the bound is armed against
|
|
100
|
+
* — the caller re-arms on bytes, never on entries, so a single 225 MB
|
|
101
|
+
* `electron.exe` on slow storage cannot look idle. It fires from the CRC
|
|
102
|
+
* transform, the stage immediately upstream of the sink, so it reports bytes the
|
|
103
|
+
* DESTINATION has accepted rather than bytes read out of the buffer. MEASURED
|
|
104
|
+
* (Node 24.18.0) against a sink whose `_write` never calls back: the counter
|
|
105
|
+
* stops, overshooting the sink by exactly one 64 KiB readable-side highWaterMark
|
|
106
|
+
* and no more, whether the wedge happens after 64 KiB or after 640 KiB. A hung
|
|
107
|
+
* destination therefore still stops the counter, which is what makes a genuine
|
|
108
|
+
* stall catchable.
|
|
109
|
+
*
|
|
110
|
+
* `signal` is what makes the bound STOP the work rather than merely report it.
|
|
111
|
+
* `pipeline` destroys every stream on abort — MEASURED: source and sink both
|
|
112
|
+
* `destroyed`, not one further byte counted or accepted — so no write can land
|
|
113
|
+
* after the caller has given up and started cleaning the incoming tree. The
|
|
114
|
+
* rejection it produces is a plain `AbortError` (`pipeline` does not carry the
|
|
115
|
+
* abort reason), which is why the caller keeps its own classified failure and
|
|
116
|
+
* discards this one.
|
|
117
|
+
*/
|
|
118
|
+
async function writeEntry({ zipfile, entry, dest, mode, fs, onBytes, signal }) {
|
|
119
|
+
const source = await entryStream(zipfile, entry);
|
|
120
|
+
let crc = 0;
|
|
121
|
+
// Accumulated by a TRANSFORM in the pipeline, never by a `data` listener:
|
|
122
|
+
// attaching one starts the flow before `pipeline` has piped it, losing bytes.
|
|
123
|
+
const crcThrough = new Transform({
|
|
124
|
+
transform(chunk, _enc, cb) {
|
|
125
|
+
crc = zlib.crc32(chunk, crc);
|
|
126
|
+
if (onBytes) { onBytes(chunk.length); }
|
|
127
|
+
cb(null, chunk);
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
let sink;
|
|
131
|
+
try {
|
|
132
|
+
sink = fs.createWriteStream(dest, { mode });
|
|
133
|
+
} catch (e) {
|
|
134
|
+
throw badDestination(`could not write ${entry.fileName}: ${(e && e.message) || e}`);
|
|
135
|
+
}
|
|
136
|
+
// WHICH SIDE FAILED FIRST is the causal claim the caller acts on (D2): a bad
|
|
137
|
+
// archive may be evicted, a full disk must never be. `pipeline` destroys the
|
|
138
|
+
// other half after the first error, so both ends usually end up emitting —
|
|
139
|
+
// only the FIRST one recorded says what actually happened.
|
|
140
|
+
let first = null;
|
|
141
|
+
const note = (from) => (e) => { if (!first) { first = { from, e }; } };
|
|
142
|
+
source.on('error', note('archive'));
|
|
143
|
+
sink.on('error', note('dest'));
|
|
144
|
+
try {
|
|
145
|
+
await pipeline(source, crcThrough, sink, ...(signal ? [{ signal }] : []));
|
|
146
|
+
} catch (e) {
|
|
147
|
+
const cause = first ? first.e : e;
|
|
148
|
+
const detail = `${entry.fileName}: ${(cause && cause.message) || cause}`;
|
|
149
|
+
throw first && first.from === 'dest'
|
|
150
|
+
? badDestination(`could not write ${detail}`)
|
|
151
|
+
: badArchive(`could not inflate ${detail}`);
|
|
152
|
+
}
|
|
153
|
+
if ((crc >>> 0) !== (entry.crc32 >>> 0)) {
|
|
154
|
+
throw badArchive(`crc32 mismatch for ${entry.fileName}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Create one symlink, refusing a target that resolves outside `root`.
|
|
160
|
+
*
|
|
161
|
+
* `canonical` is `realpathSync` of the directory the link is ACTUALLY created
|
|
162
|
+
* in — never `path.dirname(dest)`. That distinction is the whole control, and
|
|
163
|
+
* getting it wrong was MEASURED to defeat the check completely: three entries
|
|
164
|
+
* naming `L0`, `L0/L1`, `L0/L1/L2`, each a symlink to `.`, make the LEXICAL
|
|
165
|
+
* dirname `<root>/L0/L1/L2` three levels deeper than the real one (`<root>`),
|
|
166
|
+
* so a fourth entry `L0/L1/L2/x -> ../../../victim` resolved to `<root>/victim`
|
|
167
|
+
* — inside, accepted — while the link really landed at `<root>/../../../victim`.
|
|
168
|
+
* Escape depth tracked chain length 1:1. Every one of those names passes
|
|
169
|
+
* yauzl's `validateFileName`, so one ordinary-looking archive was arbitrary
|
|
170
|
+
* same-user file write outside the extraction root.
|
|
171
|
+
*
|
|
172
|
+
* WHAT THIS DOES AND DOES NOT PROMISE. It resolves the target from the real
|
|
173
|
+
* directory and refuses anything that leaves `root`; it does NOT follow
|
|
174
|
+
* symlinks inside the target's own intermediate components, so a link pointing
|
|
175
|
+
* at an in-root path that some LATER entry turns into a link elsewhere is not
|
|
176
|
+
* caught here — that shape is caught by the per-entry `realpath` bound check in
|
|
177
|
+
* `placeEntry`, which re-runs after every earlier entry has been written.
|
|
178
|
+
*
|
|
179
|
+
* `signal` is checked immediately before the `symlinkSync`. A link target is a
|
|
180
|
+
* handful of bytes read through `collect`, which is not a `pipeline` and so is
|
|
181
|
+
* not destroyed by the abort — without this check the stall bound could fire and
|
|
182
|
+
* a symlink still appear in the tree the caller is about to delete.
|
|
183
|
+
*/
|
|
184
|
+
async function writeSymlink({ zipfile, entry, canonical, root, fs, signal }) {
|
|
185
|
+
const target = (await collect(await entryStream(zipfile, entry), `the symlink target for ${entry.fileName}`)).toString('utf8');
|
|
186
|
+
const dest = path.join(canonical, path.basename(entry.fileName));
|
|
187
|
+
const resolved = path.resolve(canonical, target);
|
|
188
|
+
const rel = path.relative(root, resolved);
|
|
189
|
+
// SYMLINKESCAPE: `..` at the head, or an absolute answer (a different Windows
|
|
190
|
+
// drive), means the link points out of the tree amicus is allowed to write.
|
|
191
|
+
if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
|
|
192
|
+
throw outOfBound(resolved, entry.fileName);
|
|
193
|
+
}
|
|
194
|
+
if (signal && signal.aborted) { return; }
|
|
195
|
+
try {
|
|
196
|
+
fs.symlinkSync(target, dest);
|
|
197
|
+
} catch (e) {
|
|
198
|
+
throw badDestination(`could not create the symlink ${entry.fileName}: ${(e && e.message) || e}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** stat mode constants, as extract-zip decodes them from externalFileAttributes. */
|
|
203
|
+
const IFMT = 61440;
|
|
204
|
+
const IFDIR = 16384;
|
|
205
|
+
const IFLNK = 40960;
|
|
206
|
+
|
|
207
|
+
/** extract-zip's getExtractedMode, with its 0755/0644 defaults. */
|
|
208
|
+
function extractedMode(entryMode, isDir) {
|
|
209
|
+
if (entryMode !== 0) { return entryMode; }
|
|
210
|
+
return isDir ? 0o755 : 0o644;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* One entry, mirroring extract-zip's Extractor.extractEntry decision order.
|
|
215
|
+
*
|
|
216
|
+
* MOVED HERE from zip-from-buffer.js in the third council round, when the stall
|
|
217
|
+
* bound's repair pushed that file back over the 300-line gate. It is not a
|
|
218
|
+
* convenience move: this function answers "what does one archive entry become on
|
|
219
|
+
* disk, and whose fault is it when that fails", which is this module's whole
|
|
220
|
+
* subject, and it calls nothing but this module's own writers. What stays next
|
|
221
|
+
* door is the archive DRIVER — yauzl's event loop, the bound, and the decision
|
|
222
|
+
* to give up.
|
|
223
|
+
*
|
|
224
|
+
* `signal` is the stall bound's halt: an entry that arrives after the bound
|
|
225
|
+
* fired creates NOTHING — not the directory, not the file — because the caller
|
|
226
|
+
* is already deleting the incoming tree.
|
|
227
|
+
* @returns {Promise<boolean>} true if the entry was placed (false = skipped)
|
|
228
|
+
*/
|
|
229
|
+
async function placeEntry({ zipfile, entry, root, fs, signal, onBytes }) {
|
|
230
|
+
// Nothing is created for an entry that arrives after the bound fired.
|
|
231
|
+
if (signal && signal.aborted) { return false; }
|
|
232
|
+
if (entry.fileName.startsWith('__MACOSX/')) { return false; }
|
|
233
|
+
if (entry.isEncrypted()) { throw badArchive(`${entry.fileName} is encrypted`); }
|
|
234
|
+
const dest = path.join(root, entry.fileName);
|
|
235
|
+
const mode = (entry.externalFileAttributes >> 16) & 0xFFFF;
|
|
236
|
+
const symlink = (mode & IFMT) === IFLNK;
|
|
237
|
+
let isDir = (mode & IFMT) === IFDIR;
|
|
238
|
+
if (!isDir && entry.fileName.endsWith('/')) { isDir = true; }
|
|
239
|
+
if (!isDir) { isDir = ((entry.versionMadeBy >> 8) === 0 && entry.externalFileAttributes === 16); }
|
|
240
|
+
const procMode = extractedMode(mode, isDir) & 0o777;
|
|
241
|
+
const destDir = isDir ? dest : path.dirname(dest);
|
|
242
|
+
let canonical;
|
|
243
|
+
try {
|
|
244
|
+
fs.mkdirSync(destDir, isDir ? { recursive: true, mode: procMode } : { recursive: true });
|
|
245
|
+
canonical = fs.realpathSync(destDir);
|
|
246
|
+
} catch (e) {
|
|
247
|
+
throw badDestination(`could not create ${destDir}: ${(e && e.message) || e}`);
|
|
248
|
+
}
|
|
249
|
+
// extract-zip's check, VERBATIM — re-run per entry, AFTER earlier entries were
|
|
250
|
+
// written, so a symlink an earlier entry created cannot redirect a later one.
|
|
251
|
+
if (path.relative(root, canonical).split(path.sep).includes('..')) {
|
|
252
|
+
throw outOfBound(canonical, entry.fileName);
|
|
253
|
+
}
|
|
254
|
+
if (isDir) { return true; }
|
|
255
|
+
if (symlink) {
|
|
256
|
+
// `canonical`, NEVER `dest`: the link's target is resolved against the
|
|
257
|
+
// directory realpath says it is created in (see writeSymlink).
|
|
258
|
+
await writeSymlink({ zipfile, entry, canonical, root, fs, signal });
|
|
259
|
+
} else {
|
|
260
|
+
await writeEntry({ zipfile, entry, dest, mode: procMode, fs, onBytes, signal });
|
|
261
|
+
}
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
module.exports = {
|
|
266
|
+
failure, badArchive, badDestination, outOfBound, extractorUnavailable,
|
|
267
|
+
collect, entryStream, writeEntry, writeSymlink, placeEntry,
|
|
268
|
+
};
|