amicus 4.9.5 → 4.9.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +238 -0
- package/README.md +1 -1
- package/docs/ROADMAP.md +4 -4
- package/docs/architecture-map.md +36 -7
- package/docs/configuration.md +119 -11
- package/docs/electron-testing.md +133 -0
- package/docs/troubleshooting.md +103 -3
- package/docs/usage.md +1 -1
- package/package.json +2 -1
- package/scripts/postinstall.js +24 -3
- package/src/sidecar/electron-cache.js +41 -1
- package/src/sidecar/electron-custody.js +180 -0
- package/src/sidecar/electron-ensure.js +25 -4
- package/src/sidecar/electron-env-scrub.js +233 -0
- package/src/sidecar/electron-exe-rel.js +131 -0
- package/src/sidecar/electron-install.js +120 -135
- package/src/sidecar/electron-layout.js +300 -0
- package/src/sidecar/electron-native-plan.js +175 -0
- package/src/sidecar/electron-native-rescue.js +270 -0
- package/src/sidecar/electron-provision.js +148 -95
- package/src/sidecar/electron-refuse.js +253 -0
- package/src/sidecar/electron-repair-cache.js +212 -0
- package/src/sidecar/electron-rescue-notice.js +95 -0
- package/src/sidecar/electron-trust.js +45 -118
- package/src/sidecar/unzip.js +13 -3
- package/src/sidecar/zip-entry-write.js +268 -0
- package/src/sidecar/zip-from-buffer.js +231 -0
- package/src/sidecar/zip-local-name-scan.js +238 -0
- package/src/sidecar/zip-name-scan.js +146 -0
- package/src/sidecar/zip-stall-bound.js +144 -0
- package/src/utils/doctor-electron-mcp-check.js +9 -2
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ENV SCRUB — which environment names a hostile REPOSITORY can plant.
|
|
3
|
+
*
|
|
4
|
+
* WHAT THIS MODULE LOST, AND WHY (v4.9.6 second round, council seat B1).
|
|
5
|
+
* It used to build a scrubbed environment for the last-resort `install.js`
|
|
6
|
+
* SPAWN. That spawn is gone — it bypassed the custody and digest gate entirely
|
|
7
|
+
* — so `scrubbedChildEnv` and the `ELECTRON_INSTALL_TARGET_*` artifact selectors
|
|
8
|
+
* went with it: there is no child process left to hand an environment to. The
|
|
9
|
+
* measured enumeration they encoded is kept below as the record it is.
|
|
10
|
+
* `isRepoPlantedName` survives and is now used by the IN-PROCESS download scrub
|
|
11
|
+
* (electron-provision.js), which is the surface seat D5 filed against.
|
|
12
|
+
*
|
|
13
|
+
* SPLIT OUT of electron-trust.js (v4.9.6 F2). That module owns three things —
|
|
14
|
+
* the digest anchor, the gate, and this scrub — and sat at 299 of the repo's
|
|
15
|
+
* 300-line limit, so the F2 repair had nowhere to go. electron-trust.js
|
|
16
|
+
* RE-EXPORTS every name below, so existing imports keep working and the split is
|
|
17
|
+
* invisible to callers. This module is a LEAF (no requires at all); the arrow is
|
|
18
|
+
* electron-install -> electron-provision -> electron-trust -> electron-env-scrub
|
|
19
|
+
* and must never point back.
|
|
20
|
+
*
|
|
21
|
+
* The trust boundary it encodes: a hostile REPOSITORY (a clone the user opens,
|
|
22
|
+
* an unpacked sample) controls the `.npmrc` and `package.json` of the directory
|
|
23
|
+
* amicus's own docs tell people to run `npx -y amicus@latest` in. MEASURED (npm
|
|
24
|
+
* 11.16.0): that reaches the child as exactly two name shapes —
|
|
25
|
+
* `npm_config_<key lowercased>` and `npm_package_config_<key case-preserved>` —
|
|
26
|
+
* and NOTHING else. Bare `ELECTRON_*` and `AMICUS_*` names are out of its reach,
|
|
27
|
+
* which is why the escape hatch is a plain environment variable and why a
|
|
28
|
+
* machine-level `ELECTRON_MIRROR` is still honoured.
|
|
29
|
+
*
|
|
30
|
+
* @module sidecar/electron-env-scrub
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
'use strict';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Env-name PREFIXES an untrusted REPOSITORY can plant for the mirror knobs.
|
|
37
|
+
*
|
|
38
|
+
* PREFIXES, not a hand-maintained name list. RE-ENUMERATED EXHAUSTIVELY for
|
|
39
|
+
* v4.9.6 F2 against `@electron/get` 5.0.0 (`dist/artifact-utils.js`, lines 20-35):
|
|
40
|
+
* `mirrorVar(name)` is called for exactly five names — `mirror`, `nightlyMirror`,
|
|
41
|
+
* `customDir`, `customFilename`, `customVersion` — and reads six spellings of
|
|
42
|
+
* each, thirty names in all:
|
|
43
|
+
* 1. `npm_config_electron_<name.toLowerCase()>` .npmrc
|
|
44
|
+
* 2. `NPM_CONFIG_ELECTRON_<SNAKE_UPPER>` .npmrc, npm's own casing
|
|
45
|
+
* 3. `npm_config_electron_<snake_lower>` .npmrc
|
|
46
|
+
* 4. `npm_package_config_electron_<name>` (case KEPT) package.json "config"
|
|
47
|
+
* 5. `npm_package_config_electron_<snake_lower>` package.json "config"
|
|
48
|
+
* 6. `ELECTRON_<SNAKE_UPPER>` plain env
|
|
49
|
+
* Rows 1-5 — twenty-five names — all begin with one of the two prefixes below
|
|
50
|
+
* under a case fold, so the prefixes cover every repo-reachable mirror knob
|
|
51
|
+
* exactly. Row 6 (`ELECTRON_MIRROR`, `ELECTRON_NIGHTLY_MIRROR`,
|
|
52
|
+
* `ELECTRON_CUSTOM_DIR`, `ELECTRON_CUSTOM_FILENAME`, `ELECTRON_CUSTOM_VERSION`)
|
|
53
|
+
* is BARE and therefore the machine owner's, and is deliberately kept.
|
|
54
|
+
*
|
|
55
|
+
* The prefixes also cover electron's own
|
|
56
|
+
* `npm_config_electron_use_remote_checksums` (install.js lines 47-50 — that name
|
|
57
|
+
* turns electron's bundled pin OFF), plus any knob a future @electron/get adds in
|
|
58
|
+
* the same namespace. Contrast ENGINE_CREDENTIAL_ENV
|
|
59
|
+
* (scripts/run-integration-keyless.js:101), whose own docblock warns that nothing
|
|
60
|
+
* makes a name list follow an upstream bump.
|
|
61
|
+
*
|
|
62
|
+
* The BARE `electron_use_remote_checksums` is deliberately NOT removed: a bare
|
|
63
|
+
* lower-case name is not repo-injectable, so it carries the machine owner's
|
|
64
|
+
* intent, exactly like a bare `ELECTRON_MIRROR`.
|
|
65
|
+
*
|
|
66
|
+
* MATCHED CASE-INSENSITIVELY. This used to fold no case, on the claim that
|
|
67
|
+
* because the Windows environment block is case-insensitive, deleting the
|
|
68
|
+
* lower-case name also removed the `NPM_CONFIG_ELECTRON_*` view @electron/get
|
|
69
|
+
* reads second. That is true of `process.env` and FALSE of the `{...env}` PLAIN
|
|
70
|
+
* OBJECT this module actually deletes from — a plain object is case-sensitive on
|
|
71
|
+
* every platform, so the upper-case key survived and was handed to the child.
|
|
72
|
+
* RE-MEASURED (npm 11.16.0, Windows 11) — two ways a repository reaches an
|
|
73
|
+
* upper-case slot:
|
|
74
|
+
* 1. `.npmrc` `electron_mirror=…` while `NPM_CONFIG_ELECTRON_MIRROR` already
|
|
75
|
+
* exists in the environment: npm overwrites that slot's VALUE and never
|
|
76
|
+
* renames it, so the child sees the ATTACKER's URL under the upper-case name.
|
|
77
|
+
* 2. `package.json` `"config": {"ELECTRON_MIRROR": …}`: npm PRESERVES the key's
|
|
78
|
+
* case, planting `npm_package_config_ELECTRON_MIRROR` with nothing
|
|
79
|
+
* pre-existing at all — and @electron/get's own lookup for
|
|
80
|
+
* `npm_package_config_electron_mirror` (row 5 above) finds it, because the
|
|
81
|
+
* Windows lookup is case-insensitive too.
|
|
82
|
+
* The old docblock's POSIX half (`NPM_CONFIG_ELECTRON_*` is a distinct variable
|
|
83
|
+
* npm never writes there, so it is the machine owner's) is NOT measurable from
|
|
84
|
+
* this machine, and it is load-bearing in the fail-OPEN direction: wrong, it
|
|
85
|
+
* hands the child an attacker's mirror. Wrong the other way it costs one
|
|
86
|
+
* alternate spelling inside a last-resort spawn, while bare `ELECTRON_MIRROR`
|
|
87
|
+
* — which @electron/get ranks FIRST — still carries owner intent. So the fold is
|
|
88
|
+
* unconditional rather than resting on an unverified platform claim.
|
|
89
|
+
*/
|
|
90
|
+
const REPO_ENV_PREFIXES = ['npm_config_electron_', 'npm_package_config_electron_'];
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* THE ARTIFACT SELECTORS ARE GONE WITH THE SPAWN, and this is the record of what
|
|
94
|
+
* they were, because it was measured and a later change may need it.
|
|
95
|
+
*
|
|
96
|
+
* `ELECTRON_INSTALL_TARGET_ENV` held `npm_config_platform` / `npm_config_arch`
|
|
97
|
+
* and their `npm_package_config_*` spellings (council B2 added the second pair).
|
|
98
|
+
* They mattered because they chose WHICH artifact `install.js` fetched. Nothing
|
|
99
|
+
* spawns install.js any more (seat B1), and amicus's own downloader is passed
|
|
100
|
+
* `platform` and `arch` as ARGUMENTS, so no environment name can choose them.
|
|
101
|
+
*
|
|
102
|
+
* RE-ENUMERATED EXHAUSTIVELY, before the deletion, against the installed
|
|
103
|
+
* `node_modules/electron` (43.1.1) `install.js`. Every `process.env` read there:
|
|
104
|
+
* ELECTRON_INSTALL_PLATFORM (20, 99) bare — amicus used to SET it
|
|
105
|
+
* npm_config_platform (20, 99) REPO-PLANTABLE
|
|
106
|
+
* ELECTRON_INSTALL_ARCH (21) bare — amicus used to SET it
|
|
107
|
+
* npm_config_arch (21, 27) REPO-PLANTABLE
|
|
108
|
+
* force_no_cache (45) bare
|
|
109
|
+
* electron_config_cache (46) bare — the machine owner's cache root
|
|
110
|
+
* electron_use_remote_checksums (48) bare — the owner's
|
|
111
|
+
* npm_config_electron_use_remote_checksums (48) covered by the prefixes above
|
|
112
|
+
* ELECTRON_OVERRIDE_DIST_PATH (73, 80) bare
|
|
113
|
+
* install.js 43.1.1 did NOT itself read `npm_package_config_platform` / `-arch`.
|
|
114
|
+
*
|
|
115
|
+
* WHAT THE IN-PROCESS SCRUB NEEDS is only the prefixes: `@electron/get` 5.0.0
|
|
116
|
+
* reads `mirror`, `nightlyMirror`, `customDir`, `customFilename` and
|
|
117
|
+
* `customVersion` under `npm_config_electron_*` / `npm_package_config_electron_*`
|
|
118
|
+
* (dist/artifact-utils.js, lines 20-35) and takes platform and arch as call
|
|
119
|
+
* arguments. So `isRepoPlantedName` covers the download surface exactly.
|
|
120
|
+
*/
|
|
121
|
+
|
|
122
|
+
/** True for a name a hostile repository could have planted, in ANY case (see above). */
|
|
123
|
+
function isRepoPlantedName(name) {
|
|
124
|
+
return REPO_ENV_PREFIXES.some((prefix) => String(name).toLowerCase().startsWith(prefix));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Run `fn` with every repo-plantable electron name DELETED from `env`, restoring
|
|
129
|
+
* each one before returning — whether `fn` returned a value, returned a promise,
|
|
130
|
+
* or threw (council seat D5).
|
|
131
|
+
*
|
|
132
|
+
* THE HOLE THIS CLOSES. The v4.9.6 mirror-knob scrub covered only the
|
|
133
|
+
* last-resort `install.js` SPAWN, and amicus's own controlled download runs
|
|
134
|
+
* `@electron/get` IN THIS PROCESS, reading `process.env` directly. So a hostile
|
|
135
|
+
* repository could still point amicus's own download at its mirror. It was filed
|
|
136
|
+
* as a nit because the digest pin refuses the redirected bytes anyway — this is
|
|
137
|
+
* a wasted download, not a compromise — but a stated threat model that is wider
|
|
138
|
+
* than the code is its own defect.
|
|
139
|
+
*
|
|
140
|
+
* ── WHAT IT COVERS, MEASURED RATHER THAN REASONED (round 3, seat B1) ───────
|
|
141
|
+
* The previous version of this docblock ARGUED, from a source read, that every
|
|
142
|
+
* repo-plantable name is read in `downloadArtifact`'s synchronous prefix. Seat
|
|
143
|
+
* B1 called that "an unverified invariant about @electron/get internals" and
|
|
144
|
+
* was right to: the argument had never been run, and the scrub pattern was
|
|
145
|
+
* copied from `utils/engine-output-flag.js :: withOutputTokenFlag`, where it is
|
|
146
|
+
* correct only because the thing it guards reads the env synchronously at spawn.
|
|
147
|
+
*
|
|
148
|
+
* So it was MEASURED, against the INSTALLED @electron/get 5.0.0 (Node 24.18.0,
|
|
149
|
+
* Windows 11), by replacing `process.env` with a recording Proxy and driving a
|
|
150
|
+
* real `downloadArtifact` with an injected offline downloader:
|
|
151
|
+
*
|
|
152
|
+
* PINNED call (amicus's normal route — a `checksums` table goes out)
|
|
153
|
+
* 20 repo-plantable reads, ALL INSIDE the scrub window; 0 after the restore.
|
|
154
|
+
* Four knobs on a stable version (`customVersion` from `getArtifactVersion`,
|
|
155
|
+
* then `mirror`, `customDir`, `customFilename` from `getArtifactRemoteURL`)
|
|
156
|
+
* x five repo-reachable spellings; `nightlyMirror` adds five on a nightly.
|
|
157
|
+
* POSITIVE CONTROL, `npm_config_electron_mirror=https://ATTACKER.example/mirror/`:
|
|
158
|
+
* scrubbed -> https://github.com/electron/electron/releases/download/v43.1.1/…
|
|
159
|
+
* unscrubbed -> https://ATTACKER.example/mirror/ATTACKERDIR/ATTACKER.zip
|
|
160
|
+
* The scrub is therefore NOT a no-op: it is what puts that download back on
|
|
161
|
+
* the official URL.
|
|
162
|
+
*
|
|
163
|
+
* UNPINNED call (no anchor, or the hatch dropped the pin — no `checksums`)
|
|
164
|
+
* The same 20 land inside the window and the ARTIFACT still comes from the
|
|
165
|
+
* official URL. Then `validateArtifact` recursively `downloadArtifact`s
|
|
166
|
+
* `SHASUMS256.txt` AFTER awaits, with the environment restored, and reads
|
|
167
|
+
* the planted names back — 13, not 20, because `mirrorVar`'s `||` chain
|
|
168
|
+
* short-circuits as soon as a planted name answers.
|
|
169
|
+
*
|
|
170
|
+
* THE RESIDUAL IS AVAILABILITY-ONLY, and that is why it is documented rather
|
|
171
|
+
* than closed. The zip's URL was already fixed inside the scrub, so a planted
|
|
172
|
+
* mirror cannot substitute the bytes — it can only serve a checksum file that
|
|
173
|
+
* disagrees with the official artifact, which FAILS the download.
|
|
174
|
+
*
|
|
175
|
+
* IT IS STATED IN BOTH PLACES A USER READS IT, which took two rounds. Round 3
|
|
176
|
+
* narrowed `docs/configuration.md` and left the SAME overclaim standing at
|
|
177
|
+
* `docs/troubleshooting.md`, on the very bullet that tells a user to set
|
|
178
|
+
* `AMICUS_ALLOW_UNVERIFIED_ELECTRON=1` — i.e. on the page a user in exactly the
|
|
179
|
+
* UNPINNED configuration is sent to. Round 4 corrected it there too. When a
|
|
180
|
+
* claim about this module is narrowed, grep the docs for the phrasing rather
|
|
181
|
+
* than the file that suggested it.
|
|
182
|
+
*
|
|
183
|
+
* ── NOTHING OUTSIDE CAN OBSERVE IT (seat B3, REFUTED BY MEASUREMENT) ───────
|
|
184
|
+
* B3 read this as mutating shared `process.env` "while the download is still in
|
|
185
|
+
* flight", so "concurrent repairs can interleave". They cannot: delete -> call
|
|
186
|
+
* -> restore contains no `await`, so it is ONE synchronous turn and no other
|
|
187
|
+
* task can be scheduled inside it. MEASURED: two concurrent scrubbed downloads
|
|
188
|
+
* with an outside observer sampling `process.env` from the microtask, immediate
|
|
189
|
+
* and timer queues — 11380 samples, 0 saw a scrubbed environment, and both
|
|
190
|
+
* resolved to the official URL. The await-free window IS the control, which is
|
|
191
|
+
* why `fn` is called synchronously and its promise is returned UNAWAITED.
|
|
192
|
+
* `tests/electron-env-scrub-get5-contract.test.js` re-measures every number
|
|
193
|
+
* above against the installed library on each run, so a @electron/get that moves
|
|
194
|
+
* a read past an await turns red there instead of in the field.
|
|
195
|
+
*
|
|
196
|
+
* ── THREE REMEDIES CONSIDERED AND REJECTED, each with its reason ───────────
|
|
197
|
+
* 1. Pass the values instead of scrubbing. IMPOSSIBLE: `mirrorVar` ranks
|
|
198
|
+
* `process.env` ABOVE `options[name]` (dist/artifact-utils.js, lines 20-35),
|
|
199
|
+
* so a planted name beats anything amicus puts in `mirrorOptions`.
|
|
200
|
+
* 2. `mirrorOptions.resolveAssetURL`, which does override the URL outright and
|
|
201
|
+
* IS inherited by the recursive SHASUMS256 call. Rejected: it bypasses
|
|
202
|
+
* `base` entirely, so a machine owner's bare `ELECTRON_MIRROR` — which this
|
|
203
|
+
* module deliberately keeps — would silently stop being honoured, and amicus
|
|
204
|
+
* would have to hand-build electron release URLs.
|
|
205
|
+
* 3. Hold the scrub for the whole call. Rejected by the finding itself: that is
|
|
206
|
+
* the shared-mutation-across-an-await B3 filed, and it would hand a scrubbed
|
|
207
|
+
* environment to every unrelated child a long-lived MCP process spawns in
|
|
208
|
+
* that window.
|
|
209
|
+
*
|
|
210
|
+
* `delete` and restore, not `= undefined`: assigning undefined to a process.env
|
|
211
|
+
* key stores the STRING 'undefined', which `mirrorVar` would read as a truthy
|
|
212
|
+
* mirror URL and use.
|
|
213
|
+
* @template T
|
|
214
|
+
* @param {() => T} fn called synchronously, exactly once
|
|
215
|
+
* @param {NodeJS.ProcessEnv} [env] defaults to process.env
|
|
216
|
+
* @returns {T} whatever fn returned (a promise is returned, never awaited here)
|
|
217
|
+
*/
|
|
218
|
+
function withScrubbedRepoEnv(fn, env = process.env) {
|
|
219
|
+
const removed = [];
|
|
220
|
+
for (const name of Object.keys(env)) {
|
|
221
|
+
if (isRepoPlantedName(name)) {
|
|
222
|
+
removed.push([name, env[name]]);
|
|
223
|
+
delete env[name];
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
return fn();
|
|
228
|
+
} finally {
|
|
229
|
+
for (const [name, value] of removed) { env[name] = value; }
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
module.exports = { isRepoPlantedName, withScrubbedRepoEnv, REPO_ENV_PREFIXES };
|
|
@@ -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 };
|