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 CONTROL_CHAR = /\p{Cc}/u;30const decoder = new TextDecoder();313233export async function listRepos(bucket: BucketReader): Promise<string[]> {34 const keys = await bucket.list("");35 return keys36 .filter((key) => key.endsWith("/head") && key.split("/").length === 2)37 .map((key) => key.slice(0, -"/head".length))38 .filter(isRepoName);39}4041function isRepoName(name: string): boolean {42 return REPO_NAME.test(name) && name !== "." && name !== "..";43}444546function requireRepo(repo: string): void {47 if (!isRepoName(repo)) throw new BucketError(`not a repo name: "${repo}"`);48}4950function requireSaveId(id: string): void {51 if (!SAVE_ID.test(id)) throw new BucketError(`not a save ID: "${id}"`);52}535455export async function checkFormat(bucket: BucketReader, repo: string): Promise<void> {56 requireRepo(repo);57 const bytes = await bucket.get(`${repo}/format`);58 if (bytes === null) return;59 const format = decoder.decode(bytes).trim();60 if (format !== "koh-remote 1") {61 throw new BucketError(`${repo} uses remote format "${format}"; update Kepr to read it`);62 }63}6465export async function readHead(bucket: BucketReader, repo: string): Promise<string | null> {66 requireRepo(repo);67 const bytes = await bucket.get(`${repo}/head`);68 if (bytes === null) return null;69 const head = decoder.decode(bytes).trim();70 if (!SAVE_ID.test(head)) throw new BucketError(`${repo}/head is not a save ID: "${head}"`);71 return head;72}7374export async function readSave(75 bucket: BucketReader,76 repo: string,77 id: string,78): Promise<SaveRecord> {79 requireRepo(repo);80 requireSaveId(id);81 const bytes = await bucket.get(`${repo}/saves/${id}`);82 if (bytes === null) throw new BucketError(`${repo}/saves/${id} is missing`);83 const record = parseSaveRecord(JSON.parse(decoder.decode(bytes)));84 if (record.id !== id) throw new BucketError(`${repo}/saves/${id} holds save ${record.id}`);85 return record;86}878889export async function readObject(90 bucket: BucketReader,91 repo: string,92 hash: string,93): Promise<Uint8Array> {94 requireRepo(repo);95 if (!OBJECT_HASH.test(hash)) throw new BucketError(`not an object hash: "${hash}"`);96 const compressed = await bucket.get(`${repo}/objects/${hash}`);97 if (compressed === null) throw new BucketError(`${repo}/objects/${hash} is missing`);98 const bytes = new Uint8Array(inflateSync(compressed));99 if (bytesToHex(blake3(bytes)) !== hash) {100 throw new BucketError(`${repo}/objects/${hash} does not match its hash`);101 }102 return bytes;103}104105function parseSaveRecord(value: unknown): SaveRecord {106 const r = value as Record<string, unknown>;107 const valid =108 typeof value === "object" &&109 value !== null &&110 ["message", "machine", "agent", "session"].every((key) => typeof r[key] === "string") &&111 typeof r.id === "string" &&112 SAVE_ID.test(r.id) &&113 ["parent", "restores", "replayed_from"].every((key) => isOptionalSaveId(r[key])) &&114 isCount(r.created_at) &&115 isCount(r.size_bytes) &&116 typeof r.is_checkpoint === "boolean" &&117 Array.isArray(r.files) &&118 r.files.every(isSaveFile) &&119 new Set(r.files.map((file) => file.path)).size === r.files.length;120 if (!valid) throw new BucketError("save record does not match the Koh 3 schema");121 return value as SaveRecord;122}123124function isOptionalSaveId(value: unknown): boolean {125 return value === "" || (typeof value === "string" && SAVE_ID.test(value));126}127128function isCount(value: unknown): boolean {129 return Number.isSafeInteger(value) && (value as number) >= 0;130}131132133function isSafePath(path: string): boolean {134 return (135 !path.includes("\\") &&136 !CONTROL_CHAR.test(path) &&137 path.split("/").every((segment) => segment !== "" && segment !== "." && segment !== "..")138 );139}140141function isSaveFile(value: unknown): value is SaveFile {142 const f = value as Record<string, unknown>;143 return (144 typeof value === "object" &&145 value !== null &&146 typeof f.path === "string" &&147 isSafePath(f.path) &&148 typeof f.hash === "string" &&149 OBJECT_HASH.test(f.hash) &&150 typeof f.executable === "boolean"151 );152}