47192351ee32

close the joint update test's review: a tampered download refused with the binary untouched, a watchdog and a timeout on every wait, the temporary folders cleaned up, and the environment's limits named in the file (v3.0.57)

time
2026-09-27 14:17 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
deepseek/deepseek-flash
session
kepr 3 implementation
parent
9445b343df7f
2 files package.json+1 −1tests/kohupdate.test.ts+77 −22
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.56",3+	"version": "3.0.57",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

tests/kohupdate.test.ts+77 −22
@@ -1,5 +1,5 @@11 import { afterAll, beforeAll, describe, expect, test } from "bun:test";2-import { cp, mkdir, mkdtemp, realpath, writeFile } from "node:fs/promises";2+import { cp, mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";33 import { tmpdir } from "node:os";44 import { join } from "node:path";55 import type { Server } from "bun";@@ -14,16 +14,24 @@1414  * syncs and serves it, and a second koh3 — a copy of the installed binary, never the installed one1515  * — replaces itself with what it downloads.1616  *17- * The release file is a real copy of the koh3 binary, so the ad-hoc codesign on macOS is the real18- * thing. One environment limit is documented below: Kepr builds its download URLs as `https://…`19- * by design, and this test's server has no TLS, so the document it serves for the install points20- * the download at the `file://` path a folder-bucket Kepr hands out (which koh3 reads).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.2127  */2228 2329 const NEW_VERSION = "9.9.9";2430 const REPO = "koh";2531 const NOTES =2632 	"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. */34+const RUN_TIMEOUT_MS = 30_000;2735 2836 /** What koh3 reads: the release document (§22.4). */2937 type Feed = {@@ -40,16 +48,21 @@4048 4149 describe("koh3 updating itself through Kepr", () => {4250 	let server: Server<undefined>;51+	let cleanup: string[] = [];4352 	let checkSource = "";4453 	let installSource = "";54+	let tamperedSource = "";4555 	let installed = "";4656 	let published = "";4757 	let checks: {4858 		code: number;59+		jsonCode: number;4960 		json: UpdateJson;5061 		install: UpdateJson;5162 		refused: string;5263 		refusedStdout: string;64+		tampered: string;65+		afterTampering: Uint8Array;5366 		baseline: Uint8Array;5467 		kept: Uint8Array;5568 		target: string;@@ -84,6 +97,7 @@8497 		space.run("offer", "--builds", "--notes", NOTES);8598 8699 		const dataDir = await mkdtemp(join(tmpdir(), "kepr-update-"));100+		cleanup = [space.root, dataDir];87101 		const kepr = openKeprDb(dataDir);88102 		await syncAll({89103 			bucket: openBucket({ kind: "folder", path: space.bucketDir }),@@ -102,23 +116,30 @@102116 			bucket: { kind: "folder" as const, path: space.bucketDir },103117 		});104118 		let app = createApp(config("127.0.0.1:1"), kepr);105-		let local: Feed | null = null;106-		const localPath = `/${REPO}/releases/latest.local.json`;119+		let good: Feed | null = null;120+		let bad: Feed | null = null;121+		const goodPath = `/${REPO}/releases/latest.local.json`;122+		const badPath = `/${REPO}/releases/latest.tampered.json`;107123 		server = Bun.serve({108124 			port: 0,109125 			fetch: (request) => {110126 				const url = new URL(request.url);111-				if (url.pathname === localPath && local) return Response.json(local);127+				if (url.pathname === goodPath && good) return Response.json(good);128+				if (url.pathname === badPath && bad) return Response.json(bad);112129 				return app.fetch(request);113130 			},114131 		});115132 		app = createApp(config(`127.0.0.1:${server.port}`), kepr);116133 		checkSource = `http://127.0.0.1:${server.port}/${REPO}/releases/latest.json`;117-		installSource = `http://127.0.0.1:${server.port}${localPath}`;134+		installSource = `http://127.0.0.1:${server.port}${goodPath}`;135+		tamperedSource = `http://127.0.0.1:${server.port}${badPath}`;118136 119-		// The document Kepr served, with its download pointed at the very file the120-		// folder bucket holds (the TLS gap above).121-		const served: Feed = (await (await fetch(checkSource)).json()) as Feed;137+		// The document Kepr served, with its download pointed at the very file the folder138+		// bucket holds (the TLS gap above), and a twin whose checksum is wrong.139+		// A fetch here must fail fast rather than hang the suite.140+		const served: Feed = (await (141+			await fetch(checkSource, { signal: AbortSignal.timeout(10_000) })142+		).json()) as Feed;122143 		const record: ReleaseRecord = (await Bun.file(123144 			join(space.bucketDir, REPO, "releases", NEW_VERSION, "release.json"),124145 		).json()) as ReleaseRecord;@@ -132,25 +153,39 @@132153 			platformName,133154 			artifact.name,134155 		);135-		local = {156+		const real = served.platforms[platformName];157+		if (!real) throw new Error("the served document has no file for this platform");158+		good = {136159 			...served,137160 			platforms: {138161 				...served.platforms,139-				[platformName]: { ...served.platforms[platformName], url: `file://${artifactPath}` },162+				[platformName]: { ...real, url: `file://${artifactPath}` },140163 			},141164 		};165+		bad = {166+			...served,167+			platforms: {168+				...served.platforms,169+				[platformName]: { ...real, url: `file://${artifactPath}`, blake3: "0".repeat(64) },170+			},171+		};142172 143173 		const check = await run(installed, space.home, checkSource, "update", "--check");144174 		const checkJson = await run(installed, space.home, checkSource, "update", "--check", "--json");145175 		const refused = await run(installed, space.home, checkSource, "update", "--yes", "--json");146176 		const kept = new Uint8Array(await Bun.file(installed).arrayBuffer());177+		const tampered = await run(installed, space.home, tamperedSource, "update", "--yes", "--json");178+		const afterTampering = new Uint8Array(await Bun.file(installed).arrayBuffer());147179 		const install = await run(installed, space.home, installSource, "update", "--yes", "--json");148180 		checks = {149181 			code: check.exitCode,182+			jsonCode: checkJson.exitCode,150183 			json: JSON.parse(checkJson.stdout) as UpdateJson,151184 			install: JSON.parse(install.stdout) as UpdateJson,152185 			refused: refused.stderr,153186 			refusedStdout: refused.stdout,187+			tampered: tampered.stderr,188+			afterTampering,154189 			baseline,155190 			kept,156191 			// koh names the binary it replaced by its real path, and /var is a symlink.@@ -158,14 +193,16 @@158193 		};159194 	});160195 161-	afterAll(() => {196+	afterAll(async () => {162197 		server?.stop(true);198+		for (const dir of cleanup) await rm(dir, { recursive: true, force: true });163199 	});164200 165201 	/**166202 	 * Runs a koh3 with the update source pointed at this test's Kepr. Asynchronous on purpose:167203 	 * `Bun.spawnSync` would block the event loop this test's own HTTP server runs on, and the two168-	 * would wait for each other forever.204+	 * would wait for each other forever. A slow or wedged updater fails the test rather than205+	 * hanging the suite.169206 	 */170207 	async function run(path: string, home: string, source: string, ...args: string[]) {171208 		const proc = Bun.spawn([path, ...args], {@@ -173,16 +210,25 @@173210 			stdout: "pipe",174211 			stderr: "pipe",175212 		});176-		const [stdout, stderr, exitCode] = await Promise.all([213+		const finished: Promise<[string, string, number]> = Promise.all([177214 			new Response(proc.stdout).text(),178215 			new Response(proc.stderr).text(),179216 			proc.exited,180217 		]);218+		// A real-clock watchdog, not a fake timer: the thing being waited on is another219+		// process, so there is no event to await and deterministic time control cannot220+		// apply. Without it a wedged updater hangs the suite instead of failing it.221+		const late = Bun.sleep(RUN_TIMEOUT_MS).then((): [string, string, number] => {222+			proc.kill();223+			throw new Error(`koh3 ${args.join(" ")} did not finish in ${RUN_TIMEOUT_MS} ms`);224+		});225+		const [stdout, stderr, exitCode] = await Promise.race([finished, late]);181226 		return { stdout, stderr, exitCode };182227 	}183228 184-	test("--check reports an update and exits 1, and --json says the same thing", () => {229+	test("--check reports an update and exits 1, in text and in JSON alike", () => {185230 		expect(checks.code).toBe(1);231+		expect(checks.jsonCode).toBe(1);186232 		expect(checks.json.available).toBe(true);187233 	});188234 @@ -194,17 +240,26 @@194240 		expect(Buffer.compare(checks.kept, checks.baseline)).toBe(0);195241 	});196242 243+	test("a download that does not match its checksum is refused before anything is replaced", () => {244+		expect(checks.tampered).toContain("not the published file");245+		expect(Buffer.compare(checks.afterTampering, checks.baseline)).toBe(0);246+	});247+197248 	test("the update replaced the running binary with the published file", async () => {198249 		expect(checks.install.installed).toBe(checks.target);199250 		const downloaded = new Uint8Array(await Bun.file(installed).arrayBuffer());200251 		const released = new Uint8Array(await Bun.file(published).arrayBuffer());201252 		if (process.platform === "darwin") {202-			// Apple silicon refuses to run an unsigned binary, and the ad-hoc signature203-			// koh writes is part of the file, so the installed copy is the artifact plus204-			// that signature. Being validly signed and running is the proof here; the205-			// Blake3 of the bytes that arrived is checked inside koh before signing.253+			// Apple silicon refuses to run an unsigned binary, so koh signs the staged254+			// file before the rename: the installed file is the artifact with a fresh255+			// signature, and its size may go either way, so the bytes cannot be compared256+			// here. What can be proved is that the file that landed is a validly signed,257+			// runnable koh — and koh itself refused to go further until the download's258+			// Blake3 matched the published one. June's staging check (§6.4) is the real259+			// proof over HTTPS.206260 			const signed = Bun.spawn(["codesign", "--verify", installed]);207261 			expect(await signed.exited).toBe(0);262+			expect(downloaded.length).toBeGreaterThan(0);208263 		} else {209264 			expect(Buffer.compare(downloaded, released)).toBe(0);210265 		}

view file