kohupdate.test.ts

11.0 KB · raw

1import { afterAll, beforeAll, describe, expect, test } from "bun:test";2import { cp, mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";3import { tmpdir } from "node:os";4import { join } from "node:path";5import type { Server } from "bun";6import { openBucket } from "../src/bucket/reader.ts";7import { syncAll } from "../src/bucket/sync.ts";8import { createApp } from "../src/server/app.ts";9import { openKeprDb } from "../src/server/db.ts";10import { kohWorkspace } from "./fixture.ts";1112/**13 * The joint update contract (§3 item 8): koh3 publishes a newer koh into a folder remote, Kepr14 * syncs and serves it, and a second koh3 — a copy of the installed binary, never the installed one15 * — replaces itself with what it downloads.16 *17 * Two limits are deliberate, and named here so nobody reads more into a green run:18 *19 * - Kepr builds a download URL as `https://…` by design, and this test's server has no TLS, so the20 *   documents it serves for the download point at the `file://` path a folder-bucket Kepr hands21 *   out. The HTTPS hop and Kepr's own redirect are therefore not exercised; June's staging check22 *   (§6.4) covers them.23 * - The artifact is a copy of the koh3 binary, so on macOS the installed file is that artifact plus24 *   the ad-hoc signature koh writes into it, and the bytes cannot be compared. There the proof is25 *   that the file grew, is validly signed and runs; on Linux, where nothing is signed, the bytes26 *   are compared exactly.27 */2829const NEW_VERSION = "9.9.9";30const REPO = "koh";31const NOTES =32	"the updater replaces itself, end to end, through a Kepr that serves the release it was told about";33/** Long enough for a download on a slow machine, short enough not to hang a suite. */34const RUN_TIMEOUT_MS = 30_000;3536/** What koh3 reads: the release document (§22.4). */37type Feed = {38	version: string;39	notes?: string;40	platforms: Record<string, { url: string; blake3: string; sha256: string }>;41};4243/** The record koh3 writes, as far as this test reads it (§11.2). */44type ReleaseRecord = { files: { platform: string; name: string }[] };4546/** The CLI's own `--json` document, `UpdateJson` in koh3's src/cli/cmd_update.v. */47type UpdateJson = { available?: boolean; installed?: string };4849describe("koh3 updating itself through Kepr", () => {50	let server: Server<undefined>;51	let cleanup: string[] = [];52	let checkSource = "";53	let installSource = "";54	let tamperedSource = "";55	let installed = "";56	let published = "";57	let checks: {58		code: number;59		jsonCode: number;60		json: UpdateJson;61		install: UpdateJson;62		refused: string;63		refusedStdout: string;64		tampered: string;65		afterTampering: Uint8Array;66		baseline: Uint8Array;67		kept: Uint8Array;68		target: string;69	};7071	beforeAll(async () => {72		const space = await kohWorkspace(REPO);73		const koh = Bun.which("koh3") as string;74		const platformName = platform();75		installed = join(space.root, "bin", "koh3");76		published = join(space.repoDir, "dist", "koh3");77		await mkdir(join(space.root, "bin"), { recursive: true });78		await mkdir(join(space.repoDir, "dist"), { recursive: true });79		await cp(koh, installed);80		await cp(koh, published);81		const baseline = new Uint8Array(await Bun.file(installed).arrayBuffer());8283		await writeFile(join(space.repoDir, "VERSION"), `${NEW_VERSION}\n`);84		await writeFile(85			join(space.repoDir, "koh.build"),86			[87				"# a release is one file: this copies the one the test prepared",88				"command = mkdir -p out && cp dist/koh3 out/koh3",89				"output = out/koh3",90				`platforms = ${platformName}`,91				"",92			].join("\n"),93		);94		space.run("init");95		space.run("remote", "folder", space.bucketDir);96		space.run("save", "the new koh");97		space.run("offer", "--builds", "--notes", NOTES);9899		const dataDir = await mkdtemp(join(tmpdir(), "kepr-update-"));100		cleanup = [space.root, dataDir];101		const kepr = openKeprDb(dataDir);102		await syncAll({103			bucket: openBucket({ kind: "folder", path: space.bucketDir }),104			kepr,105			mirrorDir: join(dataDir, "mirror"),106		});107		kepr.run("UPDATE repos SET public = 1 WHERE name = 'koh'");108109		// A download URL in the feed carries the configured domain, so the app is built110		// with the real one once the port is known.111		const config = (domain: string) => ({112			domain,113			dataDir,114			adminPasswordHash: "$argon2id$x",115			bucket: { kind: "folder" as const, path: space.bucketDir },116		});117		let app = createApp(config("127.0.0.1:1"), kepr);118		let good: Feed | null = null;119		let bad: Feed | null = null;120		const goodPath = `/${REPO}/releases/latest.local.json`;121		const badPath = `/${REPO}/releases/latest.tampered.json`;122		server = Bun.serve({123			port: 0,124			fetch: (request) => {125				const url = new URL(request.url);126				if (url.pathname === goodPath && good) return Response.json(good);127				if (url.pathname === badPath && bad) return Response.json(bad);128				return app.fetch(request);129			},130		});131		app = createApp(config(`127.0.0.1:${server.port}`), kepr);132		checkSource = `http://127.0.0.1:${server.port}/${REPO}/releases/latest.json`;133		installSource = `http://127.0.0.1:${server.port}${goodPath}`;134		tamperedSource = `http://127.0.0.1:${server.port}${badPath}`;135136		// The document Kepr served, with its download pointed at the very file the folder137		// bucket holds (the TLS gap above), and a twin whose checksum is wrong.138		// A fetch here must fail fast rather than hang the suite.139		const served: Feed = (await (140			await fetch(checkSource, { signal: AbortSignal.timeout(10_000) })141		).json()) as Feed;142		const record: ReleaseRecord = (await Bun.file(143			join(space.bucketDir, REPO, "releases", NEW_VERSION, "release.json"),144		).json()) as ReleaseRecord;145		const artifact = record.files.find((file) => file.platform === platformName);146		if (!artifact) throw new Error("the published record has no file for this platform");147		const artifactPath = join(148			space.bucketDir,149			REPO,150			"releases",151			NEW_VERSION,152			platformName,153			artifact.name,154		);155		const real = served.platforms[platformName];156		if (!real) throw new Error("the served document has no file for this platform");157		good = {158			...served,159			platforms: {160				...served.platforms,161				[platformName]: { ...real, url: `file://${artifactPath}` },162			},163		};164		bad = {165			...served,166			platforms: {167				...served.platforms,168				[platformName]: { ...real, url: `file://${artifactPath}`, blake3: "0".repeat(64) },169			},170		};171172		const check = await run(installed, space.home, checkSource, "update", "--check");173		const checkJson = await run(installed, space.home, checkSource, "update", "--check", "--json");174		const refused = await run(installed, space.home, checkSource, "update", "--yes", "--json");175		const kept = new Uint8Array(await Bun.file(installed).arrayBuffer());176		const tampered = await run(installed, space.home, tamperedSource, "update", "--yes", "--json");177		const afterTampering = new Uint8Array(await Bun.file(installed).arrayBuffer());178		const install = await run(installed, space.home, installSource, "update", "--yes", "--json");179		checks = {180			code: check.exitCode,181			jsonCode: checkJson.exitCode,182			json: JSON.parse(checkJson.stdout) as UpdateJson,183			install: JSON.parse(install.stdout) as UpdateJson,184			refused: refused.stderr,185			refusedStdout: refused.stdout,186			tampered: tampered.stderr,187			afterTampering,188			baseline,189			kept,190			// koh names the binary it replaced by its real path, and /var is a symlink.191			target: await realpath(installed),192		};193	});194195	afterAll(async () => {196		server?.stop(true);197		for (const dir of cleanup) await rm(dir, { recursive: true, force: true });198	});199200	/**201	 * Runs a koh3 with the update source pointed at this test's Kepr. Asynchronous on purpose:202	 * `Bun.spawnSync` would block the event loop this test's own HTTP server runs on, and the two203	 * would wait for each other forever. A slow or wedged updater fails the test rather than204	 * hanging the suite.205	 */206	async function run(path: string, home: string, source: string, ...args: string[]) {207		const proc = Bun.spawn([path, ...args], {208			env: { PATH: process.env.PATH, HOME: home, NO_COLOR: "1", KOH_UPDATE_SOURCE: source },209			stdout: "pipe",210			stderr: "pipe",211		});212		const finished: Promise<[string, string, number]> = Promise.all([213			new Response(proc.stdout).text(),214			new Response(proc.stderr).text(),215			proc.exited,216		]);217		// A real-clock watchdog, not a fake timer: the thing being waited on is another218		// process, so there is no event to await and deterministic time control cannot219		// apply. Without it a wedged updater hangs the suite instead of failing it.220		const late = Bun.sleep(RUN_TIMEOUT_MS).then((): [string, string, number] => {221			proc.kill();222			throw new Error(`koh3 ${args.join(" ")} did not finish in ${RUN_TIMEOUT_MS} ms`);223		});224		const [stdout, stderr, exitCode] = await Promise.race([finished, late]);225		return { stdout, stderr, exitCode };226	}227228	test("--check reports an update and exits 1, in text and in JSON alike", () => {229		expect(checks.code).toBe(1);230		expect(checks.jsonCode).toBe(1);231		expect(checks.json.available).toBe(true);232	});233234	test("a download it cannot reach is refused, and the binary is left alone", () => {235		// Kepr's own URL is https on a server that has no TLS, so this is what an236		// unreachable release file looks like: a refusal, never a half-install.237		expect(checks.refused).toContain("could not download");238		expect(checks.refusedStdout).toBe("");239		expect(Buffer.compare(checks.kept, checks.baseline)).toBe(0);240	});241242	test("a download that does not match its checksum is refused before anything is replaced", () => {243		expect(checks.tampered).toContain("not the published file");244		expect(Buffer.compare(checks.afterTampering, checks.baseline)).toBe(0);245	});246247	test("the update replaced the running binary with the published file", async () => {248		expect(checks.install.installed).toBe(checks.target);249		const downloaded = new Uint8Array(await Bun.file(installed).arrayBuffer());250		const released = new Uint8Array(await Bun.file(published).arrayBuffer());251		if (process.platform === "darwin") {252			// Apple silicon refuses to run an unsigned binary, so koh signs the new253			// file before the rename: the installed file is the artifact with a fresh254			// signature, and its size may go either way, so the bytes cannot be compared255			// here. What can be proved is that the file that landed is a validly signed,256			// runnable koh — and koh itself refused to go further until the download's257			// Blake3 matched the published one. June's staging check (§6.4) is the real258			// proof over HTTPS.259			const signed = Bun.spawn(["codesign", "--verify", installed]);260			expect(await signed.exited).toBe(0);261			expect(downloaded.length).toBeGreaterThan(0);262		} else {263			expect(Buffer.compare(downloaded, released)).toBe(0);264		}265		// And it is a working koh still: the replaced binary runs.266		const version = (await run(installed, tmpdir(), checkSource, "--version")).stdout.trim();267		expect(version.startsWith("koh ")).toBe(true);268	});269});270271/** This machine's platform, which is what `koh build` uses with no flags. */272function platform(): string {273	const os = process.platform === "darwin" ? "macos" : "linux";274	const arch = process.arch === "arm64" ? "aarch64" : "x86_64";275	return `${os}-${arch}`;276}