bucket.test.ts

6.7 KB · raw

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 { checkFormat, listRepos, readHead, readObject, readSave } from "../src/bucket/repo.ts";6import { type Fixture, makeKohBucket } from "./fixture.ts";78let fx: Fixture;9beforeAll(async () => {10	fx = await makeKohBucket();11});1213const bucket = () => openBucket({ kind: "folder", path: fx.bucketDir });1415describe("reading a koh3-produced folder remote", () => {16	test("lists the repo", async () => {17		expect(await listRepos(bucket())).toEqual([fx.repo]);18	});1920	test("reads format, head, the save record and one verified object end to end", async () => {21		const b = bucket();22		await checkFormat(b, fx.repo);23		const head = await readHead(b, fx.repo);24		expect(head).toBe(fx.saveIds[0] as string);2526		const save = await readSave(b, fx.repo, head as string);27		expect(save.message).toBe("say hello");28		expect(save.is_checkpoint).toBe(false);29		const checkpoint = await readSave(b, fx.repo, save.parent);30		expect(checkpoint.is_checkpoint).toBe(true);31		const first = await readSave(b, fx.repo, checkpoint.parent);32		expect([first.agent, first.session]).toEqual(["test-agent", "fixture"]);3334		const main = save.files.find((file) => file.path === "src/main.ts");35		const bytes = await readObject(b, fx.repo, main?.hash as string);36		expect(new TextDecoder().decode(bytes)).toBe("// Entry point.\nconsole.log('hello');\n");37	});3839	test("a missing key reads as null and a listing is prefix-scoped", async () => {40		const b = bucket();41		expect(await b.get(`${fx.repo}/nothing-here`)).toBeNull();42		const saves = await b.list(`${fx.repo}/saves/`);43		expect(saves.sort()).toEqual(fx.saveIds.map((id) => `${fx.repo}/saves/${id}`).sort());44	});45});4647test("the S3 reader pages through listings and reads a missing key as null", async () => {48	const pageSize = 2;49	const server = Bun.serve({50		port: 0,51		async fetch(req) {52			const url = new URL(req.url);53			const folder = bucket();54			if (url.searchParams.get("list-type") === "2") {55				const all = await folder.list(url.searchParams.get("prefix") ?? "");56				const start = Number(url.searchParams.get("continuation-token") ?? 0);57				const page = all.slice(start, start + pageSize);58				const more = start + pageSize < all.length;59				const contents = page.map((key) => `<Contents><Key>${key}</Key></Contents>`).join("");60				const next = more61					? `<NextContinuationToken>${start + pageSize}</NextContinuationToken>`62					: "";63				return new Response(64					`<ListBucketResult><IsTruncated>${more}</IsTruncated>${contents}${next}</ListBucketResult>`,65				);66			}67			const bytes = await folder.get(url.pathname.replace("/kohbucket/", ""));68			if (bytes) return new Response(bytes);69			return new Response("<Error><Code>NoSuchKey</Code></Error>", { status: 404 });70		},71	});72	try {73		const s3 = openBucket({74			kind: "s3",75			endpoint: `http://localhost:${server.port}`,76			name: "kohbucket",77			region: "fr-par",78			accessKeyId: "read",79			secretAccessKey: "only",80		});81		expect(await s3.list(`${fx.repo}/`)).toEqual(await bucket().list(`${fx.repo}/`));82		expect(await readHead(s3, fx.repo)).toBe(fx.saveIds[0] as string);83		expect(await s3.get(`${fx.repo}/format`)).toBeNull();84	} finally {85		server.stop();86	}87});8889describe("the format rule", () => {90	test("accepts koh-remote 1 and refuses anything else", async () => {91		const { bucketDir, repo } = await makeKohBucket("formatted");92		const b = openBucket({ kind: "folder", path: bucketDir });93		await writeFile(join(bucketDir, repo, "format"), "koh-remote 1");94		await checkFormat(b, repo);95		await writeFile(join(bucketDir, repo, "format"), "koh-remote 2");96		await expect(checkFormat(b, repo)).rejects.toThrow('remote format "koh-remote 2"');97	});98});99100describe("object verification", () => {101	test("refuses an object whose content does not match its key", async () => {102		const b = bucket();103		const save = await readSave(b, fx.repo, fx.saveIds[0] as string);104		const [one, two] = save.files;105		const swapped = {106			...b,107			get: (key: string) => b.get(key.replace(one?.hash ?? "", two?.hash ?? "")),108		};109		await expect(readObject(swapped, fx.repo, one?.hash as string)).rejects.toThrow(110			"does not match its hash",111		);112	});113});114115describe("untrusted input", () => {116	// Starts from a genuine koh3 record and corrupts one field, as a tampered bucket would.117	async function readTampered(change: (record: Record<string, unknown>) => void) {118		const id = fx.saveIds[0] as string;119		const real = await bucket().get(`${fx.repo}/saves/${id}`);120		const record = JSON.parse(new TextDecoder().decode(real as Uint8Array));121		change(record);122		const bytes = new TextEncoder().encode(JSON.stringify(record));123		return readSave({ get: async () => bytes, list: async () => [] }, fx.repo, id);124	}125	const withPath = (path: string) => (record: Record<string, unknown>) => {126		(record.files as { path: string }[])[0] = { ...(record.files as { path: string }[])[0], path };127	};128129	test("the genuine record is accepted", async () => {130		expect((await readTampered(() => {})).message).toBe("say hello");131	});132133	test.each([134		"",135		"/etc/passwd",136		"../escape",137		"src/../../escape",138		"./src/main.ts",139		"src//main.ts",140		"src/",141		"src\\main.ts",142		"src/ma\0in.ts",143		"src/ma\nin.ts",144	])("refuses the file path %p", async (path) => {145		await expect(readTampered(withPath(path))).rejects.toThrow("Koh 3 schema");146	});147148	test("refuses duplicate paths in one record", async () => {149		const dupe = (record: Record<string, unknown>) => {150			const files = record.files as { path: string }[];151			files[1] = { ...files[1], path: (files[0] as { path: string }).path } as { path: string };152		};153		await expect(readTampered(dupe)).rejects.toThrow("Koh 3 schema");154	});155156	test.each([157		["parent", "not-an-id"],158		["restores", "ABCDEF123456"],159		["replayed_from", "../../../x"],160		["created_at", -1],161		["created_at", 1.5],162		["size_bytes", -10],163		["size_bytes", "12"],164	])("refuses %s = %p", async (field, value) => {165		await expect(readTampered((record) => (record[field] = value))).rejects.toThrow("Koh 3 schema");166	});167168	test("refuses bad repo names and save IDs before building a key", async () => {169		const b = bucket();170		for (const repo of ["..", "a/b", "", "../demo"]) {171			await expect(checkFormat(b, repo)).rejects.toThrow("not a repo name");172			await expect(readHead(b, repo)).rejects.toThrow("not a repo name");173			await expect(readSave(b, repo, fx.saveIds[0] as string)).rejects.toThrow("not a repo name");174			await expect(readObject(b, repo, "0".repeat(64))).rejects.toThrow("not a repo name");175		}176		for (const id of ["../head", "abc", "ABCDEF123456", `${fx.saveIds[0]}0`]) {177			await expect(readSave(b, fx.repo, id)).rejects.toThrow("not a save ID");178		}179	});180});