1import { beforeAll, describe, expect, test } from "bun:test";2import { cp, writeFile } from "node:fs/promises";3import { join } from "node:path";4import { inflateSync } from "node:zlib";5import { openBucket } from "../src/bucket/reader.ts";6import {7 checkFormat,8 escapePath,9 listEventIds,10 listRepos,11 readEvent,12 readHead,13 readObject,14 readSave,15} from "../src/bucket/repo.ts";16import { type Fixture, makeKohBucket } from "./fixture.ts";1718let fx: Fixture;19beforeAll(async () => {20 fx = await makeKohBucket();21});2223const bucket = () => openBucket({ kind: "folder", path: fx.bucketDir });2425describe("reading a koh-produced folder remote", () => {26 test("lists the repo", async () => {27 expect(await listRepos(bucket())).toEqual([fx.repo]);28 });2930 test("a folder without a head, or Kepr's own, is not a repo", async () => {31 32 await cp(join(fx.bucketDir, fx.repo, "saves"), join(fx.bucketDir, "half", "saves"), {33 recursive: true,34 });35 await Bun.write(join(fx.bucketDir, ".kepr/hidden", fx.repo), "secret.txt\n");36 expect(await listRepos(bucket())).toEqual([fx.repo]);37 });3839 test("reads format, head, the save record and one verified object end to end", async () => {40 const b = bucket();41 await checkFormat(b, fx.repo);42 const head = await readHead(b, fx.repo);43 expect(head).toBe(fx.saveIds[0] as string);4445 const save = await readSave(b, fx.repo, head as string);46 expect(save.message).toBe("say hello");47 expect(save.is_checkpoint).toBe(false);48 const checkpoint = await readSave(b, fx.repo, save.parent);49 expect(checkpoint.is_checkpoint).toBe(true);50 const first = await readSave(b, fx.repo, checkpoint.parent);51 expect([first.agent, first.session]).toEqual(["test-agent", "fixture"]);5253 const main = save.files.find((file) => file.path === "src/main.ts");54 const { compressed, size } = await readObject(b, fx.repo, main?.hash as string);55 const text = "// Entry point.\nconsole.log('hello');\n";56 expect(new TextDecoder().decode(inflateSync(compressed))).toBe(text);57 expect(size).toBe(text.length);58 });5960 test("a missing key reads as null and a listing is prefix-scoped", async () => {61 const b = bucket();62 expect(await b.get(`${fx.repo}/nothing-here`)).toBeNull();63 const saves = await b.list(`${fx.repo}/saves/`);64 expect(saves.sort()).toEqual(fx.saveIds.map((id) => `${fx.repo}/saves/${id}`).sort());65 });66});6768test("the S3 reader pages through listings with sizes and reads a missing key as null", async () => {69 const pageSize = 2;70 const server = Bun.serve({71 port: 0,72 async fetch(req) {73 const url = new URL(req.url);74 const folder = bucket();75 if (url.searchParams.get("list-type") === "2") {76 const sizes = await folder.sizes(url.searchParams.get("prefix") ?? "");77 const all = [...sizes.keys()];78 const start = Number(url.searchParams.get("continuation-token") ?? 0);79 const page = all.slice(start, start + pageSize);80 const more = start + pageSize < all.length;81 const contents = page82 .map((key) => `<Contents><Key>${key}</Key><Size>${sizes.get(key)}</Size></Contents>`)83 .join("");84 const next = more85 ? `<NextContinuationToken>${start + pageSize}</NextContinuationToken>`86 : "";87 return new Response(88 `<ListBucketResult><IsTruncated>${more}</IsTruncated>${contents}${next}</ListBucketResult>`,89 );90 }91 const bytes = await folder.get(url.pathname.replace("/kohbucket/", ""));92 if (bytes) return new Response(bytes);93 return new Response("<Error><Code>NoSuchKey</Code></Error>", { status: 404 });94 },95 });96 try {97 const s3 = openBucket({98 kind: "s3",99 endpoint: `http://localhost:${server.port}`,100 name: "kohbucket",101 region: "fr-par",102 accessKeyId: "read",103 secretAccessKey: "only",104 });105 expect(await s3.list(`${fx.repo}/`)).toEqual(await bucket().list(`${fx.repo}/`));106 const sizes = await s3.sizes(`${fx.repo}/`);107 expect(sizes).toEqual(await bucket().sizes(`${fx.repo}/`));108 expect(sizes.get(`${fx.repo}/head`)).toBe(12);109 expect(await readHead(s3, fx.repo)).toBe(fx.saveIds[0] as string);110 111 112 const format = await s3.get(`${fx.repo}/format`);113 expect(format === null ? null : new TextDecoder().decode(format)).toBe("koh-remote 1");114 expect(await s3.get(`${fx.repo}/no-such-key`)).toBeNull();115 const url = new URL(116 s3.downloadUrl("demo/releases/1.0/android-arm64/a b.apk", "a b.apk", "t/x"),117 );118 expect(url.pathname).toBe("/kohbucket/demo/releases/1.0/android-arm64/a%20b.apk");119 expect(url.searchParams.get("X-Amz-Expires")).toBe("300");120 expect(url.searchParams.get("response-content-disposition")).toBe(121 'attachment; filename="a b.apk"',122 );123 expect(url.searchParams.get("response-content-type")).toBe("t/x");124 } finally {125 server.stop();126 }127});128129describe("the format rule", () => {130 test("accepts koh-remote 1 and refuses anything else", async () => {131 const { bucketDir, repo } = await makeKohBucket("formatted");132 const b = openBucket({ kind: "folder", path: bucketDir });133 await writeFile(join(bucketDir, repo, "format"), "koh-remote 1");134 await checkFormat(b, repo);135 await writeFile(join(bucketDir, repo, "format"), "koh-remote 2");136 await expect(checkFormat(b, repo)).rejects.toThrow('remote format "koh-remote 2"');137 });138});139140describe("object verification", () => {141 test("refuses an object whose content does not match its key", async () => {142 const b = bucket();143 const save = await readSave(b, fx.repo, fx.saveIds[0] as string);144 const [one, two] = save.files;145 const swapped = {146 ...b,147 get: (key: string) => b.get(key.replace(one?.hash ?? "", two?.hash ?? "")),148 };149 await expect(readObject(swapped, fx.repo, one?.hash as string)).rejects.toThrow(150 "does not match its hash",151 );152 });153});154155describe("untrusted input", () => {156 157 async function readTampered(change: (record: Record<string, unknown>) => void) {158 const id = fx.saveIds[0] as string;159 const real = await bucket().get(`${fx.repo}/saves/${id}`);160 const record = JSON.parse(new TextDecoder().decode(real as Uint8Array));161 change(record);162 const bytes = new TextEncoder().encode(JSON.stringify(record));163 return readSave(164 {165 get: async () => bytes,166 list: async () => [],167 folders: async () => [],168 size: async () => 0,169 sizes: async () => new Map(),170 downloadUrl: () => "",171 viewUrl: () => null,172 put: async () => {},173 remove: async () => {},174 copy: async () => {},175 removeRenamed: async () => {},176 repoKeys: async () => [],177 removeRepoKey: async () => {},178 },179 fx.repo,180 id,181 );182 }183 const withPath = (path: string) => (record: Record<string, unknown>) => {184 (record.files as { path: string }[])[0] = { ...(record.files as { path: string }[])[0], path };185 };186187 test("the genuine record is accepted", async () => {188 expect((await readTampered(() => {})).message).toBe("say hello");189 });190191 192 test.each([193 "",194 "/etc/passwd",195 "../escape",196 "src/../../escape",197 "./src/main.ts",198 "src//main.ts",199 "src/",200 "src\\main.ts",201 "src/ma\0in.ts",202 "src/ma\nin.ts",203 "Icon\r",204 "a\u0085b",205 ])("quarantines the file path %p", async (path) => {206 const save = await readTampered(withPath(path));207 expect(save.files.map((file) => file.path)).not.toContain(path);208 expect(save.files.length).toBeGreaterThan(0);209 expect(save.quarantined).toEqual([escapePath(path)]);210 });211212 test("quarantines bytes outside UTF-8 and unpaired surrogate escapes, and keeps a real U+FFFD", async () => {213 const id = fx.saveIds[0] as string;214 const real = await bucket().get(`${fx.repo}/saves/${id}`);215 const record = JSON.parse(new TextDecoder().decode(real as Uint8Array));216 record.files[0].path = "bad-X-name";217 record.files[1].path = "odd-\ufffd-name";218 record.files[2].path = "lead-\ud800";219 record.files[3].path = "pair-\ud83d\ude00";220 const bytes = Buffer.from(JSON.stringify(record));221 bytes[bytes.indexOf("bad-X-name") + 4] = 0xff;222 const save = await readSave(223 {224 get: async () => bytes,225 list: async () => [],226 folders: async () => [],227 size: async () => 0,228 sizes: async () => new Map(),229 downloadUrl: () => "",230 viewUrl: () => null,231 put: async () => {},232 remove: async () => {},233 copy: async () => {},234 removeRenamed: async () => {},235 repoKeys: async () => [],236 removeRepoKey: async () => {},237 },238 fx.repo,239 id,240 );241 expect(save.quarantined.sort()).toEqual(["bad-\\xff-name", "lead-\\xed\\xa0\\x80"]);242 expect(save.files.map((file) => file.path)).toContain("odd-\ufffd-name");243 expect(save.files.map((file) => file.path)).toContain("pair-\ud83d\ude00");244 });245246 test("escapes a name as Koh's escape_path does", () => {247 expect(["Icon\r", "a\\b", "a\u0085", "\x7f", "plain/ok"].map(escapePath)).toEqual([248 "Icon\\x0d",249 "a\\\\b",250 "a\\u0085",251 "\\x7f",252 "plain/ok",253 ]);254 });255256 test("refuses duplicate paths in one record", async () => {257 const dupe = (record: Record<string, unknown>) => {258 const files = record.files as { path: string }[];259 files[1] = { ...files[1], path: (files[0] as { path: string }).path } as { path: string };260 };261 await expect(readTampered(dupe)).rejects.toThrow("Koh 3 schema");262 });263264 test("accepts the six-character IDs that Koh 2's early saves keep through migration", async () => {265 expect((await readTampered((record) => (record.parent = "af96c8"))).parent).toBe("af96c8");266 });267268 test.each([269 ["parent", "not-an-id"],270 ["parent", "af96c"],271 ["parent", "af96c8a"],272 ["restores", "ABCDEF123456"],273 ["replayed_from", "../../../x"],274 ["created_at", -1],275 ["created_at", 1.5],276 ["size_bytes", -10],277 ["size_bytes", "12"],278 ])("refuses %s = %p", async (field, value) => {279 await expect(readTampered((record) => (record[field] = value))).rejects.toThrow("Koh 3 schema");280 });281282 test("refuses bad repo names and save IDs before building a key", async () => {283 const b = bucket();284 for (const repo of ["..", "a/b", "", "../demo"]) {285 await expect(checkFormat(b, repo)).rejects.toThrow("not a repo name");286 await expect(readHead(b, repo)).rejects.toThrow("not a repo name");287 await expect(readSave(b, repo, fx.saveIds[0] as string)).rejects.toThrow("not a repo name");288 await expect(readObject(b, repo, "0".repeat(64))).rejects.toThrow("not a repo name");289 }290 for (const id of ["../head", "abc", "ABCDEF123456", `${fx.saveIds[0]}0`]) {291 await expect(readSave(b, fx.repo, id)).rejects.toThrow("not a save ID");292 }293 });294});295296describe("event records", () => {297 test("reads a genuine koh flag event and refuses a tampered one", async () => {298 const b = bucket();299 const [id] = await listEventIds(b, fx.repo);300 const event = await readEvent(b, fx.repo, id as string);301 expect([event.kind, event.save_id, event.reason]).toEqual([302 "flag",303 fx.saveIds[3] as string,304 "holds a secret",305 ]);306 const stripAll = { kind: "strip", save_id: "", path: "secret.txt" };307 const accepted = new TextEncoder().encode(JSON.stringify({ ...event, ...stripAll }));308 const reader = {309 get: async () => accepted,310 list: async () => [],311 folders: async () => [],312 size: async () => 0,313 sizes: async () => new Map(),314 downloadUrl: () => "",315 viewUrl: () => null,316 put: async () => {},317 remove: async () => {},318 copy: async () => {},319 removeRenamed: async () => {},320 repoKeys: async () => [],321 removeRepoKey: async () => {},322 };323 expect((await readEvent(reader, fx.repo, event.id)).kind).toBe("strip");324 for (const change of [325 { kind: "delete" },326 { path: "../x" },327 { save_id: "nope" },328 { kind: "flag", save_id: "" },329 { kind: "clear", save_id: "" },330 { kind: "flag", path: "secret.txt" },331 { kind: "clear", path: "secret.txt" },332 333 ]) {334 const bytes = new TextEncoder().encode(JSON.stringify({ ...event, ...change }));335 const tampered = {336 get: async () => bytes,337 list: async () => [],338 folders: async () => [],339 size: async () => 0,340 sizes: async () => new Map(),341 downloadUrl: () => "",342 viewUrl: () => null,343 put: async () => {},344 remove: async () => {},345 copy: async () => {},346 removeRenamed: async () => {},347 repoKeys: async () => [],348 removeRepoKey: async () => {},349 };350 await expect(readEvent(tampered, fx.repo, event.id)).rejects.toThrow("Koh 3 schema");351 }352 });353});