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 requireRepo(repo);216 if (!isVersion(version)) throw new BucketError(`not a release version: "${version}"`);217 const key = `${repo}/releases/${version}/release.json`;218 const bytes = await bucket.get(key);219 if (bytes === null) throw new BucketError(`${key} is missing`);220 const record = parseReleaseRecord(JSON.parse(decoder.decode(bytes)));221 if (record.repo !== repo || record.version !== version) {222 throw new BucketError(`${key} holds ${record.repo} ${record.version}`);223 }224 return record;225}226227function parseReleaseRecord(value: unknown): ReleaseRecord {228 const r = value as Record<string, unknown>;229 const valid =230 typeof value === "object" &&231 value !== null &&232 r.format === 1 &&233 typeof r.repo === "string" &&234 typeof r.version === "string" &&235 typeof r.save === "string" &&236 SAVE_ID.test(r.save) &&237 isCount(r.published_at) &&238 typeof r.machine === "string" &&239 typeof r.notes === "string" &&240 Array.isArray(r.files) &&241 r.files.every(isReleaseFile) &&242 243 new Set(r.files.map((file) => file.platform)).size === r.files.length;244 if (!valid) throw new BucketError("release record does not match §11.2");245 return value as ReleaseRecord;246}247248function isReleaseFile(value: unknown): value is ReleaseFile {249 const f = value as Record<string, unknown>;250 return (251 typeof value === "object" &&252 value !== null &&253 typeof f.platform === "string" &&254 isPlatform(f.platform) &&255 typeof f.name === "string" &&256 FILE_NAME.test(f.name) &&257 f.name !== "." &&258 f.name !== ".." &&259 isCount(f.size) &&260 typeof f.blake3 === "string" &&261 HEX64.test(f.blake3) &&262 typeof f.sha256 === "string" &&263 HEX64.test(f.sha256)264 );265}266267function parseSaveRecord(value: unknown): SaveRecord {268 const r = value as Record<string, unknown>;269 const valid =270 typeof value === "object" &&271 value !== null &&272 ["message", "machine", "agent", "session"].every((key) => typeof r[key] === "string") &&273 typeof r.id === "string" &&274 SAVE_ID.test(r.id) &&275 ["parent", "restores", "replayed_from"].every((key) => isOptionalSaveId(r[key])) &&276 isCount(r.created_at) &&277 isCount(r.size_bytes) &&278 typeof r.is_checkpoint === "boolean" &&279 Array.isArray(r.files) &&280 r.files.every(isSaveFile) &&281 new Set(r.files.map((file) => file.path)).size === r.files.length;282 if (!valid) throw new BucketError("save record does not match the Koh 3 schema");283 return value as SaveRecord;284}285286function parseEventRecord(value: unknown): EventRecord {287 const r = value as Record<string, unknown>;288 const valid =289 typeof value === "object" &&290 value !== null &&291 typeof r.id === "string" &&292 SAVE_ID.test(r.id) &&293 isOptionalSaveId(r.save_id) &&294 (r.path === "" || (typeof r.path === "string" && isSafePath(r.path))) &&295 296 (r.kind === "strip"297 ? r.path !== ""298 : (r.kind === "flag" || r.kind === "clear") && r.save_id !== "") &&299 typeof r.reason === "string" &&300 typeof r.machine === "string" &&301 isCount(r.created_at);302 if (!valid) throw new BucketError("event record does not match the Koh 3 schema");303 return value as EventRecord;304}305306function isOptionalSaveId(value: unknown): boolean {307 return value === "" || (typeof value === "string" && SAVE_ID.test(value));308}309310function isCount(value: unknown): boolean {311 return Number.isSafeInteger(value) && (value as number) >= 0;312}313314315export function isSafePath(path: string): boolean {316 return (317 !path.includes("\\") &&318 !CONTROL_CHAR.test(path) &&319 path.split("/").every((segment) => segment !== "" && segment !== "." && segment !== "..")320 );321}322323function isSaveFile(value: unknown): value is SaveFile {324 const f = value as Record<string, unknown>;325 return (326 typeof value === "object" &&327 value !== null &&328 typeof f.path === "string" &&329 isSafePath(f.path) &&330 typeof f.hash === "string" &&331 HEX64.test(f.hash) &&332 typeof f.executable === "boolean"333 );334}