From 66fca324d17d682866c2ec346a0daf13ad41f310 Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:20:40 +0000 Subject: [PATCH] fix(hopper): serialize signed cache publication across processes Reuse Hopper's live socket lease for each canonical release cache entry and retain its cleanup capability alongside temporary inspection resources. Move that shared lease owner into process so setup and provider adapters consume the same implementation without reversing application dependencies. Only proven malformed or unauthenticated cache evidence permits approved replacement. Preserve old evidence and roll it back if publication fails; keep unavailable inspection and permission failures distinct from invalid data. Cover real cross-process publication/cancellation, rollback, failed lease release, missing/malformed evidence and the moved provider consumers. 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 | 5 + scripts/verify-real-hopper-fat.mjs | 2 +- scripts/verify-real-hopper.mjs | 2 +- src/application/LinuxHopperPublisher.ts | 351 ++++++--- src/artifacts/ArtifactResourceScope.ts | 10 +- src/hopper/BridgeLauncher.ts | 4 +- src/hopper/HopperTargetLease.test.ts | 2 +- .../HopperLease.ts} | 18 + .../providers/hopperStartupRecovery.test.ts | 2 +- .../cli/hopperLaunchFailureCli.test.ts | 2 +- .../process/binarySessionCleanupRetry.test.ts | 2 +- .../hopper/hopperClient.helperFailure.test.ts | 2 +- .../hopper/hopperVerificationCleanup.test.ts | 2 +- .../linuxHopperPublisherVerification.test.ts | 743 ++++++++++++------ tests/fixtures/hopperCachePublisher.mjs | 31 + .../hopperLeaseDescriptorPermissions.test.ts | 2 +- 16 files changed, 828 insertions(+), 352 deletions(-) rename src/{hopper/HopperTargetLease.ts => process/HopperLease.ts} (94%) create mode 100644 tests/fixtures/hopperCachePublisher.mjs diff --git a/docs/installation.md b/docs/installation.md index 4c1b5f2f13..39a53bb701 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -556,6 +556,11 @@ 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. +Approved publications of the same canonical cache entry share a process-backed +lease. Concurrent setup waits without moving another writer's evidence; a +failed replacement restores the previous directory when possible and reports +any retained backup path. Unavailable reads or native inspection tools do not +authorize replacing existing evidence. 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/scripts/verify-real-hopper-fat.mjs b/scripts/verify-real-hopper-fat.mjs index 5b1776ccb8..930935ef7c 100644 --- a/scripts/verify-real-hopper-fat.mjs +++ b/scripts/verify-real-hopper-fat.mjs @@ -23,7 +23,7 @@ import { requireMcpOperationResult, } from "./lib/mcp-verifier-results.mjs"; import { snapshotHopperRuntime } from "./lib/real-hopper-cleanup.mjs"; -import { HOPPER_TARGET_LEASE_DIRECTORY } from "../dist/hopper/HopperTargetLease.js"; +import { HOPPER_TARGET_LEASE_DIRECTORY } from "../dist/process/HopperLease.js"; import { parseConfig } from "../dist/config/parseConfig.js"; import { verifyHopperSourceBinding, diff --git a/scripts/verify-real-hopper.mjs b/scripts/verify-real-hopper.mjs index a82c1429a6..fbe2ff3757 100644 --- a/scripts/verify-real-hopper.mjs +++ b/scripts/verify-real-hopper.mjs @@ -12,7 +12,7 @@ import { requirePseudocode, } from "../dist/application/RealHopperAssertions.js"; import { HOPPER_PROVIDER_IDENTITY } from "../dist/hopper/HopperProviderCapabilities.js"; -import { HOPPER_TARGET_LEASE_DIRECTORY } from "../dist/hopper/HopperTargetLease.js"; +import { HOPPER_TARGET_LEASE_DIRECTORY } from "../dist/process/HopperLease.js"; import { REA_WORKFLOW_PROVIDER } from "../dist/application/InvestigationProviders.js"; import { loadRealHopperFixtureTargets } from "./lib/real-hopper-fixture.mjs"; import { verifyHopperStringObjects } from "./lib/real-hopper-search.mjs"; diff --git a/src/application/LinuxHopperPublisher.ts b/src/application/LinuxHopperPublisher.ts index 6f3ac2d3ee..79f09729e3 100644 --- a/src/application/LinuxHopperPublisher.ts +++ b/src/application/LinuxHopperPublisher.ts @@ -3,7 +3,10 @@ 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 { + ArtifactResourceScope, + type ArtifactResourceOwner, +} 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"; @@ -12,6 +15,11 @@ import { readBoundedFileBytes } from "../process/BoundedFileBytes.js"; import { verifyDetachedPgpSignature } from "../process/DetachedPgpSignature.js"; import { execFileOutput } from "../process/ExecFileOutput.js"; import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js"; +import { waitForAbortableDelay } from "../process/ProviderDeadline.js"; +import { + acquireHopperReleaseCacheLease, + type HopperTargetLease, +} from "../process/HopperLease.js"; import { downloadHopperPackage, HOPPER_DOWNLOAD_BYTES, @@ -270,90 +278,158 @@ export class LinuxHopperPublisher { "The Hopper release cache is not a caller-owned directory.", ); const target = join(this.cacheRoot, verified.release.main_exe_hash); - await this.#withTemporary(async (stage) => { - await copyFile(archive, join(stage, "archive")); - if ( - (await hashStableFile(join(stage, "archive"), signal)).sha256 !== - verified.packageSha256 - ) - throw new HopperReleaseError( - "integrity", - "The verified package changed before cache retention.", - ); - await writeFile(join(stage, "public-key.asc"), verified.publicKey, { - flag: "wx", - mode: 0o600, - }); - await writeFile( - join(stage, "release.json"), - JSON.stringify({ - format: "rea-signed-hopper-release-v1", - family: verified.family, - release: verified.release, - }) + "\n", - { flag: "wx", mode: 0o600 }, - ); - try { - await rename(stage, target); - } catch (cause: unknown) { - if (!isExistingDestination(cause)) throw cause; - // A concurrent or previous writer is reusable only after complete authentication. - const existing = await this.#cachedPackage( - verified.release.main_exe_hash, - signal, - ); - if (!existing.ok) { - if (["cancelled", "cleanup_failed"].includes(existing.error.reason)) - throw existing.error; - signal?.throwIfAborted(); - const previous = await lstat(target); + const lease = await this.#acquireCacheLease( + verified.release.main_exe_hash, + signal, + ); + await this.#withOwnedResource( + { + kind: "lease", + resource: lease.socketPath ?? "hopper-release-cache-lease", + lease, + }, + async () => { + signal?.throwIfAborted(); + await this.#withTemporary(async (stage) => { + await copyFile(archive, join(stage, "archive")); if ( - !previous.isDirectory() || - previous.isSymbolicLink() || - previous.uid !== process.getuid?.() + (await hashStableFile(join(stage, "archive"), signal)).sha256 !== + verified.packageSha256 ) - 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.`, + "integrity", + "The verified package changed before cache retention.", ); - 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( - replacement.error.reason === "cleanup_failed" - ? "cleanup_failed" - : "verification_unavailable", - `The Hopper cache repair did not finish; previous evidence is preserved at ${backup}. Retry approved setup.`, - { - cause: new AggregateError( - [replacementCause, replacement.error], - "Hopper cache publication and verification outcomes", - ), - }, - ); - } - } - } - }, this.cacheRoot); + await writeFile(join(stage, "public-key.asc"), verified.publicKey, { + flag: "wx", + mode: 0o600, + }); + await writeFile( + join(stage, "release.json"), + JSON.stringify({ + format: "rea-signed-hopper-release-v1", + family: verified.family, + release: verified.release, + }) + "\n", + { flag: "wx", mode: 0o600 }, + ); + await this.#publishCacheStage( + stage, + target, + verified.release.main_exe_hash, + signal, + ); + }, this.cacheRoot); + }, + ); return ok(undefined); } catch (cause: unknown) { return err(releaseFailure(cause, signal, "verification_unavailable")); } } + async #publishCacheStage( + stage: string, + target: string, + digest: string, + signal?: AbortSignal, + ): Promise { + const existing = await this.#cachedPackage(digest, signal); + if (existing.ok) return; + if (existing.error.reason === "cache_missing") { + await rename(stage, target); + return; + } + if ( + ![ + "integrity", + "release_metadata", + "publisher_verification_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 under the publication lease may repair an entry. + // Preserve malformed evidence without following links or deleting content. + const backup = `${target}.backup-${randomUUID()}`; + await rename(target, backup); + try { + const moved = await lstat(backup); + if (moved.dev !== previous.dev || moved.ino !== previous.ino) + throw new Error("The Hopper cache entry changed during repair."); + await rename(stage, target); + } catch (cause: unknown) { + const replacement = await this.#cachedPackage(digest, signal); + if (replacement.ok) return; + await this.#failedCachePublication( + target, + backup, + [cause, replacement.error], + replacement.error.reason === "cleanup_failed", + ); + } + } + + async #acquireCacheLease( + digest: string, + signal?: AbortSignal, + ): Promise { + const deadline = performance.now() + 120_000; + const runId = randomUUID(); + let delay = 25; + while (true) { + signal?.throwIfAborted(); + const acquired = await acquireHopperReleaseCacheLease({ + cacheDirectory: this.cacheRoot, + launcherSha256: digest, + runId, + }); + if (acquired.acquired) return acquired.lease; + const remaining = deadline - performance.now(); + if (remaining <= 0) + throw new HopperReleaseError( + "verification_unavailable", + "Another approved Hopper setup still owns this release cache. Retry after it finishes.", + ); + await waitForAbortableDelay(Math.min(delay, remaining), signal); + delay = Math.min(delay * 2, 200); + } + } + + async #failedCachePublication( + target: string, + backup: string, + causes: unknown[], + cleanupFailed = false, + ): Promise { + let preserved = backup; + try { + // Under the publication lease, restore the previous directory on failure. + // POSIX rename cannot replace another nonempty evidence directory. + await rename(backup, target); + preserved = target; + } catch (cause: unknown) { + causes.push(cause); + } + throw new HopperReleaseError( + cleanupFailed ? "cleanup_failed" : "verification_unavailable", + `The Hopper cache repair did not finish; previous evidence is preserved at ${preserved}. Retry approved setup.`, + { + cause: new AggregateError( + causes, + "Hopper cache publication and recovery outcomes", + ), + }, + ); + } + async inspectLauncher( path: string, signal?: AbortSignal, @@ -445,42 +521,45 @@ export class LinuxHopperPublisher { ): Promise> { try { const root = join(this.cacheRoot, digest); - let metadata: Buffer; try { - metadata = await this.#read( - join(root, "release.json"), - RELEASE_METADATA_BYTES, - signal, - ); - } catch (cause: unknown) { - if (cause instanceof Error && Reflect.get(cause, "code") === "ENOENT") { - try { - await lstat(root); - } catch (rootCause: unknown) { - if ( - rootCause instanceof Error && - Reflect.get(rootCause, "code") === "ENOENT" - ) - return err( - new HopperReleaseError( - "cache_missing", - "No retained signed Hopper package identifies this launcher.", - { cause }, - ), - ); - throw rootCause; - } + const directory = await lstat(root); + if (!directory.isDirectory() || directory.isSymbolicLink()) throw new HopperReleaseError( "integrity", - "The retained Hopper package is missing its release metadata.", - { cause }, + "The retained Hopper cache entry is not a regular directory.", + ); + } catch (cause: unknown) { + if (cause instanceof Error && Reflect.get(cause, "code") === "ENOENT") { + return err( + new HopperReleaseError( + "cache_missing", + "No retained signed Hopper package identifies this launcher.", + { cause }, + ), ); } throw cause; } - const parsed = cacheRecordSchema.safeParse( - JSON.parse(decodeUtf8(metadata)), + // Distinguish proven malformed/missing evidence from unavailable reads or + // inspection tools. Only the former authorize approved cache replacement. + for (const name of ["release.json", "public-key.asc", "archive"]) + await this.#assertCacheFile(join(root, name)); + const metadata = await this.#read( + join(root, "release.json"), + RELEASE_METADATA_BYTES, + signal, ); + let value: unknown; + try { + value = JSON.parse(decodeUtf8(metadata)); + } catch (cause: unknown) { + throw new HopperReleaseError( + "release_metadata", + "The retained Hopper package metadata is malformed.", + { cause }, + ); + } + const parsed = cacheRecordSchema.safeParse(value); if (!parsed.success) throw new HopperReleaseError( "release_metadata", @@ -496,13 +575,21 @@ export class LinuxHopperPublisher { "integrity", "The retained Hopper metadata does not identify this launcher.", ); - const key = decodeUtf8( - await this.#read( - join(root, "public-key.asc"), - RELEASE_METADATA_BYTES, - signal, - ), + const keyBytes = await this.#read( + join(root, "public-key.asc"), + RELEASE_METADATA_BYTES, + signal, ); + let key: string; + try { + key = decodeUtf8(keyBytes); + } catch (cause: unknown) { + throw new HopperReleaseError( + "publisher_verification_failed", + "The retained Hopper public key is malformed.", + { cause }, + ); + } return this.verifyArchive( parsed.data.family, release.value, @@ -515,6 +602,25 @@ export class LinuxHopperPublisher { } } + async #assertCacheFile(path: string): Promise { + try { + const stats = await lstat(path); + if (!stats.isFile() || stats.isSymbolicLink()) + throw new HopperReleaseError( + "integrity", + `Required Hopper evidence is not a regular file: ${path}.`, + ); + } catch (cause: unknown) { + if (cause instanceof Error && Reflect.get(cause, "code") === "ENOENT") + throw new HopperReleaseError( + "integrity", + `Required Hopper evidence is missing: ${path}.`, + { cause }, + ); + throw cause; + } + } + async #packageLauncherHash( family: LinuxPackageFamily, archive: string, @@ -613,19 +719,29 @@ export class LinuxHopperPublisher { prefix: "rea-hopper-publisher-", ...(parent === undefined ? {} : { parent }), }); + return this.#withOwnedResource( + { + kind: "private-runtime-root", + resource: root.path, + root, + }, + () => operation(root.path), + ); + } + + async #withOwnedResource( + owner: ArtifactResourceOwner, + operation: () => Promise, + ): Promise { let outcome: | { readonly ok: true; readonly value: T } | { readonly ok: false; readonly cause: unknown }; try { - outcome = { ok: true, value: await operation(root.path) }; + outcome = { ok: true, value: await operation() }; } catch (cause: unknown) { outcome = { ok: false, cause }; } - const cleanup = await this.#resources.release({ - kind: "private-runtime-root", - resource: root.path, - root, - }); + const cleanup = await this.#resources.release(owner); if (cleanup.kind === "failed") { pendingCleanup.add(this.#resources); const causes = outcome.ok @@ -633,7 +749,7 @@ export class LinuxHopperPublisher { : [outcome.cause, cleanup.cause]; throw new HopperReleaseError( "cleanup_failed", - `Hopper publisher verification retained cleanup ownership for ${root.path}.`, + `Hopper publisher verification retained cleanup ownership for ${owner.resource}.`, { cause: new AggregateError( causes, @@ -685,9 +801,6 @@ export const retryLinuxHopperPublisherCleanup = async (): Promise => { const decodeUtf8 = (bytes: Uint8Array): string => new TextDecoder("utf-8", { fatal: true }).decode(bytes); -const isExistingDestination = (cause: unknown): boolean => - cause instanceof Error && - ["EEXIST", "ENOTEMPTY"].includes(String(Reflect.get(cause, "code"))); const releaseFailure = ( cause: unknown, signal: AbortSignal | undefined, diff --git a/src/artifacts/ArtifactResourceScope.ts b/src/artifacts/ArtifactResourceScope.ts index 8728996311..f069845357 100644 --- a/src/artifacts/ArtifactResourceScope.ts +++ b/src/artifacts/ArtifactResourceScope.ts @@ -8,8 +8,13 @@ import { ArtifactReaderFailure } from "./ArtifactReader.js"; import type { SafeOutputTree } from "./SafeOutputTree.js"; import type { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js"; -/** One artifact resource and its format-specific cleanup owner. */ +/** One artifact resource and the capability that owns its cleanup. */ export type ArtifactResourceOwner = + | { + readonly kind: "lease"; + readonly resource: string; + readonly lease: { release(): Promise }; + } | { readonly kind: "javascript-source-analysis"; readonly resource: string; @@ -156,6 +161,9 @@ export class ArtifactResourceScope { const cleanup = async (owner: ArtifactResourceOwner): Promise => { switch (owner.kind) { + case "lease": + await owner.lease.release(); + return; case "javascript-source-analysis": await owner.analysis.close(); return; diff --git a/src/hopper/BridgeLauncher.ts b/src/hopper/BridgeLauncher.ts index 0110379785..1cef1cf7d2 100644 --- a/src/hopper/BridgeLauncher.ts +++ b/src/hopper/BridgeLauncher.ts @@ -32,8 +32,8 @@ import { import { acquireHopperTargetLease, acquireLinuxHopperApplicationLease, -} from "./HopperTargetLease.js"; -import type { HopperTargetLease } from "./HopperTargetLease.js"; +} from "../process/HopperLease.js"; +import type { HopperTargetLease } from "../process/HopperLease.js"; import { HOPPER_SHUTDOWN_TIMEOUT_MS } from "./HopperSessionValues.js"; import { verifyMacHopperNativeHelper } from "./HopperMacPublisher.js"; import { inspectLinuxHopperLauncher } from "../application/LinuxHopper.js"; diff --git a/src/hopper/HopperTargetLease.test.ts b/src/hopper/HopperTargetLease.test.ts index a0c8e02965..87d8355029 100644 --- a/src/hopper/HopperTargetLease.test.ts +++ b/src/hopper/HopperTargetLease.test.ts @@ -20,7 +20,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { acquireHopperTargetLease, acquireLinuxHopperApplicationLease, -} from "./HopperTargetLease.js"; +} from "../process/HopperLease.js"; const directories: string[] = []; diff --git a/src/hopper/HopperTargetLease.ts b/src/process/HopperLease.ts similarity index 94% rename from src/hopper/HopperTargetLease.ts rename to src/process/HopperLease.ts index 53d09aba14..51f1b66407 100644 --- a/src/hopper/HopperTargetLease.ts +++ b/src/process/HopperLease.ts @@ -17,6 +17,8 @@ interface HopperTargetLeaseOwner { } export interface HopperTargetLease { + /** Bound socket identity for cleanup diagnostics; omission remains unknown. */ + readonly socketPath?: string; readonly release: () => Promise; } @@ -66,6 +68,21 @@ export const acquireLinuxHopperApplicationLease = (input: { }): Promise => acquireLease({ ...input, identity: { application: "linux-hopper-demo" } }); +/** Serialize approved publication/repair of one canonical signed-package entry. */ +export const acquireHopperReleaseCacheLease = async (input: { + readonly cacheDirectory: string; + readonly launcherSha256: string; + readonly runId: string; +}): Promise => + acquireLease({ + runId: input.runId, + identity: { + cacheDirectory: await realpath(input.cacheDirectory), + launcherSha256: input.launcherSha256, + operation: "signed-package-retention", + }, + }); + const acquireLease = async (input: { readonly identity: Readonly>; readonly runId: string; @@ -153,6 +170,7 @@ const tryAcquireLease = async ( return { acquired: true, lease: { + socketPath, release: async () => { if (released) return; // Node removes a bound Unix socket on close. A second unlink here diff --git a/tests/acceptance/providers/hopperStartupRecovery.test.ts b/tests/acceptance/providers/hopperStartupRecovery.test.ts index bd4a2d0f1e..2cf22c9b90 100644 --- a/tests/acceptance/providers/hopperStartupRecovery.test.ts +++ b/tests/acceptance/providers/hopperStartupRecovery.test.ts @@ -10,7 +10,7 @@ import { z } from "zod"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js"; -import { acquireHopperTargetLease } from "../../../src/hopper/HopperTargetLease.js"; +import { acquireHopperTargetLease } from "../../../src/process/HopperLease.js"; const statusSchema = z.object({ result: z.object({ open: z.boolean() }) }); const test = workspaceCliTest.skipIf(process.platform !== "darwin"); diff --git a/tests/boundary/cli/hopperLaunchFailureCli.test.ts b/tests/boundary/cli/hopperLaunchFailureCli.test.ts index 9a4f55b7cb..29e132b8a0 100644 --- a/tests/boundary/cli/hopperLaunchFailureCli.test.ts +++ b/tests/boundary/cli/hopperLaunchFailureCli.test.ts @@ -5,7 +5,7 @@ import { z } from "zod"; import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js"; -import { acquireHopperTargetLease } from "../../../src/hopper/HopperTargetLease.js"; +import { acquireHopperTargetLease } from "../../../src/process/HopperLease.js"; const FAILED_LAUNCHER = `#!/bin/sh printf 'Cannot launch Hopper!\\nDocument state is unknown.\\n' >&2 diff --git a/tests/boundary/process/binarySessionCleanupRetry.test.ts b/tests/boundary/process/binarySessionCleanupRetry.test.ts index 5d58f94203..38b3b1ef7e 100644 --- a/tests/boundary/process/binarySessionCleanupRetry.test.ts +++ b/tests/boundary/process/binarySessionCleanupRetry.test.ts @@ -9,7 +9,7 @@ import type { BridgeSession, } from "../../../src/hopper/BridgeLauncher.js"; import { HopperClient } from "../../../src/hopper/HopperClient.js"; -import { acquireHopperTargetLease } from "../../../src/hopper/HopperTargetLease.js"; +import { acquireHopperTargetLease } from "../../../src/process/HopperLease.js"; import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js"; import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; import { hopperFixturePath } from "../providers/hopper/hopperClient.fixture.js"; diff --git a/tests/boundary/providers/hopper/hopperClient.helperFailure.test.ts b/tests/boundary/providers/hopper/hopperClient.helperFailure.test.ts index b236b21ebe..945a5346d8 100644 --- a/tests/boundary/providers/hopper/hopperClient.helperFailure.test.ts +++ b/tests/boundary/providers/hopper/hopperClient.helperFailure.test.ts @@ -15,7 +15,7 @@ import type { } from "../../../../src/hopper/BridgeLauncher.js"; import { HopperApplicationLauncher } from "../../../../src/hopper/BridgeLauncher.js"; import { HopperClient } from "../../../../src/hopper/HopperClient.js"; -import { acquireHopperTargetLease } from "../../../../src/hopper/HopperTargetLease.js"; +import { acquireHopperTargetLease } from "../../../../src/process/HopperLease.js"; import { HopperFixtureLauncher } from "./hopperClient.fixture.js"; // Failure assertions keep the external document unknown. Teardown then publishes diff --git a/tests/boundary/providers/hopper/hopperVerificationCleanup.test.ts b/tests/boundary/providers/hopper/hopperVerificationCleanup.test.ts index 488ac40db5..2678c57026 100644 --- a/tests/boundary/providers/hopper/hopperVerificationCleanup.test.ts +++ b/tests/boundary/providers/hopper/hopperVerificationCleanup.test.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; import { expect, it, onTestFinished } from "vitest"; import { snapshotHopperRuntime } from "../../../../scripts/lib/real-hopper-cleanup.mjs"; -import { acquireHopperTargetLease } from "../../../../src/hopper/HopperTargetLease.js"; +import { acquireHopperTargetLease } from "../../../../src/process/HopperLease.js"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; it.skipIf(process.platform === "win32")( diff --git a/tests/boundary/setup/linuxHopperPublisherVerification.test.ts b/tests/boundary/setup/linuxHopperPublisherVerification.test.ts index 7a23a42642..527a5946a0 100644 --- a/tests/boundary/setup/linuxHopperPublisherVerification.test.ts +++ b/tests/boundary/setup/linuxHopperPublisherVerification.test.ts @@ -1,8 +1,12 @@ import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; +import { once } from "node:events"; +import { promises as filesystem } from "node:fs"; +import { syncBuiltinESMExports } from "node:module"; import { access, copyFile, + cp, mkdir, readFile, readdir, @@ -12,6 +16,7 @@ import { } from "node:fs/promises"; import { join } from "node:path"; import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; import { createMessage, generateKey, readPrivateKey, sign } from "openpgp"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -28,7 +33,10 @@ import { import * as signatures from "../../../src/process/DetachedPgpSignature.js"; import { downloadHopperPackage } from "../../../src/application/HopperDownload.js"; import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js"; +import * as leases from "../../../src/process/HopperLease.js"; +import * as nativeCommands from "../../../src/process/ExecFileOutput.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { createTestProcessRegistry } from "../../support/process/processFixture.js"; const run = promisify(execFile); const verifySignature = signatures.verifyDetachedPgpSignature; @@ -180,8 +188,9 @@ describe("Linux signed package retention boundary", () => { 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 coldCommands = + await import("../../../src/process/ExecFileOutput.js"); + const extraction = vi.spyOn(coldCommands, "execFileOutput"); const { LinuxHopperPublisher: ColdPublisher } = await import("../../../src/application/LinuxHopperPublisher.js"); const fresh = new ColdPublisher( @@ -214,251 +223,543 @@ describe("Linux signed package retention boundary", () => { ); }); -describe("Linux signed cache tampering", () => { - linux.each(["archive", "metadata", "missing metadata", "symlink archive"])( - "fails closed on retained %s tampering", - async (kind) => { - const f = await fixture(); - admitFixturePublisher(f.fingerprint); - try { - const verified = await f.publisher.verifyArchive( - "deb", - f.release, - f.archive, - undefined, - f.publicKey, - ); - if (!verified.ok) throw verified.error; - const retained = await f.publisher.retainVerifiedArchive( - verified.value, - f.archive, - ); - if (!retained.ok) throw retained.error; - if (kind === "archive") { - const tampered = Buffer.from("tampered"); - await writeFile(join(f.cache, "archive"), tampered); - const metadata = JSON.parse( - await readFile(join(f.cache, "release.json"), "utf8"), +linux.each([ + "archive", + "metadata", + "missing metadata", + "missing key", + "malformed key", + "missing archive", + "malformed JSON", + "empty directory", + "symlink archive", +])("fails closed on retained %s tampering", async (kind) => { + const f = await fixture(); + admitFixturePublisher(f.fingerprint); + try { + const verified = await f.publisher.verifyArchive( + "deb", + f.release, + f.archive, + undefined, + f.publicKey, + ); + if (!verified.ok) throw verified.error; + const retained = await f.publisher.retainVerifiedArchive( + verified.value, + f.archive, + ); + if (!retained.ok) throw retained.error; + if (kind === "archive") { + const tampered = Buffer.from("tampered"); + await writeFile(join(f.cache, "archive"), tampered); + const metadata = JSON.parse( + await readFile(join(f.cache, "release.json"), "utf8"), + ); + metadata.release.file_length = String(tampered.length); + metadata.release.file_hash = createHash("sha1") + .update(tampered) + .digest("hex"); + await writeFile(join(f.cache, "release.json"), JSON.stringify(metadata)); + } + if (kind === "metadata") { + const metadata = JSON.parse( + await readFile(join(f.cache, "release.json"), "utf8"), + ); + metadata.release.main_exe_hash = "a".repeat(64); + await writeFile(join(f.cache, "release.json"), JSON.stringify(metadata)); + } + if (kind === "missing metadata") await rm(join(f.cache, "release.json")); + if (kind === "missing key") await rm(join(f.cache, "public-key.asc")); + if (kind === "malformed key") + await writeFile(join(f.cache, "public-key.asc"), Buffer.from([0xff])); + if (kind === "missing archive") await rm(join(f.cache, "archive")); + if (kind === "malformed JSON") + await writeFile(join(f.cache, "release.json"), "{broken JSON"); + if (kind === "empty directory") + await Promise.all( + ["release.json", "public-key.asc", "archive"].map((name) => + rm(join(f.cache, name)), + ), + ); + if (kind === "symlink archive") { + await rm(join(f.cache, "archive")); + await symlink(f.archive, join(f.cache, "archive")); + } + const network = vi.fn(() => { + throw new Error("Must preserve the cache failure"); + }); + vi.stubGlobal("fetch", network); + expect(await f.publisher.inspectLauncher(f.launcher)).toMatchObject({ + 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" || kind === "empty directory") + 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(); + } +}); + +linux.each([false, true])( + "serializes real cross-process repair and cancellation: %s", + async (cancel) => { + const f = await fixture(); + admitFixturePublisher(f.fingerprint); + const processes = createTestProcessRegistry(); + const controller = new AbortController(); + let retention: + | ReturnType + | undefined; + try { + const verified = await f.publisher.verifyArchive( + "deb", + f.release, + f.archive, + undefined, + f.publicKey, + ); + if (!verified.ok) throw verified.error; + expect( + await f.publisher.retainVerifiedArchive(verified.value, f.archive), + ).toEqual({ ok: true, value: undefined }); + const staged = join(f.publisher.cacheRoot, "authenticated-fixture-stage"); + await cp(f.cache, staged, { recursive: true }); + const broken = "{interrupted cache record"; + await writeFile(join(f.cache, "release.json"), broken); + const child = processes.spawn(process.execPath, [ + fileURLToPath( + new URL("../../fixtures/hopperCachePublisher.mjs", import.meta.url), + ), + f.publisher.cacheRoot, + f.release.main_exe_hash, + staged, + ]); + let stderr = ""; + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + const exited = once(child, "close"); + const ready = await Promise.race([ + once(child.stdout, "data").then(([bytes]) => { + if (!Buffer.isBuffer(bytes)) + throw new Error("Expected fixture readiness bytes"); + return JSON.parse(bytes.toString()); + }), + exited.then(([code]) => { + throw new Error( + `Fixture exited before acquiring its lease: ${String(code)} ${stderr}`, ); - metadata.release.file_length = String(tampered.length); - metadata.release.file_hash = createHash("sha1") - .update(tampered) - .digest("hex"); - await writeFile( - join(f.cache, "release.json"), - JSON.stringify(metadata), - ); - } - if (kind === "metadata") { - const metadata = JSON.parse( - await readFile(join(f.cache, "release.json"), "utf8"), - ); - metadata.release.main_exe_hash = "a".repeat(64); - await writeFile( - join(f.cache, "release.json"), - JSON.stringify(metadata), - ); - } - if (kind === "missing metadata") - await rm(join(f.cache, "release.json")); - if (kind === "symlink archive") { - await rm(join(f.cache, "archive")); - await symlink(f.archive, join(f.cache, "archive")); - } - const network = vi.fn(() => { - throw new Error("Must preserve the cache failure"); - }); - vi.stubGlobal("fetch", network); - expect(await f.publisher.inspectLauncher(f.launcher)).toMatchObject({ + }), + ]); + expect(ready).toEqual({ held: true }); + let notifyBusy: (() => void) | undefined; + const busy = new Promise((resolve) => { + notifyBusy = resolve; + }); + const acquire = leases.acquireHopperReleaseCacheLease; + vi.spyOn(leases, "acquireHopperReleaseCacheLease").mockImplementation( + async (input) => { + const observed = await acquire(input); + if (!observed.acquired) notifyBusy?.(); + return observed; + }, + ); + retention = f.publisher.retainVerifiedArchive( + verified.value, + f.archive, + controller.signal, + ); + await Promise.race([ + busy, + retention.then(() => { + throw new Error("Retention bypassed the other process's cache lease"); + }), + ]); + expect(await readFile(join(f.cache, "release.json"), "utf8")).toBe( + broken, + ); + expect( + (await readdir(f.publisher.cacheRoot)).some((name) => + name.includes(".backup-"), + ), + ).toBe(false); + if (cancel) { + controller.abort(); + expect(await retention).toMatchObject({ ok: false, + error: { reason: "cancelled" }, }); - 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(); + child.stdin.end("release\n"); + } else { + child.stdin.end("publish\n"); + expect(await retention).toEqual({ ok: true, value: undefined }); 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(); + ).toHaveLength(1); } - }, - ); + expect(await exited).toEqual([0, null]); + } finally { + controller.abort(); + await processes.shutdown(); + await retention; + await f.publisher.close(); + } + }, +); - linux( - "rejects a feed digest not represented in the signed payload", - async () => { - const f = await fixture(); - admitFixturePublisher(f.fingerprint); - try { - expect( - await f.publisher.verifyArchive( - "deb", - { ...f.release, main_exe_hash: "a".repeat(64) }, - f.archive, - undefined, - f.publicKey, - ), - ).toMatchObject({ - ok: false, - error: { - reason: "integrity", - message: expect.stringContaining(f.release.main_exe_hash), - }, - }); - expect( - await f.publisher.verifyArchive( - "deb", - f.release, - f.archive, - AbortSignal.abort(), - f.publicKey, - ), - ).toMatchObject({ ok: false, error: { reason: "cancelled" } }); - } finally { - await f.publisher.close(); - } - }, - ); +linux("restores old evidence when replacement publication fails", async () => { + const f = await fixture(); + admitFixturePublisher(f.fingerprint); + try { + const verified = await f.publisher.verifyArchive( + "deb", + f.release, + f.archive, + undefined, + f.publicKey, + ); + if (!verified.ok) throw verified.error; + expect( + await f.publisher.retainVerifiedArchive(verified.value, f.archive), + ).toEqual({ ok: true, value: undefined }); + const broken = "{interrupted cache metadata"; + await writeFile(join(f.cache, "release.json"), broken); + const rename = filesystem.rename; + const denied = Object.assign(new Error("Fixture publication failure"), { + code: "EIO", + }); + let backup: string | undefined; + const publication = vi + .spyOn(filesystem, "rename") + .mockImplementation(async (from, to) => { + if (backup !== undefined && to === f.cache && from !== backup) + throw denied; + await rename(from, to); + if (from === f.cache && typeof to === "string") backup = to; + }); + syncBuiltinESMExports(); + try { + const result = await f.publisher.retainVerifiedArchive( + verified.value, + f.archive, + ); + expect(result).toMatchObject({ + ok: false, + error: { + reason: "verification_unavailable", + message: expect.stringContaining(f.cache), + }, + }); + if (result.ok) throw new Error("Expected publication failure"); + expect(result.error.cause).toBeInstanceOf(AggregateError); + if (result.error.cause instanceof AggregateError) + expect(result.error.cause.errors).toContain(denied); + expect(await readFile(join(f.cache, "release.json"), "utf8")).toBe( + broken, + ); + expect( + (await readdir(f.publisher.cacheRoot)).some((name) => + name.includes(".backup-"), + ), + ).toBe(false); + } finally { + publication.mockRestore(); + syncBuiltinESMExports(); + } + expect( + await f.publisher.retainVerifiedArchive(verified.value, f.archive), + ).toEqual({ ok: true, value: undefined }); + expect(await f.publisher.inspectLauncher(f.launcher)).toMatchObject({ + ok: true, + }); + } finally { + await f.publisher.close(); + } }); -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( - "fetch", - vi.fn( - async () => - new Response( - new ReadableStream({ - start(controller) { - controller.enqueue(new Uint8Array(4)); - }, - cancel: cancelled, - }), - ), - ), - ); - expect( - await downloadHopperPackage("https://www.hopperapp.com/fixture", { - maxBytes: 3, - }), - ).toEqual({ ok: false, bytes: new Uint8Array() }); - expect(cancelled).toHaveBeenCalledOnce(); - }); - - linux("retains and retries failed verification cleanup", async () => { +linux( + "preserves existing evidence when native cache inspection is unavailable", + async () => { const f = await fixture(); admitFixturePublisher(f.fingerprint); + try { + const verified = await f.publisher.verifyArchive( + "deb", + f.release, + f.archive, + undefined, + f.publicKey, + ); + if (!verified.ok) throw verified.error; + expect( + await f.publisher.retainVerifiedArchive(verified.value, f.archive), + ).toEqual({ ok: true, value: undefined }); + await writeFile(join(f.cache, "public-key.asc"), f.publicKey + "\n"); + const metadata = await readFile(join(f.cache, "release.json")); + const unavailable = vi + .spyOn(nativeCommands, "execFileOutput") + .mockRejectedValue(new Error("Fixture native inspection unavailable")); + expect( + await f.publisher.retainVerifiedArchive(verified.value, f.archive), + ).toMatchObject({ + ok: false, + error: { reason: "verification_unavailable" }, + }); + expect(await readFile(join(f.cache, "release.json"))).toEqual(metadata); + expect( + (await readdir(f.publisher.cacheRoot)).some((name) => + name.includes(".backup-"), + ), + ).toBe(false); + unavailable.mockRestore(); + expect(await f.publisher.inspectLauncher(f.launcher)).toMatchObject({ + ok: true, + }); + } finally { + await f.publisher.close(); + } + }, +); + +linux( + "rejects a feed digest not represented in the signed payload", + async () => { + const f = await fixture(); + admitFixturePublisher(f.fingerprint); + try { + expect( + await f.publisher.verifyArchive( + "deb", + { ...f.release, main_exe_hash: "a".repeat(64) }, + f.archive, + undefined, + f.publicKey, + ), + ).toMatchObject({ + ok: false, + error: { + reason: "integrity", + message: expect.stringContaining(f.release.main_exe_hash), + }, + }); + expect( + await f.publisher.verifyArchive( + "deb", + f.release, + f.archive, + AbortSignal.abort(), + f.publicKey, + ), + ).toMatchObject({ ok: false, error: { reason: "cancelled" } }); + } finally { + await f.publisher.close(); + } + }, +); + +linux( + "retains a failed publication lease until its real socket owner closes", + async () => { + const f = await fixture(); + admitFixturePublisher(f.fingerprint); + const acquire = leases.acquireHopperReleaseCacheLease; + let allowCleanup = false; + vi.spyOn(leases, "acquireHopperReleaseCacheLease").mockImplementation( + async (input) => { + const acquired = await acquire(input); + if (!acquired.acquired) return acquired; + return { + acquired: true as const, + lease: { + ...acquired.lease, + release: async () => { + if (!allowCleanup) + throw new Error("Fixture lease close unavailable"); + await acquired.lease.release(); + }, + }, + }; + }, + ); + try { + const verified = await f.publisher.verifyArchive( + "deb", + f.release, + f.archive, + undefined, + f.publicKey, + ); + if (!verified.ok) throw verified.error; + expect( + await f.publisher.retainVerifiedArchive(verified.value, f.archive), + ).toMatchObject({ ok: false, error: { reason: "cleanup_failed" } }); + const input = { + cacheDirectory: f.publisher.cacheRoot, + launcherSha256: f.release.main_exe_hash, + runId: "fixture-competing-writer", + }; + expect(await acquire(input)).toMatchObject({ acquired: false }); + await expect(f.publisher.close()).rejects.toMatchObject({ + cleanup: { resources: [expect.stringContaining(".sock")] }, + }); + allowCleanup = true; + await f.publisher.close(); + const recovered = await acquire(input); + if (!recovered.acquired) + throw new Error("Expected the released real cache lease"); + await recovered.lease.release(); + } finally { + allowCleanup = true; + await f.publisher.close(); + } + }, +); + +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( + "fetch", + vi.fn( + async () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(4)); + }, + cancel: cancelled, + }), + ), + ), + ); + expect( + await downloadHopperPackage("https://www.hopperapp.com/fixture", { + maxBytes: 3, + }), + ).toEqual({ ok: false, bytes: new Uint8Array() }); + expect(cancelled).toHaveBeenCalledOnce(); +}); + +linux("retains and retries failed verification cleanup", async () => { + const f = await fixture(); + admitFixturePublisher(f.fingerprint); + const cleanup = vi + .spyOn(PrivateRuntimeRoot.prototype, "close") + .mockRejectedValueOnce(new Error("Fixture cleanup unavailable")); + try { + expect( + await f.publisher.verifyArchive( + "deb", + f.release, + f.archive, + undefined, + f.publicKey, + ), + ).toMatchObject({ ok: false, error: { reason: "cleanup_failed" } }); + expect(cleanup).toHaveBeenCalledOnce(); + await f.publisher.close(); + expect(cleanup).toHaveBeenCalledTimes(2); + } finally { + 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") - .mockRejectedValueOnce(new Error("Fixture cleanup unavailable")); + .mockImplementation(async () => { + controller.abort(); + throw new Error("Fixture cleanup unavailable after cancellation"); + }); try { expect( await f.publisher.verifyArchive( "deb", f.release, f.archive, - undefined, + controller.signal, f.publicKey, ), - ).toMatchObject({ ok: false, error: { reason: "cleanup_failed" } }); - expect(cleanup).toHaveBeenCalledOnce(); - await f.publisher.close(); - expect(cleanup).toHaveBeenCalledTimes(2); + ).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(); } - }); - - 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(); - } - }, - ); -}); + }, +); diff --git a/tests/fixtures/hopperCachePublisher.mjs b/tests/fixtures/hopperCachePublisher.mjs new file mode 100644 index 0000000000..3a49ed3f9c --- /dev/null +++ b/tests/fixtures/hopperCachePublisher.mjs @@ -0,0 +1,31 @@ +import { once } from "node:events"; +import { randomUUID } from "node:crypto"; +import { rename } from "node:fs/promises"; +import { join } from "node:path"; +import { createInterface } from "node:readline"; + +import { acquireHopperReleaseCacheLease } from "../../dist/process/HopperLease.js"; + +// The parent prepares authenticated fixture evidence. Coordinate a real second +// process publishing it while another publisher is waiting for this lease. +const [cacheDirectory, launcherSha256, staged] = process.argv.slice(2); +const commands = createInterface({ input: process.stdin }); +const command = once(commands, "line"); +const acquired = await acquireHopperReleaseCacheLease({ + cacheDirectory, + launcherSha256, + runId: randomUUID(), +}); +if (!acquired.acquired) throw new Error("Fixture cache lease was already held"); +try { + console.log(JSON.stringify({ held: true })); + const [action] = await command; + if (action === "publish") { + const target = join(cacheDirectory, launcherSha256); + await rename(target, `${target}.backup-${randomUUID()}`); + await rename(staged, target); + } else if (action !== "release") throw new Error("Unknown fixture action"); +} finally { + await acquired.lease.release(); + commands.close(); +} diff --git a/tests/process-global/hopperLeaseDescriptorPermissions.test.ts b/tests/process-global/hopperLeaseDescriptorPermissions.test.ts index 174097abe3..92a6e19e63 100644 --- a/tests/process-global/hopperLeaseDescriptorPermissions.test.ts +++ b/tests/process-global/hopperLeaseDescriptorPermissions.test.ts @@ -32,7 +32,7 @@ vi.mock("node:fs/promises", async (importOriginal) => { }; }); -import { acquireLinuxHopperApplicationLease } from "../../src/hopper/HopperTargetLease.js"; +import { acquireLinuxHopperApplicationLease } from "../../src/process/HopperLease.js"; afterEach(() => { replacement.selected = "";