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";11121314151617181920212223242526272829const 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";3334const RUN_TIMEOUT_MS = 30_000;353637type Feed = {38 version: string;39 notes?: string;40 platforms: Record<string, { url: string; blake3: string; sha256: string }>;41};424344type ReleaseRecord = { files: { platform: string; name: string }[] };454647type 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 110 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 137 138 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 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 201202203204205206 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 218 219 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 236 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 253 254 255 256 257 258 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 266 const version = (await run(installed, tmpdir(), checkSource, "--version")).stdout.trim();267 expect(version.startsWith("koh ")).toBe(true);268 });269});270271272function platform(): string {273 const os = process.platform === "darwin" ? "macos" : "linux";274 const arch = process.arch === "arm64" ? "aarch64" : "x86_64";275 return `${os}-${arch}`;276}