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.
@@ -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
+ };
@@ -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
- module.exports = { robustExtract, nativeUnzipPlan, IDLE_MS, MAX_MS };
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
+ };