bucket.test.ts

8.4 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 {6	checkFormat,7	listEventIds,8	listRepos,9	readEvent,10	readHead,11	readObject,12	readSave,13} from "../src/bucket/repo.ts";14import { type Fixture, makeKohBucket } from "./fixture.ts";1516let fx: Fixture;17beforeAll(async () => {18	fx = await makeKohBucket();19});2021const bucket = () => openBucket({ kind: "folder", path: fx.bucketDir });2223describe("reading a koh3-produced folder remote", () => {24	test("lists the repo", async () => {25		expect(await listRepos(bucket())).toEqual([fx.repo]);26	});2728	test("reads format, head, the save record and one verified object end to end", async () => {29		const b = bucket();30		await checkFormat(b, fx.repo);31		const head = await readHead(b, fx.repo);32		expect(head).toBe(fx.saveIds[0] as string);3334		const save = await readSave(b, fx.repo, head as string);35		expect(save.message).toBe("say hello");36		expect(save.is_checkpoint).toBe(false);37		const checkpoint = await readSave(b, fx.repo, save.parent);38		expect(checkpoint.is_checkpoint).toBe(true);39		const first = await readSave(b, fx.repo, checkpoint.parent);40		expect([first.agent, first.session]).toEqual(["test-agent", "fixture"]);4142		const main = save.files.find((file) => file.path === "src/main.ts");43		const { bytes } = await readObject(b, fx.repo, main?.hash as string);44		expect(new TextDecoder().decode(bytes)).toBe("// Entry point.\nconsole.log('hello');\n");45	});4647	test("a missing key reads as null and a listing is prefix-scoped", async () => {48		const b = bucket();49		expect(await b.get(`${fx.repo}/nothing-here`)).toBeNull();50		const saves = await b.list(`${fx.repo}/saves/`);51		expect(saves.sort()).toEqual(fx.saveIds.map((id) => `${fx.repo}/saves/${id}`).sort());52	});53});5455test("the S3 reader pages through listings and reads a missing key as null", async () => {56	const pageSize = 2;57	const server = Bun.serve({58		port: 0,59		async fetch(req) {60			const url = new URL(req.url);61			const folder = bucket();62			if (url.searchParams.get("list-type") === "2") {63				const all = await folder.list(url.searchParams.get("prefix") ?? "");64				const start = Number(url.searchParams.get("continuation-token") ?? 0);65				const page = all.slice(start, start + pageSize);66				const more = start + pageSize < all.length;67				const contents = page.map((key) => `<Contents><Key>${key}</Key></Contents>`).join("");68				const next = more69					? `<NextContinuationToken>${start + pageSize}</NextContinuationToken>`70					: "";71				return new Response(72					`<ListBucketResult><IsTruncated>${more}</IsTruncated>${contents}${next}</ListBucketResult>`,73				);74			}75			const bytes = await folder.get(url.pathname.replace("/kohbucket/", ""));76			if (bytes) return new Response(bytes);77			return new Response("<Error><Code>NoSuchKey</Code></Error>", { status: 404 });78		},79	});80	try {81		const s3 = openBucket({82			kind: "s3",83			endpoint: `http://localhost:${server.port}`,84			name: "kohbucket",85			region: "fr-par",86			accessKeyId: "read",87			secretAccessKey: "only",88		});89		expect(await s3.list(`${fx.repo}/`)).toEqual(await bucket().list(`${fx.repo}/`));90		expect(await readHead(s3, fx.repo)).toBe(fx.saveIds[0] as string);91		expect(await s3.get(`${fx.repo}/format`)).toBeNull();92		const url = new URL(93			s3.downloadUrl("demo/releases/1.0/android-arm64/a b.apk", "a b.apk", "t/x"),94		);95		expect(url.pathname).toBe("/kohbucket/demo/releases/1.0/android-arm64/a%20b.apk");96		expect(url.searchParams.get("X-Amz-Expires")).toBe("300");97		expect(url.searchParams.get("response-content-disposition")).toBe(98			'attachment; filename="a b.apk"',99		);100		expect(url.searchParams.get("response-content-type")).toBe("t/x");101	} finally {102		server.stop();103	}104});105106describe("the format rule", () => {107	test("accepts koh-remote 1 and refuses anything else", async () => {108		const { bucketDir, repo } = await makeKohBucket("formatted");109		const b = openBucket({ kind: "folder", path: bucketDir });110		await writeFile(join(bucketDir, repo, "format"), "koh-remote 1");111		await checkFormat(b, repo);112		await writeFile(join(bucketDir, repo, "format"), "koh-remote 2");113		await expect(checkFormat(b, repo)).rejects.toThrow('remote format "koh-remote 2"');114	});115});116117describe("object verification", () => {118	test("refuses an object whose content does not match its key", async () => {119		const b = bucket();120		const save = await readSave(b, fx.repo, fx.saveIds[0] as string);121		const [one, two] = save.files;122		const swapped = {123			...b,124			get: (key: string) => b.get(key.replace(one?.hash ?? "", two?.hash ?? "")),125		};126		await expect(readObject(swapped, fx.repo, one?.hash as string)).rejects.toThrow(127			"does not match its hash",128		);129	});130});131132describe("untrusted input", () => {133	// Starts from a genuine koh3 record and corrupts one field, as a tampered bucket would.134	async function readTampered(change: (record: Record<string, unknown>) => void) {135		const id = fx.saveIds[0] as string;136		const real = await bucket().get(`${fx.repo}/saves/${id}`);137		const record = JSON.parse(new TextDecoder().decode(real as Uint8Array));138		change(record);139		const bytes = new TextEncoder().encode(JSON.stringify(record));140		return readSave(141			{ get: async () => bytes, list: async () => [], downloadUrl: () => "" },142			fx.repo,143			id,144		);145	}146	const withPath = (path: string) => (record: Record<string, unknown>) => {147		(record.files as { path: string }[])[0] = { ...(record.files as { path: string }[])[0], path };148	};149150	test("the genuine record is accepted", async () => {151		expect((await readTampered(() => {})).message).toBe("say hello");152	});153154	test.each([155		"",156		"/etc/passwd",157		"../escape",158		"src/../../escape",159		"./src/main.ts",160		"src//main.ts",161		"src/",162		"src\\main.ts",163		"src/ma\0in.ts",164		"src/ma\nin.ts",165	])("refuses the file path %p", async (path) => {166		await expect(readTampered(withPath(path))).rejects.toThrow("Koh 3 schema");167	});168169	test("refuses duplicate paths in one record", async () => {170		const dupe = (record: Record<string, unknown>) => {171			const files = record.files as { path: string }[];172			files[1] = { ...files[1], path: (files[0] as { path: string }).path } as { path: string };173		};174		await expect(readTampered(dupe)).rejects.toThrow("Koh 3 schema");175	});176177	test.each([178		["parent", "not-an-id"],179		["restores", "ABCDEF123456"],180		["replayed_from", "../../../x"],181		["created_at", -1],182		["created_at", 1.5],183		["size_bytes", -10],184		["size_bytes", "12"],185	])("refuses %s = %p", async (field, value) => {186		await expect(readTampered((record) => (record[field] = value))).rejects.toThrow("Koh 3 schema");187	});188189	test("refuses bad repo names and save IDs before building a key", async () => {190		const b = bucket();191		for (const repo of ["..", "a/b", "", "../demo"]) {192			await expect(checkFormat(b, repo)).rejects.toThrow("not a repo name");193			await expect(readHead(b, repo)).rejects.toThrow("not a repo name");194			await expect(readSave(b, repo, fx.saveIds[0] as string)).rejects.toThrow("not a repo name");195			await expect(readObject(b, repo, "0".repeat(64))).rejects.toThrow("not a repo name");196		}197		for (const id of ["../head", "abc", "ABCDEF123456", `${fx.saveIds[0]}0`]) {198			await expect(readSave(b, fx.repo, id)).rejects.toThrow("not a save ID");199		}200	});201});202203describe("event records", () => {204	test("reads a genuine koh3 flag event and refuses a tampered one", async () => {205		const b = bucket();206		const [id] = await listEventIds(b, fx.repo);207		const event = await readEvent(b, fx.repo, id as string);208		expect([event.kind, event.save_id, event.reason]).toEqual([209			"flag",210			fx.saveIds[3] as string,211			"holds a secret",212		]);213		const stripAll = { kind: "strip", save_id: "", path: "secret.txt" };214		const accepted = new TextEncoder().encode(JSON.stringify({ ...event, ...stripAll }));215		const reader = { get: async () => accepted, list: async () => [], downloadUrl: () => "" };216		expect((await readEvent(reader, fx.repo, event.id)).kind).toBe("strip");217		for (const change of [218			{ kind: "delete" },219			{ path: "../x" },220			{ save_id: "nope" },221			{ kind: "flag", save_id: "" },222			{ kind: "clear", save_id: "" },223			{ kind: "strip", path: "" },224			{ kind: "strip", save_id: "", path: "" },225		]) {226			const bytes = new TextEncoder().encode(JSON.stringify({ ...event, ...change }));227			const tampered = { get: async () => bytes, list: async () => [], downloadUrl: () => "" };228			await expect(readEvent(tampered, fx.repo, event.id)).rejects.toThrow("Koh 3 schema");229		}230	});231});