import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { cp, mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Server } from "bun"; import { openBucket } from "../src/bucket/reader.ts"; import { syncAll } from "../src/bucket/sync.ts"; import { createApp } from "../src/server/app.ts"; import { openKeprDb } from "../src/server/db.ts"; import { kohWorkspace } from "./fixture.ts"; /** * The joint update contract (§3 item 8): koh publishes a newer koh into a folder remote, Kepr * syncs and serves it, and a second koh — a copy of the installed binary, never the installed one * — replaces itself with what it downloads. * * Two limits are deliberate, and named here so nobody reads more into a green run: * * - Kepr builds a download URL as `https://…` by design, and this test's server has no TLS, so the * documents it serves for the download point at the `file://` path a folder-bucket Kepr hands * out. The HTTPS hop and Kepr's own redirect are therefore not exercised; June's staging check * (§6.4) covers them. * - The artifact is a copy of the koh binary, so on macOS the installed file is that artifact plus * the ad-hoc signature koh writes into it, and the bytes cannot be compared. There the proof is * that the file grew, is validly signed and runs; on Linux, where nothing is signed, the bytes * are compared exactly. */ const NEW_VERSION = "9.9.9"; const REPO = "koh"; const NOTES = "the updater replaces itself, end to end, through a Kepr that serves the release it was told about"; /** Long enough for a download on a slow machine, short enough not to hang a suite. */ const RUN_TIMEOUT_MS = 30_000; /** What koh reads: the release document (§22.4). */ type Feed = { version: string; notes?: string; platforms: Record; }; /** The record koh writes, as far as this test reads it (§11.2). */ type ReleaseRecord = { files: { platform: string; name: string }[] }; /** The CLI's own `--json` document, `UpdateJson` in koh's src/cli/cmd_update.v. */ type UpdateJson = { available?: boolean; installed?: string }; describe("koh updating itself through Kepr", () => { let server: Server; let cleanup: string[] = []; let checkSource = ""; let installSource = ""; let tamperedSource = ""; let installed = ""; let published = ""; let checks: { code: number; jsonCode: number; json: UpdateJson; install: UpdateJson; refused: string; refusedStdout: string; tampered: string; afterTampering: Uint8Array; baseline: Uint8Array; kept: Uint8Array; target: string; }; beforeAll(async () => { const space = await kohWorkspace(REPO); const koh = Bun.which("koh") as string; const platformName = platform(); installed = join(space.root, "bin", "koh"); published = join(space.repoDir, "dist", "koh"); await mkdir(join(space.root, "bin"), { recursive: true }); await mkdir(join(space.repoDir, "dist"), { recursive: true }); await cp(koh, installed); await cp(koh, published); const baseline = new Uint8Array(await Bun.file(installed).arrayBuffer()); await writeFile(join(space.repoDir, "VERSION"), `${NEW_VERSION}\n`); await writeFile( join(space.repoDir, "koh.build"), [ "# a release is one file: this copies the one the test prepared", "command = mkdir -p out && cp dist/koh out/koh", "output = out/koh", `platforms = ${platformName}`, "", ].join("\n"), ); space.run("init"); space.run("remote", "folder", space.bucketDir); space.run("save", "the new koh"); space.run("offer", "--builds", "--notes", NOTES); const dataDir = await mkdtemp(join(tmpdir(), "kepr-update-")); cleanup = [space.root, dataDir]; const kepr = openKeprDb(dataDir); await syncAll({ bucket: openBucket({ kind: "folder", path: space.bucketDir }), kepr, mirrorDir: join(dataDir, "mirror"), }); kepr.run("UPDATE repos SET public = 1 WHERE name = 'koh'"); // A download URL in the feed carries the configured domain, so the app is built // with the real one once the port is known. const config = (domain: string) => ({ domain, dataDir, adminPasswordHash: "$argon2id$x", bucket: { kind: "folder" as const, path: space.bucketDir }, }); let app = createApp(config("127.0.0.1:1"), kepr); let good: Feed | null = null; let bad: Feed | null = null; const goodPath = `/${REPO}/releases/latest.local.json`; const badPath = `/${REPO}/releases/latest.tampered.json`; server = Bun.serve({ port: 0, fetch: (request) => { const url = new URL(request.url); if (url.pathname === goodPath && good) return Response.json(good); if (url.pathname === badPath && bad) return Response.json(bad); return app.fetch(request); }, }); app = createApp(config(`127.0.0.1:${server.port}`), kepr); checkSource = `http://127.0.0.1:${server.port}/${REPO}/releases/latest.json`; installSource = `http://127.0.0.1:${server.port}${goodPath}`; tamperedSource = `http://127.0.0.1:${server.port}${badPath}`; // The document Kepr served, with its download pointed at the very file the folder // bucket holds (the TLS gap above), and a twin whose checksum is wrong. // A fetch here must fail fast rather than hang the suite. const served: Feed = (await ( await fetch(checkSource, { signal: AbortSignal.timeout(10_000) }) ).json()) as Feed; const record: ReleaseRecord = (await Bun.file( join(space.bucketDir, REPO, "releases", NEW_VERSION, "release.json"), ).json()) as ReleaseRecord; const artifact = record.files.find((file) => file.platform === platformName); if (!artifact) throw new Error("the published record has no file for this platform"); const artifactPath = join( space.bucketDir, REPO, "releases", NEW_VERSION, platformName, artifact.name, ); const real = served.platforms[platformName]; if (!real) throw new Error("the served document has no file for this platform"); good = { ...served, platforms: { ...served.platforms, [platformName]: { ...real, url: `file://${artifactPath}` }, }, }; bad = { ...served, platforms: { ...served.platforms, [platformName]: { ...real, url: `file://${artifactPath}`, blake3: "0".repeat(64) }, }, }; const check = await run(installed, space.home, checkSource, "update", "--check"); const checkJson = await run(installed, space.home, checkSource, "update", "--check", "--json"); const refused = await run(installed, space.home, checkSource, "update", "--yes", "--json"); const kept = new Uint8Array(await Bun.file(installed).arrayBuffer()); const tampered = await run(installed, space.home, tamperedSource, "update", "--yes", "--json"); const afterTampering = new Uint8Array(await Bun.file(installed).arrayBuffer()); const install = await run(installed, space.home, installSource, "update", "--yes", "--json"); checks = { code: check.exitCode, jsonCode: checkJson.exitCode, json: JSON.parse(checkJson.stdout) as UpdateJson, install: JSON.parse(install.stdout) as UpdateJson, refused: refused.stderr, refusedStdout: refused.stdout, tampered: tampered.stderr, afterTampering, baseline, kept, // koh names the binary it replaced by its real path, and /var is a symlink. target: await realpath(installed), }; }); afterAll(async () => { server?.stop(true); for (const dir of cleanup) await rm(dir, { recursive: true, force: true }); }); /** * Runs a koh with the update source pointed at this test's Kepr. Asynchronous on purpose: * `Bun.spawnSync` would block the event loop this test's own HTTP server runs on, and the two * would wait for each other forever. A slow or wedged updater fails the test rather than * hanging the suite. */ async function run(path: string, home: string, source: string, ...args: string[]) { const proc = Bun.spawn([path, ...args], { env: { PATH: process.env.PATH, HOME: home, NO_COLOR: "1", KOH_UPDATE_SOURCE: source }, stdout: "pipe", stderr: "pipe", }); const finished: Promise<[string, string, number]> = Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited, ]); // A real-clock watchdog, not a fake timer: the thing being waited on is another // process, so there is no event to await and deterministic time control cannot // apply. Without it a wedged updater hangs the suite instead of failing it. const late = Bun.sleep(RUN_TIMEOUT_MS).then((): [string, string, number] => { proc.kill(); throw new Error(`koh ${args.join(" ")} did not finish in ${RUN_TIMEOUT_MS} ms`); }); const [stdout, stderr, exitCode] = await Promise.race([finished, late]); return { stdout, stderr, exitCode }; } test("--check reports an update and exits 1, in text and in JSON alike", () => { expect(checks.code).toBe(1); expect(checks.jsonCode).toBe(1); expect(checks.json.available).toBe(true); }); test("a download it cannot reach is refused, and the binary is left alone", () => { // Kepr's own URL is https on a server that has no TLS, so this is what an // unreachable release file looks like: a refusal, never a half-install. expect(checks.refused).toContain("could not download"); expect(checks.refusedStdout).toBe(""); expect(Buffer.compare(checks.kept, checks.baseline)).toBe(0); }); test("a download that does not match its checksum is refused before anything is replaced", () => { expect(checks.tampered).toContain("not the published file"); expect(Buffer.compare(checks.afterTampering, checks.baseline)).toBe(0); }); test("the update replaced the running binary with the published file", async () => { expect(checks.install.installed).toBe(checks.target); const downloaded = new Uint8Array(await Bun.file(installed).arrayBuffer()); const released = new Uint8Array(await Bun.file(published).arrayBuffer()); if (process.platform === "darwin") { // Apple silicon refuses to run an unsigned binary, so koh signs the new // file before the rename: the installed file is the artifact with a fresh // signature, and its size may go either way, so the bytes cannot be compared // here. What can be proved is that the file that landed is a validly signed, // runnable koh — and koh itself refused to go further until the download's // Blake3 matched the published one. June's staging check (§6.4) is the real // proof over HTTPS. const signed = Bun.spawn(["codesign", "--verify", installed]); expect(await signed.exited).toBe(0); expect(downloaded.length).toBeGreaterThan(0); } else { expect(Buffer.compare(downloaded, released)).toBe(0); } // And it is a working koh still: the replaced binary runs. const version = (await run(installed, tmpdir(), checkSource, "--version")).stdout.trim(); expect(version.startsWith("koh ")).toBe(true); }); }); /** This machine's platform, which is what `koh build` uses with no flags. */ function platform(): string { const os = process.platform === "darwin" ? "macos" : "linux"; const arch = process.arch === "arm64" ? "aarch64" : "x86_64"; return `${os}-${arch}`; }