bucket.test.ts

10.5 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	escapePath,8	listEventIds,9	listRepos,10	readEvent,11	readHead,12	readObject,13	readSave,14} from "../src/bucket/repo.ts";15import { type Fixture, makeKohBucket } from "./fixture.ts";1617let fx: Fixture;18beforeAll(async () => {19	fx = await makeKohBucket();20});2122const bucket = () => openBucket({ kind: "folder", path: fx.bucketDir });2324describe("reading a koh3-produced folder remote", () => {25	test("lists the repo", async () => {26		expect(await listRepos(bucket())).toEqual([fx.repo]);27	});2829	test("reads format, head, the save record and one verified object end to end", async () => {30		const b = bucket();31		await checkFormat(b, fx.repo);32		const head = await readHead(b, fx.repo);33		expect(head).toBe(fx.saveIds[0] as string);3435		const save = await readSave(b, fx.repo, head as string);36		expect(save.message).toBe("say hello");37		expect(save.is_checkpoint).toBe(false);38		const checkpoint = await readSave(b, fx.repo, save.parent);39		expect(checkpoint.is_checkpoint).toBe(true);40		const first = await readSave(b, fx.repo, checkpoint.parent);41		expect([first.agent, first.session]).toEqual(["test-agent", "fixture"]);4243		const main = save.files.find((file) => file.path === "src/main.ts");44		const { bytes } = await readObject(b, fx.repo, main?.hash as string);45		expect(new TextDecoder().decode(bytes)).toBe("// Entry point.\nconsole.log('hello');\n");46	});4748	test("a missing key reads as null and a listing is prefix-scoped", async () => {49		const b = bucket();50		expect(await b.get(`${fx.repo}/nothing-here`)).toBeNull();51		const saves = await b.list(`${fx.repo}/saves/`);52		expect(saves.sort()).toEqual(fx.saveIds.map((id) => `${fx.repo}/saves/${id}`).sort());53	});54});5556test("the S3 reader pages through listings and reads a missing key as null", async () => {57	const pageSize = 2;58	const server = Bun.serve({59		port: 0,60		async fetch(req) {61			const url = new URL(req.url);62			const folder = bucket();63			if (url.searchParams.get("list-type") === "2") {64				const all = await folder.list(url.searchParams.get("prefix") ?? "");65				const start = Number(url.searchParams.get("continuation-token") ?? 0);66				const page = all.slice(start, start + pageSize);67				const more = start + pageSize < all.length;68				const contents = page.map((key) => `<Contents><Key>${key}</Key></Contents>`).join("");69				const next = more70					? `<NextContinuationToken>${start + pageSize}</NextContinuationToken>`71					: "";72				return new Response(73					`<ListBucketResult><IsTruncated>${more}</IsTruncated>${contents}${next}</ListBucketResult>`,74				);75			}76			const bytes = await folder.get(url.pathname.replace("/kohbucket/", ""));77			if (bytes) return new Response(bytes);78			return new Response("<Error><Code>NoSuchKey</Code></Error>", { status: 404 });79		},80	});81	try {82		const s3 = openBucket({83			kind: "s3",84			endpoint: `http://localhost:${server.port}`,85			name: "kohbucket",86			region: "fr-par",87			accessKeyId: "read",88			secretAccessKey: "only",89		});90		expect(await s3.list(`${fx.repo}/`)).toEqual(await bucket().list(`${fx.repo}/`));91		expect(await readHead(s3, fx.repo)).toBe(fx.saveIds[0] as string);92		// koh3 writes `<repo>/format` on its first offer (§9.2), so it is present;93		// a key that is not there is what reads as null.94		const format = await s3.get(`${fx.repo}/format`);95		expect(format === null ? null : new TextDecoder().decode(format)).toBe("koh-remote 1");96		expect(await s3.get(`${fx.repo}/no-such-key`)).toBeNull();97		const url = new URL(98			s3.downloadUrl("demo/releases/1.0/android-arm64/a b.apk", "a b.apk", "t/x"),99		);100		expect(url.pathname).toBe("/kohbucket/demo/releases/1.0/android-arm64/a%20b.apk");101		expect(url.searchParams.get("X-Amz-Expires")).toBe("300");102		expect(url.searchParams.get("response-content-disposition")).toBe(103			'attachment; filename="a b.apk"',104		);105		expect(url.searchParams.get("response-content-type")).toBe("t/x");106	} finally {107		server.stop();108	}109});110111describe("the format rule", () => {112	test("accepts koh-remote 1 and refuses anything else", async () => {113		const { bucketDir, repo } = await makeKohBucket("formatted");114		const b = openBucket({ kind: "folder", path: bucketDir });115		await writeFile(join(bucketDir, repo, "format"), "koh-remote 1");116		await checkFormat(b, repo);117		await writeFile(join(bucketDir, repo, "format"), "koh-remote 2");118		await expect(checkFormat(b, repo)).rejects.toThrow('remote format "koh-remote 2"');119	});120});121122describe("object verification", () => {123	test("refuses an object whose content does not match its key", async () => {124		const b = bucket();125		const save = await readSave(b, fx.repo, fx.saveIds[0] as string);126		const [one, two] = save.files;127		const swapped = {128			...b,129			get: (key: string) => b.get(key.replace(one?.hash ?? "", two?.hash ?? "")),130		};131		await expect(readObject(swapped, fx.repo, one?.hash as string)).rejects.toThrow(132			"does not match its hash",133		);134	});135});136137describe("untrusted input", () => {138	// Starts from a genuine koh3 record and corrupts one field, as a tampered bucket would.139	async function readTampered(change: (record: Record<string, unknown>) => void) {140		const id = fx.saveIds[0] as string;141		const real = await bucket().get(`${fx.repo}/saves/${id}`);142		const record = JSON.parse(new TextDecoder().decode(real as Uint8Array));143		change(record);144		const bytes = new TextEncoder().encode(JSON.stringify(record));145		return readSave(146			{147				get: async () => bytes,148				list: async () => [],149				downloadUrl: () => "",150				viewUrl: () => null,151				put: async () => {},152				remove: async () => {},153			},154			fx.repo,155			id,156		);157	}158	const withPath = (path: string) => (record: Record<string, unknown>) => {159		(record.files as { path: string }[])[0] = { ...(record.files as { path: string }[])[0], path };160	};161162	test("the genuine record is accepted", async () => {163		expect((await readTampered(() => {})).message).toBe("say hello");164	});165166	// §5.5: a path Koh would refuse is quarantined, and the rest of the save still lands.167	test.each([168		"",169		"/etc/passwd",170		"../escape",171		"src/../../escape",172		"./src/main.ts",173		"src//main.ts",174		"src/",175		"src\\main.ts",176		"src/ma\0in.ts",177		"src/ma\nin.ts",178		"Icon\r",179		"a\u0085b",180	])("quarantines the file path %p", async (path) => {181		const save = await readTampered(withPath(path));182		expect(save.files.map((file) => file.path)).not.toContain(path);183		expect(save.files.length).toBeGreaterThan(0);184		expect(save.quarantined).toEqual([escapePath(path)]);185	});186187	test("quarantines bytes outside UTF-8 and unpaired surrogate escapes, and keeps a real U+FFFD", async () => {188		const id = fx.saveIds[0] as string;189		const real = await bucket().get(`${fx.repo}/saves/${id}`);190		const record = JSON.parse(new TextDecoder().decode(real as Uint8Array));191		record.files[0].path = "bad-X-name";192		record.files[1].path = "odd-\ufffd-name";193		record.files[2].path = "lead-\ud800";194		record.files[3].path = "pair-\ud83d\ude00";195		const bytes = Buffer.from(JSON.stringify(record));196		bytes[bytes.indexOf("bad-X-name") + 4] = 0xff;197		const save = await readSave(198			{199				get: async () => bytes,200				list: async () => [],201				downloadUrl: () => "",202				viewUrl: () => null,203				put: async () => {},204				remove: async () => {},205			},206			fx.repo,207			id,208		);209		expect(save.quarantined.sort()).toEqual(["bad-\\xff-name", "lead-\\xed\\xa0\\x80"]);210		expect(save.files.map((file) => file.path)).toContain("odd-\ufffd-name");211		expect(save.files.map((file) => file.path)).toContain("pair-\ud83d\ude00");212	});213214	test("escapes a name as Koh's escape_path does", () => {215		expect(["Icon\r", "a\\b", "a\u0085", "\x7f", "plain/ok"].map(escapePath)).toEqual([216			"Icon\\x0d",217			"a\\\\b",218			"a\\u0085",219			"\\x7f",220			"plain/ok",221		]);222	});223224	test("refuses duplicate paths in one record", async () => {225		const dupe = (record: Record<string, unknown>) => {226			const files = record.files as { path: string }[];227			files[1] = { ...files[1], path: (files[0] as { path: string }).path } as { path: string };228		};229		await expect(readTampered(dupe)).rejects.toThrow("Koh 3 schema");230	});231232	test.each([233		["parent", "not-an-id"],234		["restores", "ABCDEF123456"],235		["replayed_from", "../../../x"],236		["created_at", -1],237		["created_at", 1.5],238		["size_bytes", -10],239		["size_bytes", "12"],240	])("refuses %s = %p", async (field, value) => {241		await expect(readTampered((record) => (record[field] = value))).rejects.toThrow("Koh 3 schema");242	});243244	test("refuses bad repo names and save IDs before building a key", async () => {245		const b = bucket();246		for (const repo of ["..", "a/b", "", "../demo"]) {247			await expect(checkFormat(b, repo)).rejects.toThrow("not a repo name");248			await expect(readHead(b, repo)).rejects.toThrow("not a repo name");249			await expect(readSave(b, repo, fx.saveIds[0] as string)).rejects.toThrow("not a repo name");250			await expect(readObject(b, repo, "0".repeat(64))).rejects.toThrow("not a repo name");251		}252		for (const id of ["../head", "abc", "ABCDEF123456", `${fx.saveIds[0]}0`]) {253			await expect(readSave(b, fx.repo, id)).rejects.toThrow("not a save ID");254		}255	});256});257258describe("event records", () => {259	test("reads a genuine koh3 flag event and refuses a tampered one", async () => {260		const b = bucket();261		const [id] = await listEventIds(b, fx.repo);262		const event = await readEvent(b, fx.repo, id as string);263		expect([event.kind, event.save_id, event.reason]).toEqual([264			"flag",265			fx.saveIds[3] as string,266			"holds a secret",267		]);268		const stripAll = { kind: "strip", save_id: "", path: "secret.txt" };269		const accepted = new TextEncoder().encode(JSON.stringify({ ...event, ...stripAll }));270		const reader = {271			get: async () => accepted,272			list: async () => [],273			downloadUrl: () => "",274			viewUrl: () => null,275			put: async () => {},276			remove: async () => {},277		};278		expect((await readEvent(reader, fx.repo, event.id)).kind).toBe("strip");279		for (const change of [280			{ kind: "delete" },281			{ path: "../x" },282			{ save_id: "nope" },283			{ kind: "flag", save_id: "" },284			{ kind: "clear", save_id: "" },285			{ kind: "flag", path: "secret.txt" },286			{ kind: "clear", path: "secret.txt" },287			// A strip with an illegal path, "" included, parses; sync ignores it (sync.test.ts).288		]) {289			const bytes = new TextEncoder().encode(JSON.stringify({ ...event, ...change }));290			const tampered = {291				get: async () => bytes,292				list: async () => [],293				downloadUrl: () => "",294				viewUrl: () => null,295				put: async () => {},296				remove: async () => {},297			};298			await expect(readEvent(tampered, fx.repo, event.id)).rejects.toThrow("Koh 3 schema");299		}300	});301});