Files
orca/config/scripts/replace-cached-nsis-elevate.mjs
T
Orca Worker 38ece930c8 fix(release): stop shipping an unsigned elevate.exe on Windows
The release cut swaps the SignPath-signed elevate.exe into the
electron-builder toolset cache so the NSIS rebuild's CopyElevateHelper
re-copy becomes a no-op. It searched `<cache>\nsis`, a directory no
app-builder-lib layout creates, and `-ErrorAction SilentlyContinue`
plus `exit 0` turned that miss into a green step — v1.4.193 and
v1.4.194 shipped an unsigned UAC elevation helper.

Move the lookup into a script that covers the real layouts
(`nsis-3.0.4.1/…`, `nsis@<toolset>/…`, `ELECTRON_BUILDER_NSIS_DIR`),
asks app-builder-lib for the authoritative path, and exits non-zero
with an ::error:: annotation when it finds nothing. The step stays
continue-on-error so the inner-signing chain remains fail-open.
2026-09-01 18:15:43 -07:00

200 lines
7.3 KiB
JavaScript

#!/usr/bin/env node
// Why: electron-builder re-runs `CopyElevateHelper.copy` on every NSIS pack, so the
// release rebuild overwrites the SignPath-signed `resources/elevate.exe` with the
// unsigned copy sitting in the electron-builder toolset cache. The release workflow
// swapped the cached copy first, but searched `<cache>/nsis` — a directory no current
// app-builder-lib layout creates (real ones are `<cache>/nsis-3.0.4.1/nsis-3.0.4.1-<hash>/`
// and `<cache>/nsis@<toolset>/nsis-bundle-<v>-<hash>/`), so the swap silently found
// nothing and v1.4.193/v1.4.194 shipped an unsigned UAC elevation helper.
import { copyFileSync, readdirSync, statSync } from 'node:fs'
import { createRequire } from 'node:module'
import { homedir, platform as osPlatform, tmpdir } from 'node:os'
import { join, parse, resolve } from 'node:path'
const require = createRequire(import.meta.url)
const ELEVATE_EXE = 'elevate.exe'
// `nsis` (the layout the old hardcoded path assumed), `nsis-3.0.4.1` (legacy bundle,
// and `getBinFromCustomLoc('nsis', version)`), `nsis@1.2.1` (unified bundle).
const NSIS_RELEASE_DIR = /^nsis(?:[-@].*)?$/i
// elevate.exe lives at the bundle root, one level under the release dir. The legacy
// bundle carries thousands of files under Contrib/, so an unbounded walk is both slow
// and a way to match something that is not a toolset copy.
const MAX_DEPTH = 3
function isFile(path) {
try {
return statSync(path).isFile()
} catch {
return false
}
}
/**
* Mirrors `getCacheDirectory` in app-builder-lib's `out/util/electronGet.js`, which is what
* decides where the NSIS bundle is unpacked. Kept as a local port rather than an import
* because the swap must still resolve a cache root when app-builder-lib cannot be loaded.
*/
export function resolveElectronBuilderCacheDir({
env = process.env,
platform = osPlatform(),
home = homedir(),
temp = tmpdir()
} = {}) {
const override = env.ELECTRON_BUILDER_CACHE?.trim()
if (override && parse(override).root) {
return override
}
if (platform === 'darwin') {
return join(home, 'Library', 'Caches', 'electron-builder')
}
if (platform === 'win32') {
const localAppData = env.LOCALAPPDATA?.trim()
// https://github.com/electron-userland/electron-builder/issues/1164
const isSystemUser =
localAppData?.toLowerCase().includes('\\windows\\system32\\') === true ||
env.USERNAME?.trim().toLowerCase() === 'system'
if (!localAppData || isSystemUser) {
return join(temp, 'electron-builder-cache')
}
return join(localAppData, 'electron-builder', 'Cache')
}
const xdgCache = env.XDG_CACHE_HOME
return xdgCache && parse(xdgCache).root
? join(xdgCache, 'electron-builder')
: join(home, '.cache', 'electron-builder')
}
function collectElevateFiles(dir, depth, found) {
let entries
try {
entries = readdirSync(dir, { withFileTypes: true })
} catch {
return found
}
for (const entry of entries) {
const path = join(dir, entry.name)
if (entry.isFile()) {
if (entry.name.toLowerCase() === ELEVATE_EXE) {
found.push(path)
}
} else if (entry.isDirectory() && depth > 1) {
collectElevateFiles(path, depth - 1, found)
}
}
return found
}
/**
* Every cached `elevate.exe` under an NSIS release directory of `cacheDir`, plus the
* `ELECTRON_BUILDER_NSIS_DIR` override copy when that is set.
*/
export function findCachedElevatePaths(cacheDir, { env = process.env } = {}) {
const found = []
const overrideDir = env.ELECTRON_BUILDER_NSIS_DIR?.trim()
if (overrideDir && isFile(join(overrideDir, ELEVATE_EXE))) {
found.push(join(overrideDir, ELEVATE_EXE))
}
let entries
try {
entries = readdirSync(cacheDir, { withFileTypes: true })
} catch {
return found
}
for (const entry of entries) {
if (entry.isDirectory() && NSIS_RELEASE_DIR.test(entry.name)) {
collectElevateFiles(join(cacheDir, entry.name), MAX_DEPTH, found)
}
}
return found
}
/**
* The exact path `CopyElevateHelper` will pack, asked of app-builder-lib itself. Best-effort:
* the internal module path moves between majors, and a cold cache would need the network,
* so a failure here degrades to the directory scan rather than failing the swap.
*/
export async function resolveToolsetElevatePath(projectDir = process.cwd()) {
try {
const configPath = require.resolve(resolve(projectDir, 'config/electron-builder.config.cjs'))
const config = require(configPath)
const { getNsisElevatePath } = require('app-builder-lib/out/toolsets/windows.js')
return await getNsisElevatePath(config.toolsets?.nsis, config.nsis?.customNsisBinary)
} catch (error) {
process.stderr.write(
`Could not resolve elevate.exe through app-builder-lib (${error.message}); ` +
'falling back to the toolset cache scan.\n'
)
return null
}
}
/**
* Replaces every cached copy rather than picking one. Which bundle the rebuild packs
* depends on the toolset version resolved at pack time, and each cached copy is an
* unsigned `elevate.exe` that a later pack could reach for; the helper is a standalone
* UAC shim, not coupled to the NSIS version around it, so overwriting all of them is safe.
*/
export async function replaceCachedElevateHelpers({
signedPath,
cacheDir = resolveElectronBuilderCacheDir(),
projectDir = process.cwd(),
env = process.env,
probeToolset = true
} = {}) {
if (!isFile(signedPath)) {
throw new Error(`Signed elevate.exe not found: ${signedPath}`)
}
const targets = new Set(findCachedElevatePaths(cacheDir, { env }))
const toolsetPath = probeToolset ? await resolveToolsetElevatePath(projectDir) : null
if (toolsetPath != null && isFile(toolsetPath)) {
targets.add(toolsetPath)
}
const replaced = []
for (const target of targets) {
copyFileSync(signedPath, target)
replaced.push(target)
}
return { replaced, cacheDir, toolsetPath }
}
// Why an exit code and not a warning: a swap that finds nothing exits before the NSIS
// rebuild restores the unsigned helper, so a silent success here is indistinguishable
// from a release that shipped a signed one — which is how this went unnoticed for two
// releases. The workflow step is `continue-on-error`, so this annotates loudly without
// making a release unbuildable.
if (import.meta.filename === process.argv[1]) {
const signedPath = process.argv[2]
if (!signedPath) {
process.stderr.write('Usage: replace-cached-nsis-elevate.mjs <signed-elevate.exe>\n')
process.exit(2)
}
try {
const { replaced, cacheDir } = await replaceCachedElevateHelpers({ signedPath })
if (replaced.length === 0) {
process.stdout.write(
`::error::No cached elevate.exe found under ${cacheDir}; the NSIS rebuild will pack the ` +
'unsigned helper and ship an unsigned UAC elevation binary. The electron-builder ' +
'toolset cache layout has changed — update config/scripts/replace-cached-nsis-elevate.mjs.\n'
)
process.exit(1)
}
if (replaced.length > 1) {
process.stdout.write(
`Note: ${replaced.length} cached NSIS bundles were present; replaced the helper in all of them.\n`
)
}
for (const path of replaced) {
process.stdout.write(`Replaced ${path} with the SignPath-signed copy.\n`)
}
} catch (error) {
process.stdout.write(`::error::Could not replace the cached elevate.exe: ${error.message}\n`)
process.exit(1)
}
}