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,
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 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 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);
// koh3 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 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 () => [],
sizes: async () => new Map(),
downloadUrl: () => "",
viewUrl: () => null,
put: async () => {},
remove: 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 () => [],
sizes: async () => new Map(),
downloadUrl: () => "",
viewUrl: () => null,
put: async () => {},
remove: 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 koh3 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 () => [],
sizes: async () => new Map(),
downloadUrl: () => "",
viewUrl: () => null,
put: async () => {},
remove: 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 () => [],
sizes: async () => new Map(),
downloadUrl: () => "",
viewUrl: () => null,
put: async () => {},
remove: async () => {},
};
await expect(readEvent(tampered, fx.repo, event.id)).rejects.toThrow("Koh 3 schema");
}
});
});