bucket.test.ts

12.3 KB · raw

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		// As a rename leaves the new name until it copies `head`, last.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		// koh writes `<repo>/format` on its first offer (§9.2), so it is present;111		// a key that is not there is what reads as null.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	// Starts from a genuine koh record and corrupts one field, as a tampered bucket would.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	// §5.5: a path Koh would refuse is quarantined, and the rest of the save still lands.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			// A strip with an illegal path, "" included, parses; sync ignores it (sync.test.ts).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});