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 tarballDir: join(dataDir, "tarballs"),107 });108 kepr.run("UPDATE repos SET public = 1 WHERE name = 'koh'");109110 111 112 const config = (domain: string) => ({113 domain,114 dataDir,115 adminPasswordHash: "$argon2id$x",116 bucket: { kind: "folder" as const, path: space.bucketDir },117 });118 let app = createApp(config("127.0.0.1:1"), kepr);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`;123 server = Bun.serve({124 port: 0,125 fetch: (request) => {126 const url = new URL(request.url);127 if (url.pathname === goodPath && good) return Response.json(good);128 if (url.pathname === badPath && bad) return Response.json(bad);129 return app.fetch(request);130 },131 });132 app = createApp(config(`127.0.0.1:${server.port}`), kepr);133 checkSource = `http://127.0.0.1:${server.port}/${REPO}/releases/latest.json`;134 installSource = `http://127.0.0.1:${server.port}${goodPath}`;135 tamperedSource = `http://127.0.0.1:${server.port}${badPath}`;136137 138 139 140 const served: Feed = (await (141 await fetch(checkSource, { signal: AbortSignal.timeout(10_000) })142 ).json()) as Feed;143 const record: ReleaseRecord = (await Bun.file(144 join(space.bucketDir, REPO, "releases", NEW_VERSION, "release.json"),145 ).json()) as ReleaseRecord;146 const artifact = record.files.find((file) => file.platform === platformName);147 if (!artifact) throw new Error("the published record has no file for this platform");148 const artifactPath = join(149 space.bucketDir,150 REPO,151 "releases",152 NEW_VERSION,153 platformName,154 artifact.name,155 );156 const real = served.platforms[platformName];157 if (!real) throw new Error("the served document has no file for this platform");158 good = {159 ...served,160 platforms: {161 ...served.platforms,162 [platformName]: { ...real, url: `file://${artifactPath}` },163 },164 };165 bad = {166 ...served,167 platforms: {168 ...served.platforms,169 [platformName]: { ...real, url: `file://${artifactPath}`, blake3: "0".repeat(64) },170 },171 };172173 const check = await run(installed, space.home, checkSource, "update", "--check");174 const checkJson = await run(installed, space.home, checkSource, "update", "--check", "--json");175 const refused = await run(installed, space.home, checkSource, "update", "--yes", "--json");176 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());179 const install = await run(installed, space.home, installSource, "update", "--yes", "--json");180 checks = {181 code: check.exitCode,182 jsonCode: checkJson.exitCode,183 json: JSON.parse(checkJson.stdout) as UpdateJson,184 install: JSON.parse(install.stdout) as UpdateJson,185 refused: refused.stderr,186 refusedStdout: refused.stdout,187 tampered: tampered.stderr,188 afterTampering,189 baseline,190 kept,191 192 target: await realpath(installed),193 };194 });195196 afterAll(async () => {197 server?.stop(true);198 for (const dir of cleanup) await rm(dir, { recursive: true, force: true });199 });200201 202203204205206207 async function run(path: string, home: string, source: string, ...args: string[]) {208 const proc = Bun.spawn([path, ...args], {209 env: { PATH: process.env.PATH, HOME: home, NO_COLOR: "1", KOH_UPDATE_SOURCE: source },210 stdout: "pipe",211 stderr: "pipe",212 });213 const finished: Promise<[string, string, number]> = Promise.all([214 new Response(proc.stdout).text(),215 new Response(proc.stderr).text(),216 proc.exited,217 ]);218 219 220 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]);226 return { stdout, stderr, exitCode };227 }228229 test("--check reports an update and exits 1, in text and in JSON alike", () => {230 expect(checks.code).toBe(1);231 expect(checks.jsonCode).toBe(1);232 expect(checks.json.available).toBe(true);233 });234235 test("a download it cannot reach is refused, and the binary is left alone", () => {236 237 238 expect(checks.refused).toContain("could not download");239 expect(checks.refusedStdout).toBe("");240 expect(Buffer.compare(checks.kept, checks.baseline)).toBe(0);241 });242243 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 });247248 test("the update replaced the running binary with the published file", async () => {249 expect(checks.install.installed).toBe(checks.target);250 const downloaded = new Uint8Array(await Bun.file(installed).arrayBuffer());251 const released = new Uint8Array(await Bun.file(published).arrayBuffer());252 if (process.platform === "darwin") {253 254 255 256 257 258 259 260 const signed = Bun.spawn(["codesign", "--verify", installed]);261 expect(await signed.exited).toBe(0);262 expect(downloaded.length).toBeGreaterThan(0);263 } else {264 expect(Buffer.compare(downloaded, released)).toBe(0);265 }266 267 const version = (await run(installed, tmpdir(), checkSource, "--version")).stdout.trim();268 expect(version.startsWith("koh ")).toBe(true);269 });270});271272273function platform(): string {274 const os = process.platform === "darwin" ? "macos" : "linux";275 const arch = process.arch === "arm64" ? "aarch64" : "x86_64";276 return `${os}-${arch}`;277}