import { beforeAll, describe, expect, test } from "bun:test"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { openBucket } from "../src/bucket/reader.ts"; import { checkFormat, listRepos, readHead, readObject, readSave } from "../src/bucket/repo.ts"; import { type Fixture, makeKohBucket } from "./fixture.ts"; let fx: Fixture; beforeAll(async () => { fx = await makeKohBucket(); }); const bucket = () => openBucket({ kind: "folder", path: fx.bucketDir }); describe("reading a koh3-produced folder remote", () => { test("lists the repo", async () => { expect(await listRepos(bucket())).toEqual([fx.repo]); }); test("reads format, head, the save record and one verified object end to end", async () => { const b = bucket(); await checkFormat(b, fx.repo); const head = await readHead(b, fx.repo); expect(head).toBe(fx.saveIds[0] as string); const save = await readSave(b, fx.repo, head as string); expect(save.message).toBe("say hello"); expect(save.is_checkpoint).toBe(false); const checkpoint = await readSave(b, fx.repo, save.parent); expect(checkpoint.is_checkpoint).toBe(true); const first = await readSave(b, fx.repo, checkpoint.parent); expect([first.agent, first.session]).toEqual(["test-agent", "fixture"]); const main = save.files.find((file) => file.path === "src/main.ts"); const bytes = await readObject(b, fx.repo, main?.hash as string); expect(new TextDecoder().decode(bytes)).toBe("// Entry point.\nconsole.log('hello');\n"); }); test("a missing key reads as null and a listing is prefix-scoped", async () => { const b = bucket(); expect(await b.get(`${fx.repo}/nothing-here`)).toBeNull(); const saves = await b.list(`${fx.repo}/saves/`); expect(saves.sort()).toEqual(fx.saveIds.map((id) => `${fx.repo}/saves/${id}`).sort()); }); }); test("the S3 reader pages through listings and reads a missing key as null", async () => { const pageSize = 2; const server = Bun.serve({ port: 0, async fetch(req) { const url = new URL(req.url); const folder = bucket(); if (url.searchParams.get("list-type") === "2") { const all = await folder.list(url.searchParams.get("prefix") ?? ""); const start = Number(url.searchParams.get("continuation-token") ?? 0); const page = all.slice(start, start + pageSize); const more = start + pageSize < all.length; const contents = page.map((key) => `${key}`).join(""); const next = more ? `${start + pageSize}` : ""; return new Response( `${more}${contents}${next}`, ); } const bytes = await folder.get(url.pathname.replace("/kohbucket/", "")); if (bytes) return new Response(bytes); return new Response("NoSuchKey", { status: 404 }); }, }); try { const s3 = openBucket({ kind: "s3", endpoint: `http://localhost:${server.port}`, name: "kohbucket", region: "fr-par", accessKeyId: "read", secretAccessKey: "only", }); expect(await s3.list(`${fx.repo}/`)).toEqual(await bucket().list(`${fx.repo}/`)); expect(await readHead(s3, fx.repo)).toBe(fx.saveIds[0] as string); expect(await s3.get(`${fx.repo}/format`)).toBeNull(); } finally { server.stop(); } }); describe("the format rule", () => { test("accepts koh-remote 1 and refuses anything else", async () => { const { bucketDir, repo } = await makeKohBucket("formatted"); const b = openBucket({ kind: "folder", path: bucketDir }); await writeFile(join(bucketDir, repo, "format"), "koh-remote 1"); await checkFormat(b, repo); await writeFile(join(bucketDir, repo, "format"), "koh-remote 2"); await expect(checkFormat(b, repo)).rejects.toThrow('remote format "koh-remote 2"'); }); }); describe("object verification", () => { test("refuses an object whose content does not match its key", async () => { const b = bucket(); const save = await readSave(b, fx.repo, fx.saveIds[0] as string); const [one, two] = save.files; const swapped = { ...b, get: (key: string) => b.get(key.replace(one?.hash ?? "", two?.hash ?? "")), }; await expect(readObject(swapped, fx.repo, one?.hash as string)).rejects.toThrow( "does not match its hash", ); }); }); describe("untrusted input", () => { // Starts from a genuine koh3 record and corrupts one field, as a tampered bucket would. async function readTampered(change: (record: Record) => void) { const id = fx.saveIds[0] as string; const real = await bucket().get(`${fx.repo}/saves/${id}`); const record = JSON.parse(new TextDecoder().decode(real as Uint8Array)); change(record); const bytes = new TextEncoder().encode(JSON.stringify(record)); return readSave({ get: async () => bytes, list: async () => [] }, fx.repo, id); } const withPath = (path: string) => (record: Record) => { (record.files as { path: string }[])[0] = { ...(record.files as { path: string }[])[0], path }; }; test("the genuine record is accepted", async () => { expect((await readTampered(() => {})).message).toBe("say hello"); }); test.each([ "", "/etc/passwd", "../escape", "src/../../escape", "./src/main.ts", "src//main.ts", "src/", "src\\main.ts", "src/ma\0in.ts", "src/ma\nin.ts", ])("refuses the file path %p", async (path) => { await expect(readTampered(withPath(path))).rejects.toThrow("Koh 3 schema"); }); test("refuses duplicate paths in one record", async () => { const dupe = (record: Record) => { const files = record.files as { path: string }[]; files[1] = { ...files[1], path: (files[0] as { path: string }).path } as { path: string }; }; await expect(readTampered(dupe)).rejects.toThrow("Koh 3 schema"); }); test.each([ ["parent", "not-an-id"], ["restores", "ABCDEF123456"], ["replayed_from", "../../../x"], ["created_at", -1], ["created_at", 1.5], ["size_bytes", -10], ["size_bytes", "12"], ])("refuses %s = %p", async (field, value) => { await expect(readTampered((record) => (record[field] = value))).rejects.toThrow("Koh 3 schema"); }); test("refuses bad repo names and save IDs before building a key", async () => { const b = bucket(); for (const repo of ["..", "a/b", "", "../demo"]) { await expect(checkFormat(b, repo)).rejects.toThrow("not a repo name"); await expect(readHead(b, repo)).rejects.toThrow("not a repo name"); await expect(readSave(b, repo, fx.saveIds[0] as string)).rejects.toThrow("not a repo name"); await expect(readObject(b, repo, "0".repeat(64))).rejects.toThrow("not a repo name"); } for (const id of ["../head", "abc", "ABCDEF123456", `${fx.saveIds[0]}0`]) { await expect(readSave(b, fx.repo, id)).rejects.toThrow("not a save ID"); } }); });