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>
This commit is contained in:
committed 2026-10-15 03:20:40 +00:00
1 parent d80d03a913
commit 66fca324d1
16 files changed
+828 -352

No files matched your search

+5
View File
@@ -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 `<launcher-sha256>.backup-<id>`. 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,
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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";
+232 -119
View File
@@ -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<void> {
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<HopperTargetLease> {
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<never> {
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<Result<VerifiedLinuxHopperPackage, HopperReleaseError>> {
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<void> {
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<T>(
owner: ArtifactResourceOwner,
operation: () => Promise<T>,
): Promise<T> {
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<void> => {
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,
+9 -1
View File
@@ -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<void> };
}
| {
readonly kind: "javascript-source-analysis";
readonly resource: string;
@@ -156,6 +161,9 @@ export class ArtifactResourceScope {
const cleanup = async (owner: ArtifactResourceOwner): Promise<void> => {
switch (owner.kind) {
case "lease":
await owner.lease.release();
return;
case "javascript-source-analysis":
await owner.analysis.close();
return;
+2 -2
View File
@@ -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";
+1 -1
View File
@@ -20,7 +20,7 @@ import { afterEach, describe, expect, it } from "vitest";
import {
acquireHopperTargetLease,
acquireLinuxHopperApplicationLease,
} from "./HopperTargetLease.js";
} from "../process/HopperLease.js";
const directories: string[] = [];
@@ -17,6 +17,8 @@ interface HopperTargetLeaseOwner {
}
export interface HopperTargetLease {
/** Bound socket identity for cleanup diagnostics; omission remains unknown. */
readonly socketPath?: string;
readonly release: () => Promise<void>;
}
@@ -66,6 +68,21 @@ export const acquireLinuxHopperApplicationLease = (input: {
}): Promise<HopperTargetLeaseAcquisition> =>
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<HopperTargetLeaseAcquisition> =>
acquireLease({
runId: input.runId,
identity: {
cacheDirectory: await realpath(input.cacheDirectory),
launcherSha256: input.launcherSha256,
operation: "signed-package-retention",
},
});
const acquireLease = async (input: {
readonly identity: Readonly<Record<string, unknown>>;
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
@@ -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");
@@ -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
@@ -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";
@@ -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
@@ -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")(
@@ -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<LinuxHopperPublisher["retainVerifiedArchive"]>
| 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<void>((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();
}
},
);
});
},
);
+31
View File
@@ -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();
}
@@ -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 = "";