repo.ts

3.9 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 decoder = new TextDecoder();3031// ponytail: one full listing per call; switch to a delimiter listing if the bucket grows large.32export async function listRepos(bucket: BucketReader): Promise<string[]> {33	const keys = await bucket.list("");34	return keys35		.filter((key) => key.endsWith("/head") && key.split("/").length === 2)36		.map((key) => key.slice(0, -"/head".length))37		.filter((name) => REPO_NAME.test(name) && name !== "." && name !== "..");38}3940/** Applies §9.2's format rule: a missing key is version 1; anything but `koh-remote 1` is refused. */41export async function checkFormat(bucket: BucketReader, repo: string): Promise<void> {42	const bytes = await bucket.get(`${repo}/format`);43	if (bytes === null) return;44	const format = decoder.decode(bytes).trim();45	if (format !== "koh-remote 1") {46		throw new BucketError(`${repo} uses remote format "${format}"; update Kepr to read it`);47	}48}4950export async function readHead(bucket: BucketReader, repo: string): Promise<string | null> {51	const bytes = await bucket.get(`${repo}/head`);52	if (bytes === null) return null;53	const head = decoder.decode(bytes).trim();54	if (!SAVE_ID.test(head)) throw new BucketError(`${repo}/head is not a save ID: "${head}"`);55	return head;56}5758export async function readSave(59	bucket: BucketReader,60	repo: string,61	id: string,62): Promise<SaveRecord> {63	const bytes = await bucket.get(`${repo}/saves/${id}`);64	if (bytes === null) throw new BucketError(`${repo}/saves/${id} is missing`);65	const record = parseSaveRecord(JSON.parse(decoder.decode(bytes)));66	if (record.id !== id) throw new BucketError(`${repo}/saves/${id} holds save ${record.id}`);67	return record;68}6970/** Fetches an object, inflates it and checks its Blake3 against its key, as steal does (§9.4). */71export async function readObject(72	bucket: BucketReader,73	repo: string,74	hash: string,75): Promise<Uint8Array> {76	if (!OBJECT_HASH.test(hash)) throw new BucketError(`not an object hash: "${hash}"`);77	const compressed = await bucket.get(`${repo}/objects/${hash}`);78	if (compressed === null) throw new BucketError(`${repo}/objects/${hash} is missing`);79	const bytes = new Uint8Array(inflateSync(compressed));80	if (bytesToHex(blake3(bytes)) !== hash) {81		throw new BucketError(`${repo}/objects/${hash} does not match its hash`);82	}83	return bytes;84}8586function parseSaveRecord(value: unknown): SaveRecord {87	const r = value as Record<string, unknown>;88	const strings = [89		"id",90		"parent",91		"message",92		"machine",93		"agent",94		"session",95		"restores",96		"replayed_from",97	];98	const valid =99		typeof value === "object" &&100		value !== null &&101		strings.every((key) => typeof r[key] === "string") &&102		SAVE_ID.test(r.id as string) &&103		typeof r.created_at === "number" &&104		typeof r.size_bytes === "number" &&105		typeof r.is_checkpoint === "boolean" &&106		Array.isArray(r.files) &&107		r.files.every(isSaveFile);108	if (!valid) throw new BucketError("save record does not match the Koh 3 schema");109	return value as SaveRecord;110}111112function isSaveFile(value: unknown): value is SaveFile {113	const f = value as Record<string, unknown>;114	return (115		typeof value === "object" &&116		value !== null &&117		typeof f.path === "string" &&118		typeof f.hash === "string" &&119		OBJECT_HASH.test(f.hash) &&120		typeof f.executable === "boolean"121	);122}