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
↓ kepr-47192351.tar.gzatlas at this save
files changed2 files
package.json+1 −1tests/kohupdate.test.ts+77 −22package.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": {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 }