repo.ts

5.1 KB · raw

1import { inflateSync } from "node:zlib";2import { blake3 } from "@noble/hashes/blake3.js";3import { bytesToHex } from "@noble/hashes/utils.js";4import type { BucketReader } from "./reader.ts";56export type SaveFile = { path: string; hash: string; executable: boolean };78/** The save record Koh 3 writes to `<repo>/saves/<id>` (§9.2). */9export type SaveRecord = {10	id: string;11	parent: string;12	message: string;13	created_at: number;14	machine: string;15	is_checkpoint: boolean;16	size_bytes: number;17	agent: string;18	session: string;19	restores: string;20	replayed_from: string;21	files: SaveFile[];22};2324export class BucketError extends Error {}2526const SAVE_ID = /^[0-9a-f]{12}$/;27const OBJECT_HASH = /^[0-9a-f]{64}$/;28const REPO_NAME = /^[A-Za-z0-9._-]+$/;29const CONTROL_CHAR = /\p{Cc}/u;30const decoder = new TextDecoder();3132// ponytail: one full listing per call; switch to a delimiter listing if the bucket grows large.33export async function listRepos(bucket: BucketReader): Promise<string[]> {34	const keys = await bucket.list("");35	return keys36		.filter((key) => key.endsWith("/head") && key.split("/").length === 2)37		.map((key) => key.slice(0, -"/head".length))38		.filter(isRepoName);39}4041function isRepoName(name: string): boolean {42	return REPO_NAME.test(name) && name !== "." && name !== "..";43}4445// Checked here rather than in route handlers, so no caller can build a key from unchecked input.46function requireRepo(repo: string): void {47	if (!isRepoName(repo)) throw new BucketError(`not a repo name: "${repo}"`);48}4950function requireSaveId(id: string): void {51	if (!SAVE_ID.test(id)) throw new BucketError(`not a save ID: "${id}"`);52}5354/** Applies §9.2's format rule: a missing key is version 1; anything but `koh-remote 1` is refused. */55export async function checkFormat(bucket: BucketReader, repo: string): Promise<void> {56	requireRepo(repo);57	const bytes = await bucket.get(`${repo}/format`);58	if (bytes === null) return;59	const format = decoder.decode(bytes).trim();60	if (format !== "koh-remote 1") {61		throw new BucketError(`${repo} uses remote format "${format}"; update Kepr to read it`);62	}63}6465export async function readHead(bucket: BucketReader, repo: string): Promise<string | null> {66	requireRepo(repo);67	const bytes = await bucket.get(`${repo}/head`);68	if (bytes === null) return null;69	const head = decoder.decode(bytes).trim();70	if (!SAVE_ID.test(head)) throw new BucketError(`${repo}/head is not a save ID: "${head}"`);71	return head;72}7374export async function readSave(75	bucket: BucketReader,76	repo: string,77	id: string,78): Promise<SaveRecord> {79	requireRepo(repo);80	requireSaveId(id);81	const bytes = await bucket.get(`${repo}/saves/${id}`);82	if (bytes === null) throw new BucketError(`${repo}/saves/${id} is missing`);83	const record = parseSaveRecord(JSON.parse(decoder.decode(bytes)));84	if (record.id !== id) throw new BucketError(`${repo}/saves/${id} holds save ${record.id}`);85	return record;86}8788/** Fetches an object, inflates it and checks its Blake3 against its key, as steal does (§9.4). */89export async function readObject(90	bucket: BucketReader,91	repo: string,92	hash: string,93): Promise<Uint8Array> {94	requireRepo(repo);95	if (!OBJECT_HASH.test(hash)) throw new BucketError(`not an object hash: "${hash}"`);96	const compressed = await bucket.get(`${repo}/objects/${hash}`);97	if (compressed === null) throw new BucketError(`${repo}/objects/${hash} is missing`);98	const bytes = new Uint8Array(inflateSync(compressed));99	if (bytesToHex(blake3(bytes)) !== hash) {100		throw new BucketError(`${repo}/objects/${hash} does not match its hash`);101	}102	return bytes;103}104105function parseSaveRecord(value: unknown): SaveRecord {106	const r = value as Record<string, unknown>;107	const valid =108		typeof value === "object" &&109		value !== null &&110		["message", "machine", "agent", "session"].every((key) => typeof r[key] === "string") &&111		typeof r.id === "string" &&112		SAVE_ID.test(r.id) &&113		["parent", "restores", "replayed_from"].every((key) => isOptionalSaveId(r[key])) &&114		isCount(r.created_at) &&115		isCount(r.size_bytes) &&116		typeof r.is_checkpoint === "boolean" &&117		Array.isArray(r.files) &&118		r.files.every(isSaveFile) &&119		new Set(r.files.map((file) => file.path)).size === r.files.length;120	if (!valid) throw new BucketError("save record does not match the Koh 3 schema");121	return value as SaveRecord;122}123124function isOptionalSaveId(value: unknown): boolean {125	return value === "" || (typeof value === "string" && SAVE_ID.test(value));126}127128function isCount(value: unknown): boolean {129	return Number.isSafeInteger(value) && (value as number) >= 0;130}131132/** A repo-relative path that can't escape or alias: no empty, `.` or `..` segments, no backslashes or control characters. */133function isSafePath(path: string): boolean {134	return (135		!path.includes("\\") &&136		!CONTROL_CHAR.test(path) &&137		path.split("/").every((segment) => segment !== "" && segment !== "." && segment !== "..")138	);139}140141function isSaveFile(value: unknown): value is SaveFile {142	const f = value as Record<string, unknown>;143	return (144		typeof value === "object" &&145		value !== null &&146		typeof f.path === "string" &&147		isSafePath(f.path) &&148		typeof f.hash === "string" &&149		OBJECT_HASH.test(f.hash) &&150		typeof f.executable === "boolean"151	);152}