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};232425export type EventRecord = {26 id: string;27 kind: "flag" | "clear" | "strip";28 save_id: string;29 path: string;30 reason: string;31 created_at: number;32 machine: string;33};343536export type VerifiedObject = { compressed: Uint8Array; bytes: Uint8Array };3738export class BucketError extends Error {}3940const SAVE_ID = /^[0-9a-f]{12}$/;41const OBJECT_HASH = /^[0-9a-f]{64}$/;42const REPO_NAME = /^[A-Za-z0-9._-]+$/;43const CONTROL_CHAR = /\p{Cc}/u;44const decoder = new TextDecoder();454647export async function listRepos(bucket: BucketReader): Promise<string[]> {48 const keys = await bucket.list("");49 return keys50 .filter((key) => key.endsWith("/head") && key.split("/").length === 2)51 .map((key) => key.slice(0, -"/head".length))52 .filter(isRepoName);53}5455function isRepoName(name: string): boolean {56 return REPO_NAME.test(name) && name !== "." && name !== "..";57}585960function requireRepo(repo: string): void {61 if (!isRepoName(repo)) throw new BucketError(`not a repo name: "${repo}"`);62}6364function requireSaveId(id: string): void {65 if (!SAVE_ID.test(id)) throw new BucketError(`not a save ID: "${id}"`);66}676869export async function checkFormat(bucket: BucketReader, repo: string): Promise<void> {70 requireRepo(repo);71 const bytes = await bucket.get(`${repo}/format`);72 if (bytes === null) return;73 const format = decoder.decode(bytes).trim();74 if (format !== "koh-remote 1") {75 throw new BucketError(`${repo} uses remote format "${format}"; update Kepr to read it`);76 }77}7879export async function readHead(bucket: BucketReader, repo: string): Promise<string | null> {80 requireRepo(repo);81 const bytes = await bucket.get(`${repo}/head`);82 if (bytes === null) return null;83 const head = decoder.decode(bytes).trim();84 if (!SAVE_ID.test(head)) throw new BucketError(`${repo}/head is not a save ID: "${head}"`);85 return head;86}8788export async function readSave(89 bucket: BucketReader,90 repo: string,91 id: string,92): Promise<SaveRecord> {93 requireRepo(repo);94 requireSaveId(id);95 const bytes = await bucket.get(`${repo}/saves/${id}`);96 if (bytes === null) throw new BucketError(`${repo}/saves/${id} is missing`);97 const record = parseSaveRecord(JSON.parse(decoder.decode(bytes)));98 if (record.id !== id) throw new BucketError(`${repo}/saves/${id} holds save ${record.id}`);99 return record;100}101102103export async function readObject(104 bucket: BucketReader,105 repo: string,106 hash: string,107): Promise<VerifiedObject> {108 requireRepo(repo);109 if (!OBJECT_HASH.test(hash)) throw new BucketError(`not an object hash: "${hash}"`);110 const compressed = await bucket.get(`${repo}/objects/${hash}`);111 if (compressed === null) throw new BucketError(`${repo}/objects/${hash} is missing`);112 const bytes = new Uint8Array(inflateSync(compressed));113 if (bytesToHex(blake3(bytes)) !== hash) {114 throw new BucketError(`${repo}/objects/${hash} does not match its hash`);115 }116 return { compressed, bytes };117}118119export async function listEventIds(bucket: BucketReader, repo: string): Promise<string[]> {120 requireRepo(repo);121 const prefix = `${repo}/events/`;122 123 return (await bucket.list(prefix))124 .map((key) => key.slice(prefix.length))125 .filter((id) => SAVE_ID.test(id));126}127128export async function readEvent(129 bucket: BucketReader,130 repo: string,131 id: string,132): Promise<EventRecord> {133 requireRepo(repo);134 requireSaveId(id);135 const bytes = await bucket.get(`${repo}/events/${id}`);136 if (bytes === null) throw new BucketError(`${repo}/events/${id} is missing`);137 const record = parseEventRecord(JSON.parse(decoder.decode(bytes)));138 if (record.id !== id) throw new BucketError(`${repo}/events/${id} holds event ${record.id}`);139 return record;140}141142function parseSaveRecord(value: unknown): SaveRecord {143 const r = value as Record<string, unknown>;144 const valid =145 typeof value === "object" &&146 value !== null &&147 ["message", "machine", "agent", "session"].every((key) => typeof r[key] === "string") &&148 typeof r.id === "string" &&149 SAVE_ID.test(r.id) &&150 ["parent", "restores", "replayed_from"].every((key) => isOptionalSaveId(r[key])) &&151 isCount(r.created_at) &&152 isCount(r.size_bytes) &&153 typeof r.is_checkpoint === "boolean" &&154 Array.isArray(r.files) &&155 r.files.every(isSaveFile) &&156 new Set(r.files.map((file) => file.path)).size === r.files.length;157 if (!valid) throw new BucketError("save record does not match the Koh 3 schema");158 return value as SaveRecord;159}160161function parseEventRecord(value: unknown): EventRecord {162 const r = value as Record<string, unknown>;163 const valid =164 typeof value === "object" &&165 value !== null &&166 typeof r.id === "string" &&167 SAVE_ID.test(r.id) &&168 isOptionalSaveId(r.save_id) &&169 (r.path === "" || (typeof r.path === "string" && isSafePath(r.path))) &&170 171 (r.kind === "strip"172 ? r.path !== ""173 : (r.kind === "flag" || r.kind === "clear") && r.save_id !== "") &&174 typeof r.reason === "string" &&175 typeof r.machine === "string" &&176 isCount(r.created_at);177 if (!valid) throw new BucketError("event record does not match the Koh 3 schema");178 return value as EventRecord;179}180181function isOptionalSaveId(value: unknown): boolean {182 return value === "" || (typeof value === "string" && SAVE_ID.test(value));183}184185function isCount(value: unknown): boolean {186 return Number.isSafeInteger(value) && (value as number) >= 0;187}188189190export function isSafePath(path: string): boolean {191 return (192 !path.includes("\\") &&193 !CONTROL_CHAR.test(path) &&194 path.split("/").every((segment) => segment !== "" && segment !== "." && segment !== "..")195 );196}197198function isSaveFile(value: unknown): value is SaveFile {199 const f = value as Record<string, unknown>;200 return (201 typeof value === "object" &&202 value !== null &&203 typeof f.path === "string" &&204 isSafePath(f.path) &&205 typeof f.hash === "string" &&206 OBJECT_HASH.test(f.hash) &&207 typeof f.executable === "boolean"208 );209}