import { inflateSync } from "node:zlib"; import { blake3 } from "@noble/hashes/blake3.js"; import { bytesToHex } from "@noble/hashes/utils.js"; import type { BucketReader } from "./reader.ts"; export type SaveFile = { path: string; hash: string; executable: boolean }; /** The save record Koh 3 writes to `/saves/` (§9.2). */ export type SaveRecord = { id: string; parent: string; message: string; created_at: number; machine: string; is_checkpoint: boolean; size_bytes: number; agent: string; session: string; restores: string; replayed_from: string; files: SaveFile[]; }; export class BucketError extends Error {} const SAVE_ID = /^[0-9a-f]{12}$/; const OBJECT_HASH = /^[0-9a-f]{64}$/; const REPO_NAME = /^[A-Za-z0-9._-]+$/; const decoder = new TextDecoder(); // ponytail: one full listing per call; switch to a delimiter listing if the bucket grows large. export async function listRepos(bucket: BucketReader): Promise { const keys = await bucket.list(""); return keys .filter((key) => key.endsWith("/head") && key.split("/").length === 2) .map((key) => key.slice(0, -"/head".length)) .filter((name) => REPO_NAME.test(name) && name !== "." && name !== ".."); } /** Applies §9.2's format rule: a missing key is version 1; anything but `koh-remote 1` is refused. */ export async function checkFormat(bucket: BucketReader, repo: string): Promise { const bytes = await bucket.get(`${repo}/format`); if (bytes === null) return; const format = decoder.decode(bytes).trim(); if (format !== "koh-remote 1") { throw new BucketError(`${repo} uses remote format "${format}"; update Kepr to read it`); } } export async function readHead(bucket: BucketReader, repo: string): Promise { const bytes = await bucket.get(`${repo}/head`); if (bytes === null) return null; const head = decoder.decode(bytes).trim(); if (!SAVE_ID.test(head)) throw new BucketError(`${repo}/head is not a save ID: "${head}"`); return head; } export async function readSave( bucket: BucketReader, repo: string, id: string, ): Promise { const bytes = await bucket.get(`${repo}/saves/${id}`); if (bytes === null) throw new BucketError(`${repo}/saves/${id} is missing`); const record = parseSaveRecord(JSON.parse(decoder.decode(bytes))); if (record.id !== id) throw new BucketError(`${repo}/saves/${id} holds save ${record.id}`); return record; } /** Fetches an object, inflates it and checks its Blake3 against its key, as steal does (§9.4). */ export async function readObject( bucket: BucketReader, repo: string, hash: string, ): Promise { if (!OBJECT_HASH.test(hash)) throw new BucketError(`not an object hash: "${hash}"`); const compressed = await bucket.get(`${repo}/objects/${hash}`); if (compressed === null) throw new BucketError(`${repo}/objects/${hash} is missing`); const bytes = new Uint8Array(inflateSync(compressed)); if (bytesToHex(blake3(bytes)) !== hash) { throw new BucketError(`${repo}/objects/${hash} does not match its hash`); } return bytes; } function parseSaveRecord(value: unknown): SaveRecord { const r = value as Record; const strings = [ "id", "parent", "message", "machine", "agent", "session", "restores", "replayed_from", ]; const valid = typeof value === "object" && value !== null && strings.every((key) => typeof r[key] === "string") && SAVE_ID.test(r.id as string) && typeof r.created_at === "number" && typeof r.size_bytes === "number" && typeof r.is_checkpoint === "boolean" && Array.isArray(r.files) && r.files.every(isSaveFile); if (!valid) throw new BucketError("save record does not match the Koh 3 schema"); return value as SaveRecord; } function isSaveFile(value: unknown): value is SaveFile { const f = value as Record; return ( typeof value === "object" && value !== null && typeof f.path === "string" && typeof f.hash === "string" && OBJECT_HASH.test(f.hash) && typeof f.executable === "boolean" ); }