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>
This commit is contained in:
N0zoM1z0
2026-10-15 02:38:36 +00:00
parent 41189f88c4
commit 80ca14f16d
9 changed files with 382 additions and 32 deletions
+4
View File
@@ -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/<launcher-sha256>`.
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 `<launcher-sha256>.backup-<id>`. 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,
+9 -2
View File
@@ -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) ||
+78 -17
View File
@@ -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<NodeJS.ProcessEnv>,
): Result<string, HopperReleaseError> => {
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<Result<LinuxHopperRelease, HopperReleaseError>> {
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<Result<void, HopperReleaseError>> {
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<void> => {
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"],
+1 -1
View File
@@ -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`}/<launcher-sha256> 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`}/<launcher-sha256> for offline verification. Preserve replaced damaged evidence beside it as <launcher-sha256>.backup-<id>. 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,
+32
View File
@@ -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<Result<null, ProviderCleanupError>>;
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<Result<null, ProviderCleanupError>> {
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 } = {},
+27 -3
View File
@@ -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;
@@ -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,
@@ -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",
@@ -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();
}
},
);
});