From 180dcc4534ddfa9abca0172737c7be250551480e Mon Sep 17 00:00:00 2001 From: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:11:24 -0400 Subject: [PATCH] fix(updater): re-prove the retained Linux package before a privileged retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recovery card's "Try Automatic Install Again" handed electron-updater the cached .deb/.rpm path with no re-verification. That path is user-writable, so the digest proven when the card rendered says nothing about the bytes dpkg or rpm would read as root minutes later — and the card's other actions (copy command, reveal) validated while the one that actually installs did not. Re-hash the artifact immediately before the install, ahead of any destructive quit prep, and abort with copy that tells the user to download again. This narrows the window rather than closing it; only an immutable handoff would close it, which is a larger change. Also fix a macOS-only failure this suite gained with the platform-conditional pre-commit copy: the expectation hardcoded the non-Darwin string, so the suite was red on any Mac. --- src/main/linux-package-update-recovery.ts | 15 +++ src/main/updater.test.ts | 136 +++++++++++++++++++++- src/main/updater.ts | 42 ++++++- 3 files changed, 186 insertions(+), 7 deletions(-) diff --git a/src/main/linux-package-update-recovery.ts b/src/main/linux-package-update-recovery.ts index 83aca03cb3f..82d83c80e6e 100644 --- a/src/main/linux-package-update-recovery.ts +++ b/src/main/linux-package-update-recovery.ts @@ -289,6 +289,21 @@ export async function resolveLinuxPackageInstallInstructions( } } +/** + * Re-proves the retained package immediately before the privileged installer consumes it. + * + * The cache path is user-writable, so a digest checked when the recovery card rendered says + * nothing about the bytes `dpkg -i` will read minutes later. Re-hashing here does not close + * the race — only an immutable handoff would — but it shrinks the window from "since the card + * appeared" to "since this call", and it catches the artifact being swapped or deleted outright. + */ +export async function revalidateLinuxPackageForInstall( + recovery: LinuxPackageInstallRecovery +): Promise<{ ok: true } | { ok: false; reason: LinuxPackageRecoveryUnavailableReason }> { + const validation = await validateTrackedArtifact(recovery) + return validation.ok ? { ok: true } : { ok: false, reason: validation.reason } +} + export async function revealLinuxPackage( recovery: LinuxPackageInstallRecovery ): Promise { diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index 35a8c4c45bc..8ee04531029 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -1,7 +1,33 @@ /* eslint-disable max-lines */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createHash } from 'node:crypto' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import type * as UpdaterModule from './updater' +/** + * Stages a real package inside a real updater cache so the pre-install digest re-proof has + * something to prove. Returns the path and its actual digest for the download event. + */ +function stageRetainedLinuxPackage(fileName: string): { + cacheRoot: string + packagePath: string + sha512: string +} { + const cacheRoot = mkdtempSync(join(tmpdir(), 'orca-updater-cache-')) + const pendingDir = join(cacheRoot, 'orca-updater', 'pending') + mkdirSync(pendingDir, { recursive: true }) + const packagePath = join(pendingDir, fileName) + const bytes = Buffer.from(`orca test package ${fileName}`) + writeFileSync(packagePath, bytes) + return { + cacheRoot, + packagePath, + sha512: createHash('sha512').update(bytes).digest('base64') + } +} + const { appMock, browserWindowMock, @@ -1780,8 +1806,12 @@ describe('updater', () => { 'updater:status', expect.objectContaining({ state: 'error', - // Why: a pre-commit install failure is not fixed by restarting, so the copy must not suggest it. - message: 'Could not start the update installer. Orca remains open.' + // Why: a pre-commit install failure is not fixed by restarting, so the copy must not + // suggest it — except on macOS, where quitting does re-stage a Squirrel update. + message: + process.platform === 'darwin' + ? 'Could not restart to install the update. Quit and reopen Orca, then try again.' + : 'Could not start the update installer. Orca remains open.' }) ) }) @@ -3794,6 +3824,27 @@ describe('updater', () => { }) describe('linux root package install recovery', () => { + const stagedCacheRoots: string[] = [] + let restoreCacheHome: (() => void) | null = null + + // Why: the pre-install digest re-proof streams the package off real disk, and fake timers + // do not advance libuv. Interleave timer advances with real event-loop turns so the read + // actually completes before the assertions run. + const settleRetainedPackageRevalidation = async (): Promise => { + for (let attempt = 0; attempt < 10; attempt += 1) { + await vi.advanceTimersByTimeAsync(100) + await new Promise((resolve) => process.nextTick(resolve)) + } + } + + afterEach(() => { + restoreCacheHome?.() + restoreCacheHome = null + for (const root of stagedCacheRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } + }) + // Real 64-byte SHA-512 values; capture rejects a digest that cannot decode to one. const DEB_SHA512 = 'LHlL7dKoqg98gS2nfQv878dK+UoktbAkm4M20/hoJ2Qr0Kqsa3MSL4VmWy/Lll/MYjQFkpvOxduQ/vswentozA==' @@ -4041,16 +4092,35 @@ describe('updater', () => { }) it('retries the automatic install without redownloading the package', async () => { + // Why: the retry re-proves the digest before handing the path to a root installer, so + // this needs a genuinely valid retained package rather than a path that never existed. + const staged = stageRetainedLinuxPackage('orca-ide_1.0.61_amd64.deb') + const previousCacheHome = process.env.XDG_CACHE_HOME + process.env.XDG_CACHE_HOME = staged.cacheRoot + stagedCacheRoots.push(staged.cacheRoot) + restoreCacheHome = (): void => { + if (previousCacheHome === undefined) { + delete process.env.XDG_CACHE_HOME + } else { + process.env.XDG_CACHE_HOME = previousCacheHome + } + } const { send, updater } = await startUpdater('deb') - await reachDownloaded(updater, downloadedEvent()) + await reachDownloaded( + updater, + downloadedEvent({ + downloadedFile: staged.packagePath, + files: [{ url: 'orca-ide_1.0.61_amd64.deb', sha512: staged.sha512 }] + }) + ) autoUpdaterMock.quitAndInstall.mockImplementation(() => { autoUpdaterMock.emit('error', new Error(EXIT_127)) }) updater.quitAndInstall() - await vi.advanceTimersByTimeAsync(100) + await settleRetainedPackageRevalidation() updater.quitAndInstall() - await vi.advanceTimersByTimeAsync(100) + await settleRetainedPackageRevalidation() // Why: a retry usually fails identically; a deduped status would strand the preload restart relay. expect( @@ -4069,6 +4139,60 @@ describe('updater', () => { }) }) + // Why: the cache path is user-writable, so the bytes verified when the recovery card + // rendered are not necessarily the bytes a root package manager would read on retry. + it('aborts the retry when the retained package no longer matches its digest', async () => { + const staged = stageRetainedLinuxPackage('orca-ide_1.0.61_amd64.deb') + const previousCacheHome = process.env.XDG_CACHE_HOME + process.env.XDG_CACHE_HOME = staged.cacheRoot + stagedCacheRoots.push(staged.cacheRoot) + restoreCacheHome = (): void => { + if (previousCacheHome === undefined) { + delete process.env.XDG_CACHE_HOME + } else { + process.env.XDG_CACHE_HOME = previousCacheHome + } + } + const { send, updater } = await startUpdater('deb') + await reachDownloaded( + updater, + downloadedEvent({ + downloadedFile: staged.packagePath, + files: [{ url: 'orca-ide_1.0.61_amd64.deb', sha512: staged.sha512 }] + }) + ) + + // The escalation fails, which is what puts the recovery card (and its retry) on screen. + autoUpdaterMock.quitAndInstall.mockImplementation(() => { + autoUpdaterMock.emit('error', new Error(EXIT_127)) + }) + updater.quitAndInstall() + await settleRetainedPackageRevalidation() + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + + // A local process swaps the verified package for its own between failure and retry. + writeFileSync(staged.packagePath, Buffer.from('attacker supplied package')) + send.mockClear() + killAllPtyMock.mockClear() + + updater.quitAndInstall() + await settleRetainedPackageRevalidation() + + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + expect(killAllPtyMock).not.toHaveBeenCalled() + expect(updater.isQuittingForUpdate()).toBe(false) + expect(send).toHaveBeenCalledWith('updater:status', { + state: 'error', + message: + 'The downloaded update package no longer matches the release it was verified against, so Orca did not install it. Download the update again.' + }) + expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith( + 'linux_package_recovery_revalidation_failed', + expect.objectContaining({ reason: 'hash-mismatch' }), + expect.anything() + ) + }) + it('records classification-only lifecycle data for a package install failure', async () => { const { updater } = await startUpdater('deb') await reachDownloaded(updater, downloadedEvent()) diff --git a/src/main/updater.ts b/src/main/updater.ts index 6bb26d84dd5..ae3d4330d89 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -45,6 +45,7 @@ import { clearTrackedLinuxPackageArtifact, getTrackedLinuxPackageArtifact, resolveLinuxPackageInstallInstructions, + revalidateLinuxPackageForInstall, revealLinuxPackage, type LinuxPackageRecoveryUnavailableReason } from './linux-package-update-recovery' @@ -1880,10 +1881,49 @@ export function quitAndInstall(): void { // Why: defer the quit a tick so the renderer can flush dismissals/state before windows start closing. pendingQuitAndInstallTimer = setTimeout(() => { - void performQuitAndInstall() + void (async () => { + // Why: a retry hands a user-writable cache path to a root package manager, and the + // digest behind the recovery card was proven when the card rendered — possibly minutes + // and one swap ago. Re-prove it here, before performQuitAndInstall starts tearing the + // app down, so a replaced or vanished package aborts instead of being installed as root. + if (retriedRecovery) { + const revalidated = await revalidateLinuxPackageForInstall(retriedRecovery) + if (!revalidated.ok) { + if (pendingQuitAndInstallTimer) { + clearTimeout(pendingQuitAndInstallTimer) + pendingQuitAndInstallTimer = null + } + recordUpdaterLifecycle( + 'linux_package_recovery_revalidation_failed', + { + action: 'retry-automatic', + packageType: retriedRecovery.packageType, + version: retriedRecovery.version, + reason: revalidated.reason + }, + { level: 'warn', message: 'Retained update package no longer matches its digest' } + ) + sendInstallFailureStatus({ + state: 'error', + message: getLinuxPackageRevalidationFailureMessage(revalidated.reason) + }) + return + } + } + await performQuitAndInstall() + })() }, QUIT_AND_INSTALL_DELAY_MS) } +/** The retained package changed under us; never advise retrying the same file. */ +function getLinuxPackageRevalidationFailureMessage( + reason: LinuxPackageRecoveryUnavailableReason +): string { + return reason === 'missing' + ? 'The downloaded update package is no longer on disk. Download the update again.' + : 'The downloaded update package no longer matches the release it was verified against, so Orca did not install it. Download the update again.' +} + async function checkForUpdateNudge(): Promise { if (!app.isPackaged || is.dev) { return