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 CONTROL_CHAR = /\p{Cc}/u; 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(isRepoName); } function isRepoName(name: string): boolean { return REPO_NAME.test(name) && name !== "." && name !== ".."; } // Checked here rather than in route handlers, so no caller can build a key from unchecked input. function requireRepo(repo: string): void { if (!isRepoName(repo)) throw new BucketError(`not a repo name: "${repo}"`); } function requireSaveId(id: string): void { if (!SAVE_ID.test(id)) throw new BucketError(`not a save ID: "${id}"`); } /** 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 { requireRepo(repo); 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 { requireRepo(repo); 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 { requireRepo(repo); requireSaveId(id); 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 { requireRepo(repo); 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 valid = typeof value === "object" && value !== null && ["message", "machine", "agent", "session"].every((key) => typeof r[key] === "string") && typeof r.id === "string" && SAVE_ID.test(r.id) && ["parent", "restores", "replayed_from"].every((key) => isOptionalSaveId(r[key])) && isCount(r.created_at) && isCount(r.size_bytes) && typeof r.is_checkpoint === "boolean" && Array.isArray(r.files) && r.files.every(isSaveFile) && new Set(r.files.map((file) => file.path)).size === r.files.length; if (!valid) throw new BucketError("save record does not match the Koh 3 schema"); return value as SaveRecord; } function isOptionalSaveId(value: unknown): boolean { return value === "" || (typeof value === "string" && SAVE_ID.test(value)); } function isCount(value: unknown): boolean { return Number.isSafeInteger(value) && (value as number) >= 0; } /** A repo-relative path that can't escape or alias: no empty, `.` or `..` segments, no backslashes or control characters. */ function isSafePath(path: string): boolean { return ( !path.includes("\\") && !CONTROL_CHAR.test(path) && path.split("/").every((segment) => segment !== "" && segment !== "." && segment !== "..") ); } function isSaveFile(value: unknown): value is SaveFile { const f = value as Record; return ( typeof value === "object" && value !== null && typeof f.path === "string" && isSafePath(f.path) && typeof f.hash === "string" && OBJECT_HASH.test(f.hash) && typeof f.executable === "boolean" ); }