fix(updater): re-prove the retained Linux package on every install path

Review findings on the original fix:

1. The abort force-sent its error status with no staleness guard, so a
   verdict from a hash that outlived its cycle overwrote whatever card had
   replaced it (a fresh 'available' from Check for Updates became a stale
   "package no longer matches" error). Now keyed on an install-cycle
   signature, the same protection failLinuxPackageRecovery already had.
2. 'read-failed' (EMFILE/EIO/EACCES mid-stream) was described as a digest
   mismatch and tore down the recovery card. It now reuses the accurate
   per-reason copy and keeps the card, exactly as the Copy/Show paths do
   for the same reason. It still fails closed: chmod 000 on a swapped file
   would otherwise be a one-line bypass, since root can read what we cannot.
3. The check was keyed on the recovery status, so it only covered the retry.
   The primary 'downloaded -> Restart to Update' install, whose window is
   hours rather than seconds, handed the same user-writable path to dpkg/rpm
   unverified. Moved into performQuitAndInstall keyed on the tracked
   artifact, so both paths are covered; non-Linux keeps its exact timing
   through a synchronous artifact guard.
4. The async prologue had moved the "quit timer is always cleared"
   invariant out of a try/finally. The re-proof now owns a flag cleared in
   finally, and a rejection fails closed instead of wedging the updater.
5. The install re-proof no longer joins an in-flight validation, so its
   proof cannot predate the click that asked for it.
6. The retry button gained the pending affordance the other actions have,
   since the click now streams the whole package before anything happens.

Tests: real packages are staged in a real updater cache for the whole Linux
block (a path that never existed would now abort every install); new cases
cover the swapped primary install, the stale-verdict drop, the preserved
card on read-failed, the rejecting re-proof, the concurrent second click,
and the fresh-hash guarantee. Each was verified to fail with only its
source change reverted.
This commit is contained in:
OrcaWin
2026-08-04 00:34:16 -04:00
parent 180dcc4534
commit f657a51466
7 changed files with 488 additions and 160 deletions
@@ -611,6 +611,31 @@ describePosix('validation coalescing', () => {
expect(hashPasses.count).toBe(2)
})
// Why: a verdict handed to a root package manager must cover the bytes as of the click, not the
// bytes a Copy click started streaming seconds earlier.
it('never reuses an in-flight pass for a pre-install re-proof', async () => {
await writePackage('orca.deb')
capture()
const artifact = recovery.getTrackedLinuxPackageArtifact()
const copyPass = recovery.resolveLinuxPackageInstallInstructions(recoveryFor())
const installPass = recovery.revalidateLinuxPackageForInstall(artifact!)
await expect(installPass).resolves.toEqual({ ok: true })
await expect(copyPass).resolves.toMatchObject({ ok: true })
expect(hashPasses.count).toBe(2)
})
it('lets a later Copy click join the pre-install pass', async () => {
await writePackage('orca.deb')
capture()
const artifact = recovery.getTrackedLinuxPackageArtifact()
const installPass = recovery.revalidateLinuxPackageForInstall(artifact!)
const copyPass = recovery.resolveLinuxPackageInstallInstructions(recoveryFor())
await Promise.all([installPass, copyPass])
expect(hashPasses.count).toBe(1)
})
it('does not reuse an in-flight pass for a different artifact', async () => {
await writePackage('orca.deb')
await writePackage('orca-next.deb')
@@ -629,6 +654,39 @@ describePosix('validation coalescing', () => {
})
})
describePosix('revalidateLinuxPackageForInstall', () => {
it('proves the retained package still matches its release digest', async () => {
await writePackage('orca.deb')
capture()
const artifact = recovery.getTrackedLinuxPackageArtifact()
await expect(recovery.revalidateLinuxPackageForInstall(artifact!)).resolves.toEqual({
ok: true
})
})
it('rejects a package swapped after the download was verified', async () => {
await writePackage('orca.deb')
capture()
const artifact = recovery.getTrackedLinuxPackageArtifact()
await writePackage('orca.deb', 'attacker supplied package')
await expect(recovery.revalidateLinuxPackageForInstall(artifact!)).resolves.toEqual({
ok: false,
reason: 'hash-mismatch'
})
})
it('reports a package deleted from the cache as missing', async () => {
const filePath = await writePackage('orca.deb')
capture()
const artifact = recovery.getTrackedLinuxPackageArtifact()
await fsp.rm(filePath)
await expect(recovery.revalidateLinuxPackageForInstall(artifact!)).resolves.toEqual({
ok: false,
reason: 'missing'
})
})
})
describePosix('revealLinuxPackage', () => {
it('reveals a verified package on the machine that owns it', async () => {
const filePath = await writePackage('orca.deb')
+32 -17
View File
@@ -241,6 +241,29 @@ async function validateArtifact(artifact: LinuxPackageArtifact): Promise<Validat
}
}
/**
* Hashes the artifact, joining an identical pass already in flight. `fresh` opts out of that reuse:
* a verdict that reaches a root installer must cover the bytes as of this call, not as of whenever
* some earlier Copy/Show click started streaming.
*/
function runValidation(
artifact: LinuxPackageArtifact,
options?: { fresh?: boolean }
): Promise<ValidationResult> {
const key = `${artifact.packageType}:${artifact.version}:${artifact.path}:${artifact.sha512}`
if (!options?.fresh && inFlightValidation?.key === key) {
return inFlightValidation.promise
}
const promise: Promise<ValidationResult> = validateArtifact(artifact).finally(() => {
// Why: identity, not key — a fresh install pass may already have replaced this entry.
if (inFlightValidation?.promise === promise) {
inFlightValidation = null
}
})
inFlightValidation = { key, promise }
return promise
}
/**
* Revalidates the retained package against the digest captured from the release metadata. This is
* an integrity check against that HTTPS metadata, not a package signature and not a privilege
@@ -257,17 +280,7 @@ async function validateTrackedArtifact(
) {
return { ok: false, reason: 'missing' }
}
const key = `${artifact.packageType}:${artifact.version}:${artifact.path}:${artifact.sha512}`
if (inFlightValidation?.key === key) {
return inFlightValidation.promise
}
const promise = validateArtifact(artifact).finally(() => {
if (inFlightValidation?.key === key) {
inFlightValidation = null
}
})
inFlightValidation = { key, promise }
return promise
return runValidation(artifact)
}
export async function resolveLinuxPackageInstallInstructions(
@@ -292,15 +305,17 @@ 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.
* The cache path is user-writable, so a digest checked when the download finished 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 download" to
* "since this call", and it catches the artifact being swapped or deleted outright. Takes the
* artifact rather than a recovery so both the retry and the plain "Restart to Update" install
* are covered.
*/
export async function revalidateLinuxPackageForInstall(
recovery: LinuxPackageInstallRecovery
artifact: LinuxPackageArtifact
): Promise<{ ok: true } | { ok: false; reason: LinuxPackageRecoveryUnavailableReason }> {
const validation = await validateTrackedArtifact(recovery)
const validation = await runValidation(artifact, { fresh: true })
return validation.ok ? { ok: true } : { ok: false, reason: validation.reason }
}
@@ -9,6 +9,7 @@ const {
getTrackedLinuxPackageArtifactMock,
recordUpdaterLifecycleMock,
resolveLinuxPackageInstallInstructionsMock,
revalidateLinuxPackageForInstallMock,
revealLinuxPackageMock,
resetHandlers
} = vi.hoisted(() => {
@@ -42,6 +43,7 @@ const {
getTrackedLinuxPackageArtifactMock: vi.fn(),
recordUpdaterLifecycleMock: vi.fn(),
resolveLinuxPackageInstallInstructionsMock: vi.fn(),
revalidateLinuxPackageForInstallMock: vi.fn(),
revealLinuxPackageMock: vi.fn(),
resetHandlers: () => updaterHandlers.clear()
}
@@ -82,6 +84,7 @@ vi.mock('./linux-package-update-recovery', () => ({
clearTrackedLinuxPackageArtifactForOtherVersion: vi.fn(),
getTrackedLinuxPackageArtifact: getTrackedLinuxPackageArtifactMock,
resolveLinuxPackageInstallInstructions: resolveLinuxPackageInstallInstructionsMock,
revalidateLinuxPackageForInstall: revalidateLinuxPackageForInstallMock,
revealLinuxPackage: revealLinuxPackageMock
}))
@@ -113,6 +116,7 @@ describe('linux package recovery actions', () => {
resolveLinuxPackageInstallInstructionsMock
.mockReset()
.mockResolvedValue({ ok: true, command: "sudo apt install -- '<pkg>'", packageFileName: 'p' })
revalidateLinuxPackageForInstallMock.mockReset().mockResolvedValue({ ok: true })
revealLinuxPackageMock.mockReset().mockResolvedValue({ ok: true })
})
+268 -101
View File
@@ -5,26 +5,47 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type * as UpdaterModule from './updater'
import type * as RecoveryModule from './linux-package-update-recovery'
import type { UpdateStatus } from '../shared/types'
type RevalidationVerdict = Awaited<
ReturnType<typeof RecoveryModule.revalidateLinuxPackageForInstall>
>
// Captured before any vi.useFakeTimers() call: the only handle left that still yields to libuv.
const realSetTimeout = globalThis.setTimeout
type StagedLinuxPackages = {
cacheRoot: string
debPath: string
debSha512: string
rpmPath: string
rpmSha512: string
}
/**
* 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.
* Stages real packages inside a real updater cache: every install re-proves the retained digest by
* streaming the file off disk, so a path that never existed would abort before reaching the native
* updater. Returns the actual digests for the download events.
*/
function stageRetainedLinuxPackage(fileName: string): {
cacheRoot: string
packagePath: string
sha512: string
} {
function stageLinuxUpdateCache(): StagedLinuxPackages {
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)
const stagePackage = (fileName: string): { path: string; sha512: string } => {
const packagePath = join(pendingDir, fileName)
const bytes = Buffer.from(`orca test package ${fileName}`)
writeFileSync(packagePath, bytes)
return { path: packagePath, sha512: createHash('sha512').update(bytes).digest('base64') }
}
const deb = stagePackage('orca-ide_1.0.61_amd64.deb')
const rpm = stagePackage('orca-ide-1.0.61.x86_64.rpm')
return {
cacheRoot,
packagePath,
sha512: createHash('sha512').update(bytes).digest('base64')
debPath: deb.path,
debSha512: deb.sha512,
rpmPath: rpm.path,
rpmSha512: rpm.sha512
}
}
@@ -3824,50 +3845,93 @@ describe('updater', () => {
})
describe('linux root package install recovery', () => {
const stagedCacheRoots: string[] = []
let restoreCacheHome: (() => void) | null = null
let staged: StagedLinuxPackages
let EXIT_127: string
// 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<void> => {
for (let attempt = 0; attempt < 10; attempt += 1) {
await vi.advanceTimersByTimeAsync(100)
await new Promise((resolve) => process.nextTick(resolve))
// Why: the pre-install digest re-proof streams the package off real disk. Fake timers never
// advance libuv, so the quit timer needs fake time while the read needs real event-loop turns.
const settleQuitAndInstall = async (): Promise<void> => {
await vi.advanceTimersByTimeAsync(100)
for (let turn = 0; turn < 40; turn += 1) {
await new Promise((resolve) => realSetTimeout(resolve, 0))
}
await vi.advanceTimersByTimeAsync(0)
}
beforeEach(() => {
staged = stageLinuxUpdateCache()
vi.stubEnv('XDG_CACHE_HOME', staged.cacheRoot)
EXIT_127 = `Command failed: /usr/bin/pkexec /usr/bin/dpkg -i ${staged.debPath}, exited with code 127`
})
afterEach(() => {
vi.doUnmock('./linux-package-update-recovery')
vi.unstubAllEnvs()
rmSync(staged.cacheRoot, { recursive: true, force: true })
})
/**
* Holds the pre-install re-proof open so a verdict can be delivered at an exact point in the
* cycle. Only that call is replaced — the artifact state stays real. Must precede startUpdater.
*/
const holdRevalidation = (): {
settle: (verdict: RevalidationVerdict) => void
fail: (error: Error) => void
} => {
let pending: {
resolve: (verdict: RevalidationVerdict) => void
reject: (error: Error) => void
} | null = null
vi.doMock('./linux-package-update-recovery', async () => {
const actual = await vi.importActual<typeof RecoveryModule>(
'./linux-package-update-recovery'
)
return {
...actual,
revalidateLinuxPackageForInstall: vi.fn(
() =>
new Promise<RevalidationVerdict>((resolve, reject) => {
pending = { resolve, reject }
})
)
}
})
return {
settle: (verdict) => {
pending?.resolve(verdict)
pending = null
},
fail: (error) => {
pending?.reject(error)
pending = null
}
}
}
afterEach(() => {
restoreCacheHome?.()
restoreCacheHome = null
for (const root of stagedCacheRoots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
})
const lastStatus = (send: ReturnType<typeof vi.fn>): UpdateStatus | undefined =>
send.mock.calls.findLast(([channel]) => channel === 'updater:status')?.[1]
// Real 64-byte SHA-512 values; capture rejects a digest that cannot decode to one.
const DEB_SHA512 =
'LHlL7dKoqg98gS2nfQv878dK+UoktbAkm4M20/hoJ2Qr0Kqsa3MSL4VmWy/Lll/MYjQFkpvOxduQ/vswentozA=='
const RPM_SHA512 =
'W2U3AUPfVpc0Ia1qX/VJ5+8aW+yOFhysT6ryodo7A6DTZKqL6RYLK53U6ShGx+9f4lb35qtd4tOp5jzJZZdAfQ=='
// Why: macOS keeps the restart advice because quitting does re-stage a Squirrel update.
const PRE_COMMIT_FAILURE_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.'
const DEB_PATH = '/home/tester/.cache/orca-updater/pending/orca-ide_1.0.61_amd64.deb'
const RPM_PATH = '/home/tester/.cache/orca-updater/pending/orca-ide-1.0.61.x86_64.rpm'
const AGENT_STDERR =
'pkexec: Error executing command as another user: No authentication agent found.'
const EXIT_127 = `Command failed: /usr/bin/pkexec /usr/bin/dpkg -i ${DEB_PATH}, exited with code 127`
const downloadedEvent = (overrides?: Record<string, unknown>): Record<string, unknown> => ({
version: '1.0.61',
downloadedFile: DEB_PATH,
files: [{ url: 'orca-ide_1.0.61_amd64.deb', sha512: DEB_SHA512 }],
downloadedFile: staged.debPath,
files: [{ url: 'orca-ide_1.0.61_amd64.deb', sha512: staged.debSha512 }],
...overrides
})
const rpmDownloadedEvent = (): Record<string, unknown> =>
downloadedEvent({
downloadedFile: staged.rpmPath,
files: [{ url: 'orca-ide-1.0.61.x86_64.rpm', sha512: staged.rpmSha512 }]
})
const startUpdater = async (
packageType: 'deb' | 'rpm' | null
): Promise<{ send: ReturnType<typeof vi.fn>; updater: typeof UpdaterModule }> => {
@@ -3953,12 +4017,12 @@ describe('updater', () => {
const { send, updater } = await startUpdater('deb')
await reachDownloaded(updater, downloadedEvent())
autoUpdaterMock.quitAndInstall.mockImplementation(() => {
autoUpdaterMock.logger?.error(`${AGENT_STDERR} target ${DEB_PATH}`)
autoUpdaterMock.logger?.error(`${AGENT_STDERR} target ${staged.debPath}`)
throw new Error(EXIT_127)
})
updater.quitAndInstall()
await vi.advanceTimersByTimeAsync(100)
await settleQuitAndInstall()
// Why: the sync throw ends capture before the catch, so the stashed text must survive.
expect(send).toHaveBeenCalledWith('updater:status', {
@@ -3977,19 +4041,13 @@ describe('updater', () => {
const openWindow = { removeAllListeners: vi.fn() }
browserWindowMock.getAllWindows.mockReturnValue([openWindow] as never)
const { send, updater } = await startUpdater('rpm')
await reachDownloaded(
updater,
downloadedEvent({
downloadedFile: RPM_PATH,
files: [{ url: 'orca-ide-1.0.61.x86_64.rpm', sha512: RPM_SHA512 }]
})
)
await reachDownloaded(updater, rpmDownloadedEvent())
autoUpdaterMock.quitAndInstall.mockImplementation(() => {
autoUpdaterMock.emit('error', new Error('Command failed, exited with code 1'))
})
updater.quitAndInstall()
await vi.advanceTimersByTimeAsync(100)
await settleQuitAndInstall()
expect(send).toHaveBeenCalledWith('updater:status', {
state: 'error',
@@ -4019,7 +4077,7 @@ describe('updater', () => {
})
updater.quitAndInstall()
await vi.advanceTimersByTimeAsync(100)
await settleQuitAndInstall()
expect(send).toHaveBeenCalledWith('updater:status', {
state: 'error',
@@ -4043,7 +4101,7 @@ describe('updater', () => {
})
updater.quitAndInstall()
await vi.advanceTimersByTimeAsync(100)
await settleQuitAndInstall()
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
expect(send).toHaveBeenCalledWith('updater:status', {
@@ -4063,7 +4121,7 @@ describe('updater', () => {
send.mockClear()
updater.quitAndInstall()
await vi.advanceTimersByTimeAsync(100)
await settleQuitAndInstall()
expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith(
'post_commit_cleanup_failed',
@@ -4081,7 +4139,7 @@ describe('updater', () => {
await reachDownloaded(updater, downloadedEvent())
updater.quitAndInstall()
await vi.advanceTimersByTimeAsync(100)
await settleQuitAndInstall()
expect(killAllPtyMock).toHaveBeenCalledTimes(1)
send.mockClear()
@@ -4092,35 +4150,16 @@ 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({
downloadedFile: staged.packagePath,
files: [{ url: 'orca-ide_1.0.61_amd64.deb', sha512: staged.sha512 }]
})
)
await reachDownloaded(updater, downloadedEvent())
autoUpdaterMock.quitAndInstall.mockImplementation(() => {
autoUpdaterMock.emit('error', new Error(EXIT_127))
})
updater.quitAndInstall()
await settleRetainedPackageRevalidation()
await settleQuitAndInstall()
updater.quitAndInstall()
await settleRetainedPackageRevalidation()
await settleQuitAndInstall()
// Why: a retry usually fails identically; a deduped status would strand the preload restart relay.
expect(
@@ -4142,41 +4181,24 @@ 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 }]
})
)
await reachDownloaded(updater, downloadedEvent())
// 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()
await settleQuitAndInstall()
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'))
writeFileSync(staged.debPath, Buffer.from('attacker supplied package'))
send.mockClear()
killAllPtyMock.mockClear()
updater.quitAndInstall()
await settleRetainedPackageRevalidation()
await settleQuitAndInstall()
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
expect(killAllPtyMock).not.toHaveBeenCalled()
@@ -4184,25 +4206,170 @@ describe('updater', () => {
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.'
'The downloaded package no longer matches the verified release, so Orca will not hand it to a package manager. Download the update again, or get it from the official release page.'
})
expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith(
'linux_package_recovery_revalidation_failed',
'linux_package_revalidation_failed',
expect.objectContaining({ action: 'retry-automatic', reason: 'hash-mismatch' }),
expect.anything()
)
})
// Why: "Restart to Update" is the common path and can sit unclicked for hours, so the same
// user-writable package reaches a root installer with a far longer window than any retry.
it('aborts the first install when the downloaded package was swapped', async () => {
const { send, updater } = await startUpdater('deb')
await reachDownloaded(updater, downloadedEvent())
writeFileSync(staged.debPath, Buffer.from('attacker supplied package'))
send.mockClear()
updater.quitAndInstall()
await settleQuitAndInstall()
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
expect(killAllPtyMock).not.toHaveBeenCalled()
expect(updater.isQuittingForUpdate()).toBe(false)
expect(send).toHaveBeenCalledWith('updater:status', {
state: 'error',
message:
'The downloaded package no longer matches the verified release, so Orca will not hand it to a package manager. Download the update again, or get it from the official release page.'
})
expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith(
'linux_package_revalidation_failed',
expect.objectContaining({ action: 'restart-to-install', reason: 'hash-mismatch' }),
expect.anything()
)
})
it('installs normally when the retained package still matches its digest', async () => {
const { updater } = await startUpdater('deb')
await reachDownloaded(updater, downloadedEvent())
updater.quitAndInstall()
await settleQuitAndInstall()
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
expect(killAllPtyMock).toHaveBeenCalledTimes(1)
expect(recordUpdaterLifecycleMock).not.toHaveBeenCalledWith(
'linux_package_revalidation_failed',
expect.anything(),
expect.anything()
)
})
// Why: hashing 160 MB outlives the cycle it started in, and Check for Updates stays enabled
// while it runs — a verdict from the old cycle must not replace the card that took over.
it('drops an abort verdict once a newer check replaced the card', async () => {
const revalidation = holdRevalidation()
const { send, updater } = await startUpdater('deb')
await reachDownloaded(updater, downloadedEvent())
updater.quitAndInstall()
await vi.advanceTimersByTimeAsync(100)
// The user gives up waiting and checks again; that check owns the card from here.
updater.checkForUpdatesFromMenu()
await vi.advanceTimersByTimeAsync(0)
expect(lastStatus(send)).toMatchObject({ state: 'available', version: '1.0.61' })
revalidation.settle({ ok: false, reason: 'hash-mismatch' })
await settleQuitAndInstall()
// The install is still abandoned — only the stale status is withheld.
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
expect(lastStatus(send)).toMatchObject({ state: 'available', version: '1.0.61' })
expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith(
'linux_package_revalidation_failed',
expect.objectContaining({ reason: 'hash-mismatch' }),
expect.anything()
)
})
// Why: EMFILE/EIO during the stream says nothing about the bytes, so the copy must not claim
// the package changed and the card must keep the actions that still work.
it('keeps the recovery card usable when the re-proof cannot read the package', async () => {
const revalidation = holdRevalidation()
const { send, updater } = await startUpdater('deb')
await reachDownloaded(updater, downloadedEvent())
autoUpdaterMock.quitAndInstall.mockImplementation(() => {
autoUpdaterMock.emit('error', new Error(EXIT_127))
})
updater.quitAndInstall()
await vi.advanceTimersByTimeAsync(100)
revalidation.settle({ ok: true })
await settleQuitAndInstall()
send.mockClear()
updater.quitAndInstall()
await vi.advanceTimersByTimeAsync(100)
revalidation.settle({ ok: false, reason: 'read-failed' })
await settleQuitAndInstall()
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
expect(lastStatus(send)).toEqual({
state: 'error',
message:
'Orca could not read the downloaded package. Download the update again, or get it from the official release page.',
recovery: {
kind: 'linux-package-install',
packageType: 'deb',
reason: 'package-install-failed',
version: '1.0.61'
}
})
})
// Why: the re-proof runs before performQuitAndInstall's own error handling, so a rejection
// there would strand the quit timer and make every later install a silent no-op.
it('stays installable after a re-proof that rejects outright', async () => {
const revalidation = holdRevalidation()
const { send, updater } = await startUpdater('deb')
await reachDownloaded(updater, downloadedEvent())
updater.quitAndInstall()
await vi.advanceTimersByTimeAsync(100)
revalidation.fail(new Error('hash worker crashed'))
await settleQuitAndInstall()
// Fails closed: an unprovable package is not handed to a root package manager.
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
expect(lastStatus(send)).toMatchObject({
state: 'error',
message:
'Orca could not read the downloaded package. Download the update again, or get it from the official release page.'
})
updater.quitAndInstall()
await vi.advanceTimersByTimeAsync(100)
revalidation.settle({ ok: true })
await settleQuitAndInstall()
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
})
// Why: a second click during the multi-second hash must not schedule a parallel install.
it('ignores a second install request while the digest re-proof runs', async () => {
const { updater } = await startUpdater('deb')
await reachDownloaded(updater, downloadedEvent())
updater.quitAndInstall()
// Fires the quit timer, which starts the hash; the read itself is still outstanding.
await vi.advanceTimersByTimeAsync(100)
updater.quitAndInstall()
await settleQuitAndInstall()
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
})
it('records classification-only lifecycle data for a package install failure', async () => {
const { updater } = await startUpdater('deb')
await reachDownloaded(updater, downloadedEvent())
autoUpdaterMock.quitAndInstall.mockImplementation(() => {
autoUpdaterMock.logger?.error(`${AGENT_STDERR} target ${DEB_PATH}`)
autoUpdaterMock.logger?.error(`${AGENT_STDERR} target ${staged.debPath}`)
autoUpdaterMock.emit('error', new Error(EXIT_127))
})
updater.quitAndInstall()
await vi.advanceTimersByTimeAsync(100)
await settleQuitAndInstall()
const failure = recordUpdaterLifecycleMock.mock.calls.find(
([event]) => event === 'linux_package_install_failed'
@@ -4215,7 +4382,7 @@ describe('updater', () => {
errorType: 'Error'
})
const durable = JSON.stringify(recordUpdaterLifecycleMock.mock.calls)
expect(durable).not.toContain(DEB_PATH)
expect(durable).not.toContain(staged.debPath)
expect(durable).not.toContain('authentication agent')
})
@@ -4227,7 +4394,7 @@ describe('updater', () => {
})
updater.quitAndInstall()
await vi.advanceTimersByTimeAsync(100)
await settleQuitAndInstall()
const failure = recordUpdaterLifecycleMock.mock.calls.find(
([event]) => event === 'linux_package_install_failed'
+119 -42
View File
@@ -47,6 +47,7 @@ import {
resolveLinuxPackageInstallInstructions,
revalidateLinuxPackageForInstall,
revealLinuxPackage,
type LinuxPackageArtifact,
type LinuxPackageRecoveryUnavailableReason
} from './linux-package-update-recovery'
import {
@@ -125,6 +126,9 @@ let autoUpdateCheckTimer: ReturnType<typeof setTimeout> | null = null
let nudgeCheckTimer: ReturnType<typeof setTimeout> | null = null
let pendingQuitAndInstallTimer: ReturnType<typeof setTimeout> | null = null
let quitAndInstallInProgress = false
// Why: the pre-install digest re-proof streams the whole package, so a second install request can
// arrive while it runs — after the quit timer was cleared but before the handoff owns the process.
let linuxPackageRevalidationInFlight = false
let updateInstallMode: UpdateInstallMode = 'interactive'
let lastInstallDeferralVersion = { download: null as string | null, install: null as string | null }
// Why: once install has committed, late 'error' events must not clear quittingForUpdate — that would re-enable dock activate mid-installer.
@@ -694,7 +698,7 @@ function clearPrereleaseFallbackContextIfSettled(): void {
}
async function performQuitAndInstall(): Promise<void> {
if (quitAndInstallInProgress) {
if (quitAndInstallInProgress || linuxPackageRevalidationInFlight) {
recordUpdaterLifecycle('quit_and_install_ignored', { reason: 'already-in-progress' })
return
}
@@ -708,6 +712,13 @@ async function performQuitAndInstall(): Promise<void> {
if (deferHeadlessServeInstall('install', pendingVersion)) {
return
}
// Why: the retained .deb/.rpm sits on a user-writable path that a root package manager is about
// to read, and nothing re-checks it after download. Re-prove it here — before any teardown — so a
// swapped or vanished package aborts instead of being installed as root. The synchronous guard
// keeps every non-Linux install on its existing timing.
if (getTrackedLinuxPackageArtifact() && !(await proveRetainedLinuxPackage(pendingVersion))) {
return
}
quitAndInstallInProgress = true
markMacQuitAndInstallInFlight()
@@ -1802,6 +1813,108 @@ function failLinuxPackageRecovery(
throw new Error(message)
}
/**
* Identifies the update cycle an install belongs to, so a verdict produced by a multi-second hash
* can be dropped when a newer cycle already replaced the card it would otherwise overwrite.
*/
function getInstallCycleSignature(): string {
const recovery = getActiveLinuxPackageRecovery()
if (recovery) {
return `recovery:${recovery.packageType}:${recovery.version}`
}
return currentStatus.state === 'downloaded'
? `downloaded:${currentStatus.version}`
: `state:${currentStatus.state}`
}
/**
* Re-proves the retained package before the install starts. Returns false when the install must be
* abandoned; the artifact is only re-read here, so callers still own every teardown decision.
*/
async function proveRetainedLinuxPackage(pendingVersion: string): Promise<boolean> {
const artifact = getTrackedLinuxPackageArtifact()
if (!artifact) {
return true
}
// Why: an artifact retained from another cycle says nothing about the file electron-updater is
// about to install, so proving it would block a legitimate install on an unrelated digest.
if (pendingVersion && pendingVersion !== artifact.version) {
return true
}
const recovery = getActiveLinuxPackageRecovery()
const cycle = getInstallCycleSignature()
const reason = await revalidateRetainedLinuxPackage(artifact)
if (!reason) {
return true
}
reportLinuxPackageRevalidationFailure({ artifact, recovery, reason, cycle })
return false
}
/** The failing reason, or null when the retained package still matches its release digest. */
async function revalidateRetainedLinuxPackage(
artifact: LinuxPackageArtifact
): Promise<LinuxPackageRecoveryUnavailableReason | null> {
linuxPackageRevalidationInFlight = true
try {
const verdict = await revalidateLinuxPackageForInstall(artifact)
return verdict.ok ? null : verdict.reason
} catch (error) {
recordUpdaterLifecycle(
'linux_package_revalidation_errored',
{ errorType: error instanceof Error ? error.name : typeof error },
{ level: 'warn', message: 'Could not re-verify the retained update package' }
)
// Why: fail closed — bytes we could not read are bytes we cannot hand to a root installer.
return 'read-failed'
} finally {
// Why: the invariant every install path depends on — a wedged flag would make quitAndInstall
// early-return for the rest of the session.
linuxPackageRevalidationInFlight = false
}
}
function reportLinuxPackageRevalidationFailure({
artifact,
recovery,
reason,
cycle
}: {
artifact: LinuxPackageArtifact
recovery: LinuxPackageInstallRecovery | null
reason: LinuxPackageRecoveryUnavailableReason
cycle: string
}): void {
recordUpdaterLifecycle(
'linux_package_revalidation_failed',
{
action: recovery ? 'retry-automatic' : 'restart-to-install',
packageType: artifact.packageType,
version: artifact.version,
reason
},
{ level: 'warn', message: 'Retained update package failed its pre-install digest check' }
)
// Why: a package proven bad must not stay tracked, but a download that landed during the hash
// owns the slot now and destroying it would force a needless 160 MB redownload.
const clearsArtifact = RECOVERY_CLEARING_REASONS.includes(reason)
if (clearsArtifact && getTrackedLinuxPackageArtifact() === artifact) {
clearTrackedLinuxPackageArtifact()
}
// Why: same reasoning as failLinuxPackageRecovery — a verdict from a cycle that has since been
// replaced must not clobber whatever card the user is looking at now.
if (getInstallCycleSignature() !== cycle) {
return
}
sendInstallFailureStatus({
state: 'error',
message: LINUX_PACKAGE_RECOVERY_MESSAGES[reason],
// Why: an unreadable file is not evidence the bytes changed, so the recovery card and its
// Copy/Show actions survive a transient I/O failure exactly as they do elsewhere.
...(recovery && !clearsArtifact ? { recovery } : {})
})
}
export async function getLinuxPackageInstallInstructions(): Promise<LinuxPackageInstallInstructions> {
const recovery = getActiveLinuxPackageRecovery()
if (!recovery) {
@@ -1850,7 +1963,10 @@ export function quitAndInstall(): void {
localBuildSelectionInProgress ||
pinnedBuildSelectionInProgress ||
pendingQuitAndInstallTimer ||
quitAndInstallInProgress
quitAndInstallInProgress ||
// Why: the quit timer is already cleared while the pre-install digest re-proof streams, so
// without this a second click would schedule a parallel install of the same package.
linuxPackageRevalidationInFlight
) {
return
}
@@ -1881,49 +1997,10 @@ export function quitAndInstall(): void {
// Why: defer the quit a tick so the renderer can flush dismissals/state before windows start closing.
pendingQuitAndInstallTimer = setTimeout(() => {
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()
})()
void 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<void> {
if (!app.isPackaged || is.dev) {
return
@@ -484,6 +484,9 @@ describe('LinuxPackageInstallRecoveryCard retry', () => {
fireEvent.click(button('Try Automatic Install Again'))
await flushActions()
// Why: the retry now re-proves the package digest before quitting, so the click must report
// progress instead of leaving three inert buttons for the length of the hash.
expect(isAriaDisabled(button('Checking package...'))).toBe(true)
// Why: quitAndInstall resolves as soon as main schedules the install, so a resolved promise is
// not an outcome — the slot stays held until a real status arrives.
expect(isAriaDisabled(button('Copy Install Command'))).toBe(true)
@@ -198,6 +198,10 @@ export function LinuxPackageInstallRecoveryCard({
'auto.components.LinuxPackageInstallRecoveryCard.3da99454c6',
'Try Automatic Install Again'
),
// Why: the retry re-proves the package digest before it quits, so the click is no longer
// instant — without this the card would just go inert for the length of the hash.
pendingLabel: CHECKING_LABEL,
isPending: pendingAction === 'retry',
disabled: pendingAction !== null,
onClick: handleRetryAutomatic
}