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 = "";