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