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 ReleaseFile = {37 platform: string;38 name: string;39 size: number;40 blake3: string;41 sha256: string;42};434445export type ReleaseRecord = {46 format: 1;47 repo: string;48 version: string;49 save: string;50 published_at: number;51 machine: string;52 notes: string;53 files: ReleaseFile[];54};555657export const PLATFORMS = [58 "linux-x86_64",59 "linux-aarch64",60 "macos-aarch64",61 "macos-x86_64",62 "windows-x86_64",63 "android-arm64",64 "android-x86",65] as const;666768export type VerifiedObject = { compressed: Uint8Array; bytes: Uint8Array };6970export class BucketError extends Error {}7172const SAVE_ID = /^[0-9a-f]{12}$/;73const REPO_NAME = /^[A-Za-z0-9._-]+$/;74const CONTROL_CHAR = /\p{Cc}/u;75const HEX64 = /^[0-9a-f]{64}$/;76const VERSION = /^[A-Za-z0-9.-]{1,32}$/;7778const FILE_NAME = /^[^/\\"\p{Cc}]{1,255}$/u;79const decoder = new TextDecoder();808182export async function listRepos(bucket: BucketReader): Promise<string[]> {83 const keys = await bucket.list("");84 return keys85 .filter((key) => key.endsWith("/head") && key.split("/").length === 2)86 .map((key) => key.slice(0, -"/head".length))87 .filter(isRepoName);88}8990function isRepoName(name: string): boolean {91 return REPO_NAME.test(name) && name !== "." && name !== "..";92}939495function requireRepo(repo: string): void {96 if (!isRepoName(repo)) throw new BucketError(`not a repo name: "${repo}"`);97}9899function requireSaveId(id: string): void {100 if (!SAVE_ID.test(id)) throw new BucketError(`not a save ID: "${id}"`);101}102103104export async function checkFormat(bucket: BucketReader, repo: string): Promise<void> {105 requireRepo(repo);106 const bytes = await bucket.get(`${repo}/format`);107 if (bytes === null) return;108 const format = decoder.decode(bytes).trim();109 if (format !== "koh-remote 1") {110 throw new BucketError(`${repo} uses remote format "${format}"; update Kepr to read it`);111 }112}113114export async function readHead(bucket: BucketReader, repo: string): Promise<string | null> {115 requireRepo(repo);116 const bytes = await bucket.get(`${repo}/head`);117 if (bytes === null) return null;118 const head = decoder.decode(bytes).trim();119 if (!SAVE_ID.test(head)) throw new BucketError(`${repo}/head is not a save ID: "${head}"`);120 return head;121}122123export async function readSave(124 bucket: BucketReader,125 repo: string,126 id: string,127): Promise<SaveRecord> {128 requireRepo(repo);129 requireSaveId(id);130 const bytes = await bucket.get(`${repo}/saves/${id}`);131 if (bytes === null) throw new BucketError(`${repo}/saves/${id} is missing`);132 const record = parseSaveRecord(JSON.parse(decoder.decode(bytes)));133 if (record.id !== id) throw new BucketError(`${repo}/saves/${id} holds save ${record.id}`);134 return record;135}136137138export async function readObject(139 bucket: BucketReader,140 repo: string,141 hash: string,142): Promise<VerifiedObject> {143 requireRepo(repo);144 if (!HEX64.test(hash)) throw new BucketError(`not an object hash: "${hash}"`);145 const compressed = await bucket.get(`${repo}/objects/${hash}`);146 if (compressed === null) throw new BucketError(`${repo}/objects/${hash} is missing`);147 const bytes = new Uint8Array(inflateSync(compressed));148 if (bytesToHex(blake3(bytes)) !== hash) {149 throw new BucketError(`${repo}/objects/${hash} does not match its hash`);150 }151 return { compressed, bytes };152}153154export async function listEventIds(bucket: BucketReader, repo: string): Promise<string[]> {155 requireRepo(repo);156 const prefix = `${repo}/events/`;157 158 return (await bucket.list(prefix))159 .map((key) => key.slice(prefix.length))160 .filter((id) => SAVE_ID.test(id));161}162163export async function readEvent(164 bucket: BucketReader,165 repo: string,166 id: string,167): Promise<EventRecord> {168 requireRepo(repo);169 requireSaveId(id);170 const bytes = await bucket.get(`${repo}/events/${id}`);171 if (bytes === null) throw new BucketError(`${repo}/events/${id} is missing`);172 const record = parseEventRecord(JSON.parse(decoder.decode(bytes)));173 if (record.id !== id) throw new BucketError(`${repo}/events/${id} holds event ${record.id}`);174 return record;175}176177178export function isVersion(value: string): boolean {179 return VERSION.test(value) && value !== "." && value !== "..";180}181182export function isPlatform(value: string): boolean {183 return (PLATFORMS as readonly string[]).includes(value);184}185186187export async function readReleasesLatest(188 bucket: BucketReader,189 repo: string,190): Promise<string | null> {191 requireRepo(repo);192 const bytes = await bucket.get(`${repo}/releases/latest`);193 if (bytes === null) return null;194 const version = decoder.decode(bytes).trim();195 if (!isVersion(version))196 throw new BucketError(`${repo}/releases/latest is not a version: "${version}"`);197 return version;198}199200201export async function listReleaseVersions(bucket: BucketReader, repo: string): Promise<string[]> {202 requireRepo(repo);203 return (await bucket.list(`${repo}/releases/`))204 .map((key) => key.split("/"))205 .filter((parts) => parts.length === 4 && parts[3] === "release.json")206 .map((parts) => parts[2] as string)207 .filter(isVersion);208}209210export async function readRelease(211 bucket: BucketReader,212 repo: string,213 version: string,214): Promise<ReleaseRecord> {215 requireRelease(repo, version);216 const key = `${repo}/releases/${version}/release.json`;217 const bytes = await bucket.get(key);218 if (bytes === null) throw new BucketError(`${key} is missing`);219 return checkRelease(repo, version, JSON.parse(decoder.decode(bytes)));220}221222223export function checkRelease(repo: string, version: string, value: unknown): ReleaseRecord {224 requireRelease(repo, version);225 const record = parseReleaseRecord(value);226 if (record.repo !== repo || record.version !== version) {227 throw new BucketError(228 `${repo}/releases/${version}/release.json holds ${record.repo} ${record.version}`,229 );230 }231 return record;232}233234function requireRelease(repo: string, version: string): void {235 requireRepo(repo);236 if (!isVersion(version)) throw new BucketError(`not a release version: "${version}"`);237}238239function parseReleaseRecord(value: unknown): ReleaseRecord {240 const r = value as Record<string, unknown>;241 const valid =242 typeof value === "object" &&243 value !== null &&244 r.format === 1 &&245 typeof r.repo === "string" &&246 typeof r.version === "string" &&247 typeof r.save === "string" &&248 SAVE_ID.test(r.save) &&249 isCount(r.published_at) &&250 typeof r.machine === "string" &&251 typeof r.notes === "string" &&252 Array.isArray(r.files) &&253 r.files.every(isReleaseFile) &&254 255 new Set(r.files.map((file) => file.platform)).size === r.files.length;256 if (!valid) throw new BucketError("release record does not match §11.2");257 return value as ReleaseRecord;258}259260function isReleaseFile(value: unknown): value is ReleaseFile {261 const f = value as Record<string, unknown>;262 return (263 typeof value === "object" &&264 value !== null &&265 typeof f.platform === "string" &&266 isPlatform(f.platform) &&267 typeof f.name === "string" &&268 FILE_NAME.test(f.name) &&269 f.name !== "." &&270 f.name !== ".." &&271 isCount(f.size) &&272 typeof f.blake3 === "string" &&273 HEX64.test(f.blake3) &&274 typeof f.sha256 === "string" &&275 HEX64.test(f.sha256)276 );277}278279function parseSaveRecord(value: unknown): SaveRecord {280 const r = value as Record<string, unknown>;281 const valid =282 typeof value === "object" &&283 value !== null &&284 ["message", "machine", "agent", "session"].every((key) => typeof r[key] === "string") &&285 typeof r.id === "string" &&286 SAVE_ID.test(r.id) &&287 ["parent", "restores", "replayed_from"].every((key) => isOptionalSaveId(r[key])) &&288 isCount(r.created_at) &&289 isCount(r.size_bytes) &&290 typeof r.is_checkpoint === "boolean" &&291 Array.isArray(r.files) &&292 r.files.every(isSaveFile) &&293 new Set(r.files.map((file) => file.path)).size === r.files.length;294 if (!valid) throw new BucketError("save record does not match the Koh 3 schema");295 return value as SaveRecord;296}297298function parseEventRecord(value: unknown): EventRecord {299 const r = value as Record<string, unknown>;300 const valid =301 typeof value === "object" &&302 value !== null &&303 typeof r.id === "string" &&304 SAVE_ID.test(r.id) &&305 isOptionalSaveId(r.save_id) &&306 (r.path === "" || (typeof r.path === "string" && isSafePath(r.path))) &&307 308 309 (r.kind === "strip"310 ? r.path !== ""311 : (r.kind === "flag" || r.kind === "clear") && r.save_id !== "" && r.path === "") &&312 typeof r.reason === "string" &&313 typeof r.machine === "string" &&314 isCount(r.created_at);315 if (!valid) throw new BucketError("event record does not match the Koh 3 schema");316 return value as EventRecord;317}318319function isOptionalSaveId(value: unknown): boolean {320 return value === "" || (typeof value === "string" && SAVE_ID.test(value));321}322323function isCount(value: unknown): boolean {324 return Number.isSafeInteger(value) && (value as number) >= 0;325}326327328export function isSafePath(path: string): boolean {329 return (330 !path.includes("\\") &&331 !CONTROL_CHAR.test(path) &&332 path.split("/").every((segment) => segment !== "" && segment !== "." && segment !== "..")333 );334}335336function isSaveFile(value: unknown): value is SaveFile {337 const f = value as Record<string, unknown>;338 return (339 typeof value === "object" &&340 value !== null &&341 typeof f.path === "string" &&342 isSafePath(f.path) &&343 typeof f.hash === "string" &&344 HEX64.test(f.hash) &&345 typeof f.executable === "boolean"346 );347}