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 };789export 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();303132export 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}394041export 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}697071export 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}