From 80ca14f16daaebc83cd012e30d7c32a1f5bc066c Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:38:36 +0000 Subject: [PATCH] fix(hopper): recover approved caches and retain verification cleanup Normalize empty XDG cache settings at their owner and preserve damaged signed evidence as a backup when approved setup publishes an authenticated replacement. Keep read-only admission strict and retain incomplete publisher cleanup through client cancellation, restart admission and shutdown retry. Verify offline evidence with isolated module state and observed PGP/native inspection, remove vendor-network dependence from the foreign-launcher doctor test, and bound small feed/key requests separately from package downloads. Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> --- docs/installation.md | 4 + src/application/HopperDownload.ts | 11 +- src/application/LinuxHopperPublisher.ts | 95 +++++++++-- src/application/SetupPlan.ts | 2 +- src/hopper/BridgeLauncher.ts | 32 ++++ src/hopper/HopperClient.ts | 30 +++- .../cli/doctorHopperDiscovery.test.ts | 2 + .../providers/hopper/bridgeLauncher.test.ts | 85 +++++++++- .../linuxHopperPublisherVerification.test.ts | 153 +++++++++++++++++- 9 files changed, 382 insertions(+), 32 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index e74f491bf0..4c1b5f2f13 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -552,6 +552,10 @@ The feed row itself is not PGP-signed; doctor labels its display version as The approval plan includes the caller-selected cache location. Approved setup retains the signed archive, public key and release metadata under `${XDG_CACHE_HOME:-$HOME/.cache}/rea/hopper-releases/`. +An empty `XDG_CACHE_HOME` uses `$HOME/.cache`. Approved setup can repair a +damaged caller-owned entry after authenticating a fresh package; the previous +entry is preserved beside it as `.backup-`. Ordinary doctor +and launches reject damaged evidence and never repair it implicitly. Doctor and launches authenticate this evidence for offline use; an editable digest receipt alone cannot authorize a launcher. Verification reuses at most eight immutable package proofs per process, keyed by the complete package, diff --git a/src/application/HopperDownload.ts b/src/application/HopperDownload.ts index 53bf2db43b..dc8cf66084 100644 --- a/src/application/HopperDownload.ts +++ b/src/application/HopperDownload.ts @@ -4,9 +4,16 @@ export const HOPPER_DOWNLOAD_BYTES = 100_000_000; /** Bound observed response bytes before retaining them, including chunked bodies. */ export const downloadHopperPackage = async ( url: string, - options: { readonly signal?: AbortSignal; readonly maxBytes?: number } = {}, + options: { + readonly signal?: AbortSignal; + readonly maxBytes?: number; + readonly timeoutMs?: number; + } = {}, ): Promise<{ readonly ok: boolean; readonly bytes: Uint8Array }> => { - const deadline = AbortSignal.timeout(120_000); + const timeoutMs = options.timeoutMs ?? 120_000; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 120_000) + throw new RangeError("Invalid Hopper response time budget"); + const deadline = AbortSignal.timeout(timeoutMs); const maxBytes = options.maxBytes ?? HOPPER_DOWNLOAD_BYTES; if ( !Number.isSafeInteger(maxBytes) || diff --git a/src/application/LinuxHopperPublisher.ts b/src/application/LinuxHopperPublisher.ts index b989354aa7..cbf4f5ef18 100644 --- a/src/application/LinuxHopperPublisher.ts +++ b/src/application/LinuxHopperPublisher.ts @@ -1,9 +1,10 @@ -import { createHash } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { copyFile, lstat, mkdir, rename, writeFile } from "node:fs/promises"; import { isAbsolute, join } from "node:path"; import { z } from "zod"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js"; import { err, ok, type Result } from "../domain/result.js"; import { hashStableFile } from "../filesystem/StableFileHash.js"; @@ -29,6 +30,8 @@ import { withRegularFile } from "./RegularFileRead.js"; /** Metadata/key inspection budget, independent of the compressed package budget. */ const RELEASE_METADATA_BYTES = 1024 * 1024; +/** Small feed/key requests must not consume the full package-download deadline. */ +const RELEASE_METADATA_TIMEOUT_MS = 10_000; const cacheRecordSchema = z.object({ format: z.literal("rea-signed-hopper-release-v1"), family: z.enum(["deb", "rpm", "arch"]), @@ -76,7 +79,7 @@ export const linuxHopperReleaseCacheDirectory = ( environment: Readonly, ): Result => { try { - const supplied = environment.XDG_CACHE_HOME; + const supplied = environment.XDG_CACHE_HOME || undefined; if (supplied !== undefined && !isAbsolute(supplied)) return err( new HopperReleaseError( @@ -124,10 +127,12 @@ export class LinuxHopperPublisher { signal?: AbortSignal, ): Promise> { try { + signal?.throwIfAborted(); if (this.#feed === undefined) { const response = await downloadHopperPackage(HOPPER_RELEASES_URL, { ...(signal === undefined ? {} : { signal }), maxBytes: RELEASE_METADATA_BYTES, + timeoutMs: RELEASE_METADATA_TIMEOUT_MS, }); if (!response.ok) throw new HopperReleaseError( @@ -169,6 +174,7 @@ export class LinuxHopperPublisher { const response = await downloadHopperPackage(HOPPER_PUBLIC_KEY_URL, { ...(signal === undefined ? {} : { signal }), maxBytes: RELEASE_METADATA_BYTES, + timeoutMs: RELEASE_METADATA_TIMEOUT_MS, }); if (!response.ok) throw new HopperReleaseError( @@ -251,6 +257,7 @@ export class LinuxHopperPublisher { signal?: AbortSignal, ): Promise> { try { + signal?.throwIfAborted(); await mkdir(this.cacheRoot, { recursive: true, mode: 0o700 }); const parent = await lstat(this.cacheRoot); if ( @@ -295,7 +302,43 @@ export class LinuxHopperPublisher { verified.release.main_exe_hash, signal, ); - if (!existing.ok) throw existing.error; + if (!existing.ok) { + if (["cancelled", "cleanup_failed"].includes(existing.error.reason)) + throw existing.error; + signal?.throwIfAborted(); + const previous = await lstat(target); + if ( + !previous.isDirectory() || + previous.isSymbolicLink() || + previous.uid !== process.getuid?.() + ) + throw existing.error; + // Only approved retention may repair an entry. Preserve all old + // evidence, including malformed data, without following symlinks. + const backup = `${target}.backup-${randomUUID()}`; + await rename(target, backup); + const moved = await lstat(backup); + if (moved.dev !== previous.dev || moved.ino !== previous.ino) + throw new HopperReleaseError( + "verification_unavailable", + `The Hopper cache entry changed during repair; its moved contents are preserved at ${backup}. Retry approved setup.`, + ); + try { + await rename(stage, target); + } catch (replacementCause: unknown) { + // Another approved writer may have published the same release. + const replacement = await this.#cachedPackage( + verified.release.main_exe_hash, + signal, + ); + if (!replacement.ok) + throw new HopperReleaseError( + "verification_unavailable", + `The Hopper cache repair did not finish; previous evidence is preserved at ${backup}. Retry approved setup.`, + { cause: replacementCause }, + ); + } + } } }, this.cacheRoot); return ok(undefined); @@ -600,18 +643,34 @@ export class LinuxHopperPublisher { /** Retry only resources retained by this producer after failed cleanup. */ export const retryLinuxHopperPublisherCleanup = async (): Promise => { const causes: unknown[] = []; + const observations = []; for (const scope of pendingCleanup) { try { await scope.close(); pendingCleanup.delete(scope); } catch (cause: unknown) { causes.push(cause); + observations.push( + ArtifactReaderFailure.cleanupObservation( + cause, + "hopper-publisher-verification", + ), + ); } } if (causes.length > 0) - throw new AggregateError( - causes, + throw new ArtifactReaderFailure( + "unavailable", "Hopper publisher cleanup remains incomplete", + { + cause: new AggregateError(causes, "Hopper publisher cleanup failures"), + cleanup: { + reason: observations.map(({ reason }) => reason).join("; "), + resources: [ + ...new Set(observations.flatMap(({ resources }) => resources)), + ], + }, + }, ); }; @@ -625,19 +684,21 @@ const releaseFailure = ( signal: AbortSignal | undefined, fallback: HopperReleaseError["reason"], ): HopperReleaseError => - signal?.aborted === true - ? new HopperReleaseError( - "cancelled", - "Hopper publisher verification was cancelled.", - { cause }, - ) - : cause instanceof HopperReleaseError - ? cause - : new HopperReleaseError( - fallback, - "Hopper publisher verification could not complete.", + cause instanceof HopperReleaseError && cause.reason === "cleanup_failed" + ? cause + : signal?.aborted === true + ? new HopperReleaseError( + "cancelled", + "Hopper publisher verification was cancelled.", { cause }, - ); + ) + : cause instanceof HopperReleaseError + ? cause + : new HopperReleaseError( + fallback, + "Hopper publisher verification could not complete.", + { cause }, + ); const launcherIdentity = ( verified: VerifiedLinuxHopperPackage, source: VerifiedLinuxHopperLauncher["source"], diff --git a/src/application/SetupPlan.ts b/src/application/SetupPlan.ts index 6e017d8eac..3a7b3d0f1d 100644 --- a/src/application/SetupPlan.ts +++ b/src/application/SetupPlan.ts @@ -301,7 +301,7 @@ const setupPlan = (input: { : "system package manager", detail: input.platform === "linux" - ? `Download the current official Hopper demo, verify its PGP publisher and launcher bytes, and retain archive, public-key.asc and release.json under ${input.hopperCacheDirectory ?? `${input.homeDirectory}/.cache/rea/hopper-releases`}/ for offline verification. Install Hopper plus its Xvfb demo-session dependencies. REA uses a private display and selects Hopper's offered demo mode for each analysis session.` + ? `Download the current official Hopper demo, verify its PGP publisher and launcher bytes, and retain archive, public-key.asc and release.json under ${input.hopperCacheDirectory ?? `${input.homeDirectory}/.cache/rea/hopper-releases`}/ for offline verification. Preserve replaced damaged evidence beside it as .backup-. Install Hopper plus its Xvfb demo-session dependencies. REA uses a private display and selects Hopper's offered demo mode for each analysis session.` : "Download the official Hopper package, verify it, and install it. Hopper may show its demo or license prompt when first opened.", external: true, operation: "install" as const, diff --git a/src/hopper/BridgeLauncher.ts b/src/hopper/BridgeLauncher.ts index 5441aba396..0110379785 100644 --- a/src/hopper/BridgeLauncher.ts +++ b/src/hopper/BridgeLauncher.ts @@ -9,6 +9,8 @@ import { HopperStartError, } from "../domain/hopperErrors.js"; import { err, ok, type Result } from "../domain/result.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { cleanupOwnedProcessGroup } from "../process/ProcessOwnership.js"; import { execFileOutput } from "../process/ExecFileOutput.js"; import { @@ -35,6 +37,7 @@ import type { HopperTargetLease } from "./HopperTargetLease.js"; import { HOPPER_SHUTDOWN_TIMEOUT_MS } from "./HopperSessionValues.js"; import { verifyMacHopperNativeHelper } from "./HopperMacPublisher.js"; import { inspectLinuxHopperLauncher } from "../application/LinuxHopper.js"; +import { retryLinuxHopperPublisherCleanup } from "../application/LinuxHopperPublisher.js"; const execFileAsync = promisify(execFile); const HOPPER_BACKGROUND_STARTUP_MS = 5_000; @@ -74,6 +77,8 @@ export type BridgeLaunch = { /** Application-owned capability that starts the in-Hopper bridge. */ export interface BridgeLauncher { + /** Retry owned pre-launch resources even when no application was started. */ + cleanup?(): Promise>; launch( session: BridgeSession, options?: { readonly signal?: AbortSignal }, @@ -112,6 +117,7 @@ export type HopperApplicationLauncherOptions = export interface HopperApplicationLauncherDependencies { readonly verifyNativeHelper?: typeof verifyMacHopperNativeHelper; readonly verifyLinuxLauncher?: typeof inspectLinuxHopperLauncher; + readonly retryPublisherCleanup?: typeof retryLinuxHopperPublisherCleanup; readonly platform?: NodeJS.Platform; readonly acquireTargetLease?: typeof acquireHopperTargetLease; readonly acquireApplicationLease?: typeof acquireLinuxHopperApplicationLease; @@ -142,6 +148,32 @@ export class HopperApplicationLauncher implements BridgeLauncher { readonly dependencies: HopperApplicationLauncherDependencies = {}, ) {} + async cleanup(): Promise> { + if (!usesLinuxDemo(this.options)) return ok(null); + try { + await ( + this.dependencies.retryPublisherCleanup ?? + retryLinuxHopperPublisherCleanup + )(); + return ok(null); + } catch (cause: unknown) { + const observation = ArtifactReaderFailure.cleanupObservation( + cause, + "hopper-publisher-verification", + ); + return err( + new ProviderCleanupError( + "hopper", + observation.resources, + { + reason: observation.reason, + }, + { cause }, + ), + ); + } + } + async launch( session: BridgeSession, options: { readonly signal?: AbortSignal } = {}, diff --git a/src/hopper/HopperClient.ts b/src/hopper/HopperClient.ts index c81a2f2fa7..a0668c3a77 100644 --- a/src/hopper/HopperClient.ts +++ b/src/hopper/HopperClient.ts @@ -29,6 +29,7 @@ import { type ProviderOperationRequest, } from "../domain/providerOperationHealth.js"; import { err, ok, type Result } from "../domain/result.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import type { JsonValue } from "../domain/jsonValue.js"; import type { ProgressReporter } from "../application/ProgressReporter.js"; import { silentLogger } from "../logger.js"; @@ -328,7 +329,8 @@ export class HopperClient { if ( this.#socket !== undefined || this.#resources.launch !== undefined || - this.#resources.runtimeRoot !== undefined + this.#resources.runtimeRoot !== undefined || + this.#startupFailureResult?.cleanupIncomplete === true ) { return err( this.#startupFailureResult ?? @@ -563,7 +565,9 @@ export class HopperClient { this.#releaseTransport(), ); await this.#reconnectForCleanup(); - return await this.#cleanup(options); + const closed = await this.#cleanup(options); + if (closed.ok) this.#startupFailureResult = undefined; + return closed; } finally { if (this.#startPromise === starting) this.#startPromise = undefined; if (this.#startupController === controller) { @@ -641,7 +645,7 @@ export class HopperClient { this.#closing = true; this.#ready = false; try { - return await cleanupHopperSession({ + const closed = await cleanupHopperSession({ socket: this.#socket, resources: this.#resources, activeRequest: this.#requests.activity(), @@ -653,6 +657,26 @@ export class HopperClient { retainDocument: options.retainDocument === true, releaseTransport: () => this.#releaseTransport(), }); + const launcherClosed = await this.#options.launcher + .cleanup?.() + .catch((cause: unknown) => + err( + new ProviderCleanupError( + "hopper", + ["hopper-launcher"], + { + reason: cause instanceof Error ? cause.message : String(cause), + }, + { cause }, + ), + ), + ); + if (launcherClosed === undefined || launcherClosed.ok) return closed; + return closed.ok + ? launcherClosed + : err( + analysisErrorWithCleanupFailure(closed.error, launcherClosed.error), + ); } finally { if (this.#socket?.destroyed === true) this.#socket = undefined; diff --git a/tests/boundary/cli/doctorHopperDiscovery.test.ts b/tests/boundary/cli/doctorHopperDiscovery.test.ts index 12df465d77..7ce591b1ba 100644 --- a/tests/boundary/cli/doctorHopperDiscovery.test.ts +++ b/tests/boundary/cli/doctorHopperDiscovery.test.ts @@ -1,5 +1,6 @@ import { access, chmod, mkdir, readFile, writeFile } from "node:fs/promises"; import { delimiter, join } from "node:path"; +import { fileURLToPath } from "node:url"; import { expect } from "vitest"; @@ -45,6 +46,7 @@ cliTest.skipIf(!["darwin", "linux"].includes(process.platform))( ? { PATH: [join(home, "bin"), process.env.PATH ?? ""].join(delimiter), REA_HOPPER_LDD_TEST_MARKER: loaderMarker, + NODE_OPTIONS: `--import=${fileURLToPath(new URL("../../fixtures/hopperRejectionFeed.mjs", import.meta.url))}`, } : {}), HOME: home, diff --git a/tests/boundary/providers/hopper/bridgeLauncher.test.ts b/tests/boundary/providers/hopper/bridgeLauncher.test.ts index ab29c88abd..155a6414f0 100644 --- a/tests/boundary/providers/hopper/bridgeLauncher.test.ts +++ b/tests/boundary/providers/hopper/bridgeLauncher.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { promisify } from "node:util"; import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; @@ -21,6 +21,9 @@ import { err, ok } from "../../../../src/domain/result.js"; import { HopperReleaseError } from "../../../../src/application/HopperReleaseFeed.js"; import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; +import { ArtifactResourceScope } from "../../../../src/artifacts/ArtifactResourceScope.js"; +import { PrivateRuntimeRoot } from "../../../../src/process/PrivateRuntimeRoot.js"; +import { HopperClient } from "../../../../src/hopper/HopperClient.js"; const readyDirectDisplay = async () => ({ @@ -61,6 +64,86 @@ const options = (launcherPath: string): HopperApplicationLauncherOptions => ({ demoHelperPath: "/rea/hopper-demo-x11.py", }); +it("retains cancelled verification cleanup through client shutdown and retries it", async () => { + const directory = await createTestTempDirectory("rea-publisher-cancel-"); + const targetPath = join(directory, "target"); + await writeFile(targetPath, "source-owned target"); + const root = await PrivateRuntimeRoot.create({ + prefix: "rea-publisher-pending-", + }); + const resources = new ArtifactResourceScope(); + let allowCleanup = false; + expect( + await resources.release({ + kind: "private-runtime-root", + resource: root.path, + root: { + path: root.path, + close: async () => { + if (!allowCleanup) + throw new Error("Fixture publisher cleanup denied"); + await root.close(); + }, + }, + }), + ).toMatchObject({ kind: "failed" }); + const controller = new AbortController(); + const verify = vi.fn(async () => { + controller.abort(); + return err( + new HopperReleaseError( + "cleanup_failed", + "Publisher cleanup remains owned", + ), + ); + }); + const launcher = new HopperApplicationLauncher( + { + ...options(process.execPath), + targetPath, + }, + { + verifyLinuxLauncher: verify, + retryPublisherCleanup: () => resources.close(), + selectPrivateDisplay: readyDirectDisplay, + }, + ); + const client = new HopperClient({ launcher, startupTimeoutMs: 5_000 }); + try { + const started = await client.start(controller.signal); + expect(started).toMatchObject({ + ok: false, + error: { cleanupIncomplete: true, cleanupResources: [root.path] }, + }); + if (started.ok) + throw new Error("Expected cancelled startup with pending cleanup"); + expect(projectAnalysisError(started.error)).toMatchObject({ + code: "cleanup_incomplete", + details: { + diagnostics: { primary_error: { code: "cancelled" } }, + resources: [root.path], + }, + }); + expect(await client.start()).toMatchObject({ + ok: false, + error: { cleanupIncomplete: true }, + }); + expect(verify).toHaveBeenCalledOnce(); + expect(await client.close()).toMatchObject({ + ok: false, + error: { cleanupIncomplete: true }, + }); + await access(root.path); + allowCleanup = true; + expect(await client.close()).toEqual({ ok: true, value: null }); + await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + allowCleanup = true; + await client.close(); + await resources.close(); + } +}); + describe("Hopper bridge launcher selection", () => { it.each([ "/opt/hopper/bin/Hopper", diff --git a/tests/boundary/setup/linuxHopperPublisherVerification.test.ts b/tests/boundary/setup/linuxHopperPublisherVerification.test.ts index 2582edb454..7a23a42642 100644 --- a/tests/boundary/setup/linuxHopperPublisherVerification.test.ts +++ b/tests/boundary/setup/linuxHopperPublisherVerification.test.ts @@ -5,6 +5,7 @@ import { copyFile, mkdir, readFile, + readdir, rm, symlink, writeFile, @@ -15,7 +16,11 @@ import { promisify } from "node:util"; import { createMessage, generateKey, readPrivateKey, sign } from "openpgp"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { LinuxHopperPublisher } from "../../../src/application/LinuxHopperPublisher.js"; +import { + LinuxHopperPublisher, + linuxHopperReleaseCacheDirectory, + retryLinuxHopperPublisherCleanup, +} from "../../../src/application/LinuxHopperPublisher.js"; import { HOPPER_LINUX_PRIMARY_FINGERPRINT, type LinuxHopperRelease, @@ -92,14 +97,33 @@ const fixture = async (family: "deb" | "arch" = "deb") => { }; }; -const admitFixturePublisher = (fingerprint: string) => { - vi.spyOn(signatures, "verifyDetachedPgpSignature").mockImplementation( - (bytes, signature, key, primary, signal) => { +const admitFixturePublisher = ( + fingerprint: string, + module: typeof signatures = signatures, +) => + vi + .spyOn(module, "verifyDetachedPgpSignature") + .mockImplementation((bytes, signature, key, primary, signal) => { expect(primary).toBe(HOPPER_LINUX_PRIMARY_FINGERPRINT); return verifySignature(bytes, signature, key, fingerprint, signal); - }, - ); -}; + }); + +it.each([undefined, "", "/selected/cache", "relative/cache"])( + "resolves the selected XDG cache setting %j once for every consumer", + (setting) => { + const selected = linuxHopperReleaseCacheDirectory({ + HOME: "/caller/home", + ...(setting === undefined ? {} : { XDG_CACHE_HOME: setting }), + }); + if (setting === "relative/cache") + expect(selected).toMatchObject({ ok: false }); + else + expect(selected).toEqual({ + ok: true, + value: join(setting || "/caller/home/.cache", "rea/hopper-releases"), + }); + }, +); describe("Linux signed package retention boundary", () => { linux( @@ -150,7 +174,17 @@ describe("Linux signed package retention boundary", () => { await f.publisher.retainVerifiedArchive(verified.value, f.archive), ).toEqual({ ok: true, value: undefined }); await f.publisher.close(); - const fresh = new LinuxHopperPublisher( + // A new instance alone still shares process-local cryptographic proofs. + // Reload the owner and assert actual PGP and native payload inspection. + vi.resetModules(); + const coldSignatures = + await import("../../../src/process/DetachedPgpSignature.js"); + const signature = admitFixturePublisher(f.fingerprint, coldSignatures); + const commands = await import("../../../src/process/ExecFileOutput.js"); + const extraction = vi.spyOn(commands, "execFileOutput"); + const { LinuxHopperPublisher: ColdPublisher } = + await import("../../../src/application/LinuxHopperPublisher.js"); + const fresh = new ColdPublisher( f.publisher.environment, async () => family, ); @@ -164,6 +198,12 @@ describe("Linux signed package retention boundary", () => { }, }); expect(network).not.toHaveBeenCalled(); + expect(signature).toHaveBeenCalledOnce(); + expect(extraction).toHaveBeenCalledWith( + family === "deb" ? "dpkg-deb" : "tar", + expect.any(Array), + expect.any(Object), + ); } finally { await fresh.close(); } @@ -233,6 +273,38 @@ describe("Linux signed cache tampering", () => { ok: false, }); expect(network).not.toHaveBeenCalled(); + // An explicitly approved fresh package can repair this entry while + // preserving the original evidence and keeping ordinary reads strict. + expect( + await f.publisher.retainVerifiedArchive(verified.value, f.archive), + ).toEqual({ ok: true, value: undefined }); + const backups = (await readdir(f.publisher.cacheRoot)).filter((name) => + name.startsWith(`${f.release.main_exe_hash}.backup-`), + ); + expect(backups).toHaveLength(1); + if (kind === "missing metadata") + await expect( + access(join(f.publisher.cacheRoot, backups[0]!, "release.json")), + ).rejects.toMatchObject({ code: "ENOENT" }); + else + expect( + await readFile( + join(f.publisher.cacheRoot, backups[0]!, "release.json"), + "utf8", + ), + ).toBeTruthy(); + expect(await f.publisher.inspectLauncher(f.launcher)).toMatchObject({ + ok: true, + }); + expect( + await f.publisher.retainVerifiedArchive(verified.value, f.archive), + ).toEqual({ ok: true, value: undefined }); + expect( + (await readdir(f.publisher.cacheRoot)).filter((name) => + name.includes(".backup-"), + ), + ).toEqual(backups); + expect(network).not.toHaveBeenCalled(); } finally { await f.publisher.close(); } @@ -277,6 +349,33 @@ describe("Linux signed cache tampering", () => { }); describe("Hopper verification capacity and ownership", () => { + it("bounds a stalled metadata fetch and preserves caller cancellation", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + (_url, options: RequestInit) => + new Promise((_resolve, reject) => { + const signal = options.signal; + signal?.addEventListener("abort", () => reject(signal.reason), { + once: true, + }); + signal?.throwIfAborted(); + }), + ), + ); + await expect( + downloadHopperPackage("https://www.hopperapp.com/fixture", { + timeoutMs: 10, + }), + ).rejects.toMatchObject({ name: "TimeoutError" }); + const controller = new AbortController(); + const reason = new Error("Caller cancelled metadata inspection"); + const pending = downloadHopperPackage("https://www.hopperapp.com/fixture", { + signal: controller.signal, + }); + controller.abort(reason); + await expect(pending).rejects.toBe(reason); + }); it("cancels an oversized chunked metadata response before retaining it", async () => { const cancelled = vi.fn(); vi.stubGlobal( @@ -324,4 +423,42 @@ describe("Hopper verification capacity and ownership", () => { await f.publisher.close(); } }); + + linux( + "preserves cleanup ownership when verification is cancelled", + async () => { + const f = await fixture(); + admitFixturePublisher(f.fingerprint); + const controller = new AbortController(); + const cleanup = vi + .spyOn(PrivateRuntimeRoot.prototype, "close") + .mockImplementation(async () => { + controller.abort(); + throw new Error("Fixture cleanup unavailable after cancellation"); + }); + try { + expect( + await f.publisher.verifyArchive( + "deb", + f.release, + f.archive, + controller.signal, + f.publicKey, + ), + ).toMatchObject({ + ok: false, + error: { reason: "cleanup_failed" }, + }); + await expect(retryLinuxHopperPublisherCleanup()).rejects.toMatchObject({ + cleanup: { + resources: [expect.stringContaining("rea-hopper-publisher-")], + }, + }); + } finally { + cleanup.mockRestore(); + await f.publisher.close(); + await retryLinuxHopperPublisherCleanup(); + } + }, + ); });