1import { beforeAll, describe, expect, test } from "bun:test";2import { writeFile } from "node:fs/promises";3import { join } from "node:path";4import { openBucket } from "../src/bucket/reader.ts";5import {6 checkFormat,7 listEventIds,8 listRepos,9 readEvent,10 readHead,11 readObject,12 readSave,13} from "../src/bucket/repo.ts";14import { type Fixture, makeKohBucket } from "./fixture.ts";1516let fx: Fixture;17beforeAll(async () => {18 fx = await makeKohBucket();19});2021const bucket = () => openBucket({ kind: "folder", path: fx.bucketDir });2223describe("reading a koh3-produced folder remote", () => {24 test("lists the repo", async () => {25 expect(await listRepos(bucket())).toEqual([fx.repo]);26 });2728 test("reads format, head, the save record and one verified object end to end", async () => {29 const b = bucket();30 await checkFormat(b, fx.repo);31 const head = await readHead(b, fx.repo);32 expect(head).toBe(fx.saveIds[0] as string);3334 const save = await readSave(b, fx.repo, head as string);35 expect(save.message).toBe("say hello");36 expect(save.is_checkpoint).toBe(false);37 const checkpoint = await readSave(b, fx.repo, save.parent);38 expect(checkpoint.is_checkpoint).toBe(true);39 const first = await readSave(b, fx.repo, checkpoint.parent);40 expect([first.agent, first.session]).toEqual(["test-agent", "fixture"]);4142 const main = save.files.find((file) => file.path === "src/main.ts");43 const { bytes } = await readObject(b, fx.repo, main?.hash as string);44 expect(new TextDecoder().decode(bytes)).toBe("// Entry point.\nconsole.log('hello');\n");45 });4647 test("a missing key reads as null and a listing is prefix-scoped", async () => {48 const b = bucket();49 expect(await b.get(`${fx.repo}/nothing-here`)).toBeNull();50 const saves = await b.list(`${fx.repo}/saves/`);51 expect(saves.sort()).toEqual(fx.saveIds.map((id) => `${fx.repo}/saves/${id}`).sort());52 });53});5455test("the S3 reader pages through listings and reads a missing key as null", async () => {56 const pageSize = 2;57 const server = Bun.serve({58 port: 0,59 async fetch(req) {60 const url = new URL(req.url);61 const folder = bucket();62 if (url.searchParams.get("list-type") === "2") {63 const all = await folder.list(url.searchParams.get("prefix") ?? "");64 const start = Number(url.searchParams.get("continuation-token") ?? 0);65 const page = all.slice(start, start + pageSize);66 const more = start + pageSize < all.length;67 const contents = page.map((key) => `<Contents><Key>${key}</Key></Contents>`).join("");68 const next = more69 ? `<NextContinuationToken>${start + pageSize}</NextContinuationToken>`70 : "";71 return new Response(72 `<ListBucketResult><IsTruncated>${more}</IsTruncated>${contents}${next}</ListBucketResult>`,73 );74 }75 const bytes = await folder.get(url.pathname.replace("/kohbucket/", ""));76 if (bytes) return new Response(bytes);77 return new Response("<Error><Code>NoSuchKey</Code></Error>", { status: 404 });78 },79 });80 try {81 const s3 = openBucket({82 kind: "s3",83 endpoint: `http://localhost:${server.port}`,84 name: "kohbucket",85 region: "fr-par",86 accessKeyId: "read",87 secretAccessKey: "only",88 });89 expect(await s3.list(`${fx.repo}/`)).toEqual(await bucket().list(`${fx.repo}/`));90 expect(await readHead(s3, fx.repo)).toBe(fx.saveIds[0] as string);91 92 93 const format = await s3.get(`${fx.repo}/format`);94 expect(format === null ? null : new TextDecoder().decode(format)).toBe("koh-remote 1");95 expect(await s3.get(`${fx.repo}/no-such-key`)).toBeNull();96 const url = new URL(97 s3.downloadUrl("demo/releases/1.0/android-arm64/a b.apk", "a b.apk", "t/x"),98 );99 expect(url.pathname).toBe("/kohbucket/demo/releases/1.0/android-arm64/a%20b.apk");100 expect(url.searchParams.get("X-Amz-Expires")).toBe("300");101 expect(url.searchParams.get("response-content-disposition")).toBe(102 'attachment; filename="a b.apk"',103 );104 expect(url.searchParams.get("response-content-type")).toBe("t/x");105 } finally {106 server.stop();107 }108});109110describe("the format rule", () => {111 test("accepts koh-remote 1 and refuses anything else", async () => {112 const { bucketDir, repo } = await makeKohBucket("formatted");113 const b = openBucket({ kind: "folder", path: bucketDir });114 await writeFile(join(bucketDir, repo, "format"), "koh-remote 1");115 await checkFormat(b, repo);116 await writeFile(join(bucketDir, repo, "format"), "koh-remote 2");117 await expect(checkFormat(b, repo)).rejects.toThrow('remote format "koh-remote 2"');118 });119});120121describe("object verification", () => {122 test("refuses an object whose content does not match its key", async () => {123 const b = bucket();124 const save = await readSave(b, fx.repo, fx.saveIds[0] as string);125 const [one, two] = save.files;126 const swapped = {127 ...b,128 get: (key: string) => b.get(key.replace(one?.hash ?? "", two?.hash ?? "")),129 };130 await expect(readObject(swapped, fx.repo, one?.hash as string)).rejects.toThrow(131 "does not match its hash",132 );133 });134});135136describe("untrusted input", () => {137 138 async function readTampered(change: (record: Record<string, unknown>) => void) {139 const id = fx.saveIds[0] as string;140 const real = await bucket().get(`${fx.repo}/saves/${id}`);141 const record = JSON.parse(new TextDecoder().decode(real as Uint8Array));142 change(record);143 const bytes = new TextEncoder().encode(JSON.stringify(record));144 return readSave(145 { get: async () => bytes, list: async () => [], downloadUrl: () => "" },146 fx.repo,147 id,148 );149 }150 const withPath = (path: string) => (record: Record<string, unknown>) => {151 (record.files as { path: string }[])[0] = { ...(record.files as { path: string }[])[0], path };152 };153154 test("the genuine record is accepted", async () => {155 expect((await readTampered(() => {})).message).toBe("say hello");156 });157158 test.each([159 "",160 "/etc/passwd",161 "../escape",162 "src/../../escape",163 "./src/main.ts",164 "src//main.ts",165 "src/",166 "src\\main.ts",167 "src/ma\0in.ts",168 "src/ma\nin.ts",169 ])("refuses the file path %p", async (path) => {170 await expect(readTampered(withPath(path))).rejects.toThrow("Koh 3 schema");171 });172173 test("refuses duplicate paths in one record", async () => {174 const dupe = (record: Record<string, unknown>) => {175 const files = record.files as { path: string }[];176 files[1] = { ...files[1], path: (files[0] as { path: string }).path } as { path: string };177 };178 await expect(readTampered(dupe)).rejects.toThrow("Koh 3 schema");179 });180181 test.each([182 ["parent", "not-an-id"],183 ["restores", "ABCDEF123456"],184 ["replayed_from", "../../../x"],185 ["created_at", -1],186 ["created_at", 1.5],187 ["size_bytes", -10],188 ["size_bytes", "12"],189 ])("refuses %s = %p", async (field, value) => {190 await expect(readTampered((record) => (record[field] = value))).rejects.toThrow("Koh 3 schema");191 });192193 test("refuses bad repo names and save IDs before building a key", async () => {194 const b = bucket();195 for (const repo of ["..", "a/b", "", "../demo"]) {196 await expect(checkFormat(b, repo)).rejects.toThrow("not a repo name");197 await expect(readHead(b, repo)).rejects.toThrow("not a repo name");198 await expect(readSave(b, repo, fx.saveIds[0] as string)).rejects.toThrow("not a repo name");199 await expect(readObject(b, repo, "0".repeat(64))).rejects.toThrow("not a repo name");200 }201 for (const id of ["../head", "abc", "ABCDEF123456", `${fx.saveIds[0]}0`]) {202 await expect(readSave(b, fx.repo, id)).rejects.toThrow("not a save ID");203 }204 });205});206207describe("event records", () => {208 test("reads a genuine koh3 flag event and refuses a tampered one", async () => {209 const b = bucket();210 const [id] = await listEventIds(b, fx.repo);211 const event = await readEvent(b, fx.repo, id as string);212 expect([event.kind, event.save_id, event.reason]).toEqual([213 "flag",214 fx.saveIds[3] as string,215 "holds a secret",216 ]);217 const stripAll = { kind: "strip", save_id: "", path: "secret.txt" };218 const accepted = new TextEncoder().encode(JSON.stringify({ ...event, ...stripAll }));219 const reader = { get: async () => accepted, list: async () => [], downloadUrl: () => "" };220 expect((await readEvent(reader, fx.repo, event.id)).kind).toBe("strip");221 for (const change of [222 { kind: "delete" },223 { path: "../x" },224 { save_id: "nope" },225 { kind: "flag", save_id: "" },226 { kind: "clear", save_id: "" },227 { kind: "flag", path: "secret.txt" },228 { kind: "clear", path: "secret.txt" },229 { kind: "strip", path: "" },230 { kind: "strip", save_id: "", path: "" },231 ]) {232 const bytes = new TextEncoder().encode(JSON.stringify({ ...event, ...change }));233 const tampered = { get: async () => bytes, list: async () => [], downloadUrl: () => "" };234 await expect(readEvent(tampered, fx.repo, event.id)).rejects.toThrow("Koh 3 schema");235 }236 });237});