repo.ts

7.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};2324/** A flag, clear or strip event, as Koh 3 writes it to `<repo>/events/<id>` (§9.2, §10). */25export type EventRecord = {26	id: string;27	kind: "flag" | "clear" | "strip";28	save_id: string;29	path: string;30	reason: string;31	created_at: number;32	machine: string;33};3435/** An object's stored zlib bytes and the inflated bytes they were verified from. */36export type VerifiedObject = { compressed: Uint8Array; bytes: Uint8Array };3738export class BucketError extends Error {}3940const SAVE_ID = /^[0-9a-f]{12}$/;41const OBJECT_HASH = /^[0-9a-f]{64}$/;42const REPO_NAME = /^[A-Za-z0-9._-]+$/;43const CONTROL_CHAR = /\p{Cc}/u;44const decoder = new TextDecoder();4546// ponytail: one full listing per call; switch to a delimiter listing if the bucket grows large.47export async function listRepos(bucket: BucketReader): Promise<string[]> {48	const keys = await bucket.list("");49	return keys50		.filter((key) => key.endsWith("/head") && key.split("/").length === 2)51		.map((key) => key.slice(0, -"/head".length))52		.filter(isRepoName);53}5455function isRepoName(name: string): boolean {56	return REPO_NAME.test(name) && name !== "." && name !== "..";57}5859// Checked here rather than in route handlers, so no caller can build a key from unchecked input.60function requireRepo(repo: string): void {61	if (!isRepoName(repo)) throw new BucketError(`not a repo name: "${repo}"`);62}6364function requireSaveId(id: string): void {65	if (!SAVE_ID.test(id)) throw new BucketError(`not a save ID: "${id}"`);66}6768/** Applies §9.2's format rule: a missing key is version 1; anything but `koh-remote 1` is refused. */69export async function checkFormat(bucket: BucketReader, repo: string): Promise<void> {70	requireRepo(repo);71	const bytes = await bucket.get(`${repo}/format`);72	if (bytes === null) return;73	const format = decoder.decode(bytes).trim();74	if (format !== "koh-remote 1") {75		throw new BucketError(`${repo} uses remote format "${format}"; update Kepr to read it`);76	}77}7879export async function readHead(bucket: BucketReader, repo: string): Promise<string | null> {80	requireRepo(repo);81	const bytes = await bucket.get(`${repo}/head`);82	if (bytes === null) return null;83	const head = decoder.decode(bytes).trim();84	if (!SAVE_ID.test(head)) throw new BucketError(`${repo}/head is not a save ID: "${head}"`);85	return head;86}8788export async function readSave(89	bucket: BucketReader,90	repo: string,91	id: string,92): Promise<SaveRecord> {93	requireRepo(repo);94	requireSaveId(id);95	const bytes = await bucket.get(`${repo}/saves/${id}`);96	if (bytes === null) throw new BucketError(`${repo}/saves/${id} is missing`);97	const record = parseSaveRecord(JSON.parse(decoder.decode(bytes)));98	if (record.id !== id) throw new BucketError(`${repo}/saves/${id} holds save ${record.id}`);99	return record;100}101102/** Fetches an object, inflates it and checks its Blake3 against its key, as steal does (§9.4). */103export async function readObject(104	bucket: BucketReader,105	repo: string,106	hash: string,107): Promise<VerifiedObject> {108	requireRepo(repo);109	if (!OBJECT_HASH.test(hash)) throw new BucketError(`not an object hash: "${hash}"`);110	const compressed = await bucket.get(`${repo}/objects/${hash}`);111	if (compressed === null) throw new BucketError(`${repo}/objects/${hash} is missing`);112	const bytes = new Uint8Array(inflateSync(compressed));113	if (bytesToHex(blake3(bytes)) !== hash) {114		throw new BucketError(`${repo}/objects/${hash} does not match its hash`);115	}116	return { compressed, bytes };117}118119export async function listEventIds(bucket: BucketReader, repo: string): Promise<string[]> {120	requireRepo(repo);121	const prefix = `${repo}/events/`;122	// A folder remote's half-written temp files can show up in a listing; only IDs are events.123	return (await bucket.list(prefix))124		.map((key) => key.slice(prefix.length))125		.filter((id) => SAVE_ID.test(id));126}127128export async function readEvent(129	bucket: BucketReader,130	repo: string,131	id: string,132): Promise<EventRecord> {133	requireRepo(repo);134	requireSaveId(id);135	const bytes = await bucket.get(`${repo}/events/${id}`);136	if (bytes === null) throw new BucketError(`${repo}/events/${id} is missing`);137	const record = parseEventRecord(JSON.parse(decoder.decode(bytes)));138	if (record.id !== id) throw new BucketError(`${repo}/events/${id} holds event ${record.id}`);139	return record;140}141142function parseSaveRecord(value: unknown): SaveRecord {143	const r = value as Record<string, unknown>;144	const valid =145		typeof value === "object" &&146		value !== null &&147		["message", "machine", "agent", "session"].every((key) => typeof r[key] === "string") &&148		typeof r.id === "string" &&149		SAVE_ID.test(r.id) &&150		["parent", "restores", "replayed_from"].every((key) => isOptionalSaveId(r[key])) &&151		isCount(r.created_at) &&152		isCount(r.size_bytes) &&153		typeof r.is_checkpoint === "boolean" &&154		Array.isArray(r.files) &&155		r.files.every(isSaveFile) &&156		new Set(r.files.map((file) => file.path)).size === r.files.length;157	if (!valid) throw new BucketError("save record does not match the Koh 3 schema");158	return value as SaveRecord;159}160161function parseEventRecord(value: unknown): EventRecord {162	const r = value as Record<string, unknown>;163	const valid =164		typeof value === "object" &&165		value !== null &&166		typeof r.id === "string" &&167		SAVE_ID.test(r.id) &&168		isOptionalSaveId(r.save_id) &&169		(r.path === "" || (typeof r.path === "string" && isSafePath(r.path))) &&170		// A flag or clear is about one save; a strip names a path, and one save or (with no ID) all.171		(r.kind === "strip"172			? r.path !== ""173			: (r.kind === "flag" || r.kind === "clear") && r.save_id !== "") &&174		typeof r.reason === "string" &&175		typeof r.machine === "string" &&176		isCount(r.created_at);177	if (!valid) throw new BucketError("event record does not match the Koh 3 schema");178	return value as EventRecord;179}180181function isOptionalSaveId(value: unknown): boolean {182	return value === "" || (typeof value === "string" && SAVE_ID.test(value));183}184185function isCount(value: unknown): boolean {186	return Number.isSafeInteger(value) && (value as number) >= 0;187}188189/** A repo-relative path that can't escape or alias: no empty, `.` or `..` segments, no backslashes or control characters. */190export function isSafePath(path: string): boolean {191	return (192		!path.includes("\\") &&193		!CONTROL_CHAR.test(path) &&194		path.split("/").every((segment) => segment !== "" && segment !== "." && segment !== "..")195	);196}197198function isSaveFile(value: unknown): value is SaveFile {199	const f = value as Record<string, unknown>;200	return (201		typeof value === "object" &&202		value !== null &&203		typeof f.path === "string" &&204		isSafePath(f.path) &&205		typeof f.hash === "string" &&206		OBJECT_HASH.test(f.hash) &&207		typeof f.executable === "boolean"208	);209}