Files
rea/tests/boundary/setup/linuxHopperPublisherVerification.test.ts
70cd4cbfb8 fix(hopper)!: verify current Linux releases with the publisher key
Replace fixed Linux Hopper release pins with current vendor feed metadata,
complete detached-PGP package verification under the stable publisher primary
key, and authenticated package evidence for offline admission. Share parsed
publisher facts across approved setup, doctor and the pre-execution guard.
Preserve existing ownership, cancellation, private-display and cleanup rules.

Fixes #3577

Normalize empty XDG settings, preserve backups during approved cache repair,
and retain publisher cleanup through cancellation and shutdown. Exercise cold
offline verification without reusing process-local proof state. Serialize
approved cache publication across processes, restore old evidence on failure,
and share the existing lease owner from process without changing its identities.

Validation: 438 focused tests (six platform-related skips), full static/documentation checks, real Ubuntu
24.04 official Hopper 6.6.0 setup/CLI/MCP, offline ordinary-user admission,
launcher mutation rejection, and cold/warm verification measurements. Official
RPM metadata mismatch is tracked separately in #3578 and fails closed.

BREAKING CHANGE: Older Linux Hopper builds admitted by the fixed allowlist
now require retained publisher-signed package evidence or an approved update
to the current public demo. Update explicit HOPPER_LAUNCHER_PATH values when
migrating; the live feed exposes only its current release.

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
2026-10-15 12:15:55 +08:00

766 lines
24 KiB
TypeScript

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,
rm,
symlink,
writeFile,
} 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";
import {
LinuxHopperPublisher,
linuxHopperReleaseCacheDirectory,
retryLinuxHopperPublisherCleanup,
} from "../../../src/application/LinuxHopperPublisher.js";
import {
HOPPER_LINUX_PRIMARY_FINGERPRINT,
type LinuxHopperRelease,
} from "../../../src/application/HopperReleaseFeed.js";
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;
const sha256 = (bytes: Uint8Array) =>
createHash("sha256").update(bytes).digest("hex");
const linux = it.skipIf(process.platform !== "linux");
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
/** Real native archives and cryptography; substitute only the test publisher identity. */
const fixture = async (family: "deb" | "arch" = "deb") => {
const directory = await createTestTempDirectory("rea-linux-publisher-");
const packageRoot = join(directory, "package-root");
const launcher = join(packageRoot, "opt/hopper/bin/Hopper");
await mkdir(join(packageRoot, "opt/hopper/bin"), { recursive: true });
await copyFile("/usr/bin/true", launcher);
const archive = join(directory, "archive");
if (family === "deb") {
await mkdir(join(packageRoot, "DEBIAN"));
await writeFile(
join(packageRoot, "DEBIAN/control"),
"Package: hopper-fixture\nVersion: 1.0\nArchitecture: amd64\nMaintainer: Fixture\nDescription: Signature boundary fixture\n",
);
await run("dpkg-deb", ["--build", packageRoot, archive]);
} else await run("tar", ["-cJf", archive, "-C", packageRoot, "opt"]);
const bytes = await readFile(archive);
const generated = await generateKey({
type: "ecc",
curve: "curve25519Legacy",
userIDs: [{ name: "Fixture publisher" }],
format: "armored",
});
const key = await readPrivateKey({ armoredKey: generated.privateKey });
const signature = await sign({
message: await createMessage({ binary: bytes }),
signingKeys: key,
detached: true,
format: "binary",
});
const release: LinuxHopperRelease = {
version: "1.0",
filename: `https://www.hopperapp.com:443/downloader/public/Hopper-1.0-Linux-demo.${family === "deb" ? "deb" : "pkg.tar.xz"}`,
file_length: String(bytes.length),
file_hash: createHash("sha1").update(bytes).digest("hex"),
main_exe_hash: sha256(await readFile(launcher)),
signature: Buffer.from(signature).toString("base64"),
public: "1",
beta: "0",
};
const publisher = new LinuxHopperPublisher(
{ ...process.env, XDG_CACHE_HOME: join(directory, "cache") },
async () => family,
);
const cache = join(publisher.cacheRoot, release.main_exe_hash);
return {
publisher,
archive,
launcher,
release,
cache,
publicKey: generated.publicKey,
fingerprint: key.getFingerprint().toUpperCase(),
};
};
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(
"rejects a cryptographically valid package signed by another publisher",
async () => {
const f = await fixture();
try {
expect(
await f.publisher.verifyArchive(
"deb",
f.release,
f.archive,
undefined,
f.publicKey,
),
).toMatchObject({
ok: false,
error: { reason: "publisher_verification_failed" },
});
await expect(access(f.publisher.cacheRoot)).rejects.toMatchObject({
code: "ENOENT",
});
} finally {
await f.publisher.close();
}
},
);
linux.each(["deb", "arch"] as const)(
"authenticates a real %s payload and verifies a retained install without network",
async (family) => {
const f = await fixture(family);
admitFixturePublisher(f.fingerprint);
const network = vi.fn(() => {
throw new Error("Offline");
});
vi.stubGlobal("fetch", network);
try {
const verified = await f.publisher.verifyArchive(
family,
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 f.publisher.close();
// 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 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(
f.publisher.environment,
async () => family,
);
try {
expect(await fresh.inspectLauncher(f.launcher)).toMatchObject({
ok: true,
value: {
sha256: f.release.main_exe_hash,
primaryFingerprint: f.fingerprint,
source: "signed_package_cache",
},
});
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();
}
} finally {
await f.publisher.close();
}
},
);
});
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}`,
);
}),
]);
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" },
});
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 readdir(f.publisher.cacheRoot)).filter((name) =>
name.includes(".backup-"),
),
).toHaveLength(1);
}
expect(await exited).toEqual([0, null]);
} finally {
controller.abort();
await processes.shutdown();
await retention;
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();
}
});
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")
.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();
}
},
);