mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
* 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. * fix(release): make the elevate.exe swap prove it replaced the packed copy Success was "some cached copy was replaced", which a stale release directory carried in by the `electron-builder-win-` prefix restore can satisfy on its own while the bundle the rebuild packs stays unsigned. The app-builder-lib probe returns the exact path CopyElevateHelper will pack, so make that the check and the directory scan the fallback: exit non-zero when the probed copy was not replaced, and annotate a warning when the probe could not run at all, so a green step never quietly means the authoritative check was skipped. Also pin both shebang scripts to LF: `core.autocrlf=true` gives a Windows checkout CRLF, and CRLF plus a shebang breaks vite's transform, so resolve-7za-path.test.mjs currently runs zero tests there. --------- Co-authored-by: Orca Worker <orca-worker@localhost>
261 lines
9.7 KiB
JavaScript
261 lines
9.7 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 via
|
|
// `getBinFromUrl`), `nsis@1.2.1` (unified bundle). Not `customNsisBinary`: the
|
|
// `nsis-<version>` key `getBinFromCustomLoc` builds is only `getBin`'s in-process promise
|
|
// key, and the extract dir is named for the custom URL's parent segment, which need not
|
|
// start with `nsis` at all. Only the app-builder-lib probe covers that layout — which is
|
|
// why the probe, not this scan, is what decides whether the swap succeeded.
|
|
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. Returns the
|
|
* failure instead of logging it: an unavailable probe leaves the directory scan as the only
|
|
* signal, and the caller has to say that out loud rather than quietly passing.
|
|
*/
|
|
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')
|
|
const path = await getNsisElevatePath(config.toolsets?.nsis, config.nsis?.customNsisBinary)
|
|
return { path, error: null }
|
|
} catch (error) {
|
|
return { path: null, error: error.message }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* `toolsetReplaced` is the signal that matters. A non-empty `replaced` only says that some
|
|
* cached copy was rewritten, which a stale release directory carried in by the
|
|
* `electron-builder-win-` prefix restore can satisfy on its own.
|
|
*/
|
|
export async function replaceCachedElevateHelpers({
|
|
signedPath,
|
|
cacheDir = resolveElectronBuilderCacheDir(),
|
|
projectDir = process.cwd(),
|
|
env = process.env,
|
|
probe = resolveToolsetElevatePath
|
|
} = {}) {
|
|
if (!isFile(signedPath)) {
|
|
throw new Error(`Signed elevate.exe not found: ${signedPath}`)
|
|
}
|
|
const targets = new Set(findCachedElevatePaths(cacheDir, { env }))
|
|
const { path: toolsetPath, error: toolsetError } = await probe(projectDir)
|
|
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,
|
|
toolsetError,
|
|
toolsetReplaced: toolsetPath != null && replaced.includes(toolsetPath)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The annotations and exit code a swap result earns. Split out so every branch is testable
|
|
* without a subprocess — including the one that made this defect class possible, where the
|
|
* step passes because *a* cached copy was replaced while the copy the rebuild packs was not.
|
|
*/
|
|
export function summarizeSwap({ replaced, cacheDir, toolsetPath, toolsetError, toolsetReplaced }) {
|
|
if (toolsetPath != null && !toolsetReplaced) {
|
|
return {
|
|
annotations: [
|
|
{
|
|
level: 'error',
|
|
message:
|
|
`app-builder-lib resolves the elevate.exe the NSIS rebuild will pack to ${toolsetPath}, ` +
|
|
'but that path could not be replaced, so the installer will ship an unsigned UAC ' +
|
|
'elevation helper.'
|
|
}
|
|
],
|
|
exitCode: 1
|
|
}
|
|
}
|
|
if (replaced.length === 0) {
|
|
return {
|
|
annotations: [
|
|
{
|
|
level: 'error',
|
|
message:
|
|
`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.'
|
|
}
|
|
],
|
|
exitCode: 1
|
|
}
|
|
}
|
|
if (toolsetPath == null) {
|
|
// A green step must never quietly mean "the authoritative check did not run". The scan
|
|
// alone is satisfiable by a stale release directory that the `electron-builder-win-`
|
|
// prefix restore carried across a lockfile change, while the bundle the rebuild actually
|
|
// packs sits in a directory this scan does not match.
|
|
return {
|
|
annotations: [
|
|
{
|
|
level: 'warning',
|
|
message:
|
|
'Could not ask app-builder-lib which elevate.exe the NSIS rebuild will pack ' +
|
|
`(${toolsetError}); replaced ${replaced.length} copies found by scanning ${cacheDir} ` +
|
|
'alone, which a stale release directory can satisfy while the packed copy stays unsigned.'
|
|
}
|
|
],
|
|
exitCode: 0
|
|
}
|
|
}
|
|
return { annotations: [], exitCode: 0 }
|
|
}
|
|
|
|
// Why an exit code and not a warning: a swap that misses the copy the rebuild packs exits
|
|
// before that 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 result = await replaceCachedElevateHelpers({ signedPath })
|
|
const { annotations, exitCode } = summarizeSwap(result)
|
|
for (const { level, message } of annotations) {
|
|
process.stdout.write(`::${level}::${message}\n`)
|
|
}
|
|
if (exitCode === 0) {
|
|
for (const path of result.replaced) {
|
|
const role = path === result.toolsetPath ? ' (the copy app-builder-lib will pack)' : ''
|
|
process.stdout.write(`Replaced ${path} with the SignPath-signed copy.${role}\n`)
|
|
}
|
|
}
|
|
process.exit(exitCode)
|
|
} catch (error) {
|
|
process.stdout.write(`::error::Could not replace the cached elevate.exe: ${error.message}\n`)
|
|
process.exit(1)
|
|
}
|
|
}
|