import { beforeAll, describe, expect, test } from "bun:test"; import { cp, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { inflateSync } from "node:zlib"; import { openBucket } from "../src/bucket/reader.ts"; import { checkFormat, escapePath, listEventIds, listRepos, readEvent, 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 koh-produced folder remote", () => { test("lists the repo", async () => { expect(await listRepos(bucket())).toEqual([fx.repo]); }); test("a folder without a head, or Kepr's own, is not a repo", async () => { // As a rename leaves the new name until it copies `head`, last. await cp(join(fx.bucketDir, fx.repo, "saves"), join(fx.bucketDir, "half", "saves"), { recursive: true, }); await Bun.write(join(fx.bucketDir, ".kepr/hidden", fx.repo), "secret.txt\n"); 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 { compressed, size } = await readObject(b, fx.repo, main?.hash as string); const text = "// Entry point.\nconsole.log('hello');\n"; expect(new TextDecoder().decode(inflateSync(compressed))).toBe(text); expect(size).toBe(text.length); }); 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 with sizes 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 sizes = await folder.sizes(url.searchParams.get("prefix") ?? ""); const all = [...sizes.keys()]; 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}${sizes.get(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}/`)); const sizes = await s3.sizes(`${fx.repo}/`); expect(sizes).toEqual(await bucket().sizes(`${fx.repo}/`)); expect(sizes.get(`${fx.repo}/head`)).toBe(12); expect(await readHead(s3, fx.repo)).toBe(fx.saveIds[0] as string); // koh writes `/format` on its first offer (§9.2), so it is present; // a key that is not there is what reads as null. const format = await s3.get(`${fx.repo}/format`); expect(format === null ? null : new TextDecoder().decode(format)).toBe("koh-remote 1"); expect(await s3.get(`${fx.repo}/no-such-key`)).toBeNull(); const url = new URL( s3.downloadUrl("demo/releases/1.0/android-arm64/a b.apk", "a b.apk", "t/x"), ); expect(url.pathname).toBe("/kohbucket/demo/releases/1.0/android-arm64/a%20b.apk"); expect(url.searchParams.get("X-Amz-Expires")).toBe("300"); expect(url.searchParams.get("response-content-disposition")).toBe( 'attachment; filename="a b.apk"', ); expect(url.searchParams.get("response-content-type")).toBe("t/x"); } 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 koh 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 () => [], folders: async () => [], size: async () => 0, sizes: async () => new Map(), downloadUrl: () => "", viewUrl: () => null, put: async () => {}, remove: async () => {}, copy: async () => {}, removeRenamed: 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"); }); // §5.5: a path Koh would refuse is quarantined, and the rest of the save still lands. 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", "Icon\r", "a\u0085b", ])("quarantines the file path %p", async (path) => { const save = await readTampered(withPath(path)); expect(save.files.map((file) => file.path)).not.toContain(path); expect(save.files.length).toBeGreaterThan(0); expect(save.quarantined).toEqual([escapePath(path)]); }); test("quarantines bytes outside UTF-8 and unpaired surrogate escapes, and keeps a real U+FFFD", async () => { 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)); record.files[0].path = "bad-X-name"; record.files[1].path = "odd-\ufffd-name"; record.files[2].path = "lead-\ud800"; record.files[3].path = "pair-\ud83d\ude00"; const bytes = Buffer.from(JSON.stringify(record)); bytes[bytes.indexOf("bad-X-name") + 4] = 0xff; const save = await readSave( { get: async () => bytes, list: async () => [], folders: async () => [], size: async () => 0, sizes: async () => new Map(), downloadUrl: () => "", viewUrl: () => null, put: async () => {}, remove: async () => {}, copy: async () => {}, removeRenamed: async () => {}, }, fx.repo, id, ); expect(save.quarantined.sort()).toEqual(["bad-\\xff-name", "lead-\\xed\\xa0\\x80"]); expect(save.files.map((file) => file.path)).toContain("odd-\ufffd-name"); expect(save.files.map((file) => file.path)).toContain("pair-\ud83d\ude00"); }); test("escapes a name as Koh's escape_path does", () => { expect(["Icon\r", "a\\b", "a\u0085", "\x7f", "plain/ok"].map(escapePath)).toEqual([ "Icon\\x0d", "a\\\\b", "a\\u0085", "\\x7f", "plain/ok", ]); }); 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("accepts the six-character IDs that Koh 2's early saves keep through migration", async () => { expect((await readTampered((record) => (record.parent = "af96c8"))).parent).toBe("af96c8"); }); test.each([ ["parent", "not-an-id"], ["parent", "af96c"], ["parent", "af96c8a"], ["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"); } }); }); describe("event records", () => { test("reads a genuine koh flag event and refuses a tampered one", async () => { const b = bucket(); const [id] = await listEventIds(b, fx.repo); const event = await readEvent(b, fx.repo, id as string); expect([event.kind, event.save_id, event.reason]).toEqual([ "flag", fx.saveIds[3] as string, "holds a secret", ]); const stripAll = { kind: "strip", save_id: "", path: "secret.txt" }; const accepted = new TextEncoder().encode(JSON.stringify({ ...event, ...stripAll })); const reader = { get: async () => accepted, list: async () => [], folders: async () => [], size: async () => 0, sizes: async () => new Map(), downloadUrl: () => "", viewUrl: () => null, put: async () => {}, remove: async () => {}, copy: async () => {}, removeRenamed: async () => {}, }; expect((await readEvent(reader, fx.repo, event.id)).kind).toBe("strip"); for (const change of [ { kind: "delete" }, { path: "../x" }, { save_id: "nope" }, { kind: "flag", save_id: "" }, { kind: "clear", save_id: "" }, { kind: "flag", path: "secret.txt" }, { kind: "clear", path: "secret.txt" }, // A strip with an illegal path, "" included, parses; sync ignores it (sync.test.ts). ]) { const bytes = new TextEncoder().encode(JSON.stringify({ ...event, ...change })); const tampered = { get: async () => bytes, list: async () => [], folders: async () => [], size: async () => 0, sizes: async () => new Map(), downloadUrl: () => "", viewUrl: () => null, put: async () => {}, remove: async () => {}, copy: async () => {}, removeRenamed: async () => {}, }; await expect(readEvent(tampered, fx.repo, event.id)).rejects.toThrow("Koh 3 schema"); } }); });