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 return bytes === null ? null : decoder.decode(bytes).trim();194}195196197export async function listReleaseVersions(bucket: BucketReader, repo: string): Promise<string[]> {198 requireRepo(repo);199 return (await bucket.list(`${repo}/releases/`))200 .map((key) => key.split("/"))201 .filter((parts) => parts.length === 4 && parts[3] === "release.json")202 .map((parts) => parts[2] as string)203 .filter(isVersion);204}205206export async function readRelease(207 bucket: BucketReader,208 repo: string,209 version: string,210): Promise<ReleaseRecord> {211 requireRepo(repo);212 if (!isVersion(version)) throw new BucketError(`not a release version: "${version}"`);213 const key = `${repo}/releases/${version}/release.json`;214 const bytes = await bucket.get(key);215 if (bytes === null) throw new BucketError(`${key} is missing`);216 const record = parseReleaseRecord(JSON.parse(decoder.decode(bytes)));217 if (record.repo !== repo || record.version !== version) {218 throw new BucketError(`${key} holds ${record.repo} ${record.version}`);219 }220 return record;221}222223function parseReleaseRecord(value: unknown): ReleaseRecord {224 const r = value as Record<string, unknown>;225 const valid =226 typeof value === "object" &&227 value !== null &&228 r.format === 1 &&229 typeof r.repo === "string" &&230 typeof r.version === "string" &&231 typeof r.save === "string" &&232 SAVE_ID.test(r.save) &&233 isCount(r.published_at) &&234 typeof r.machine === "string" &&235 typeof r.notes === "string" &&236 Array.isArray(r.files) &&237 r.files.every(isReleaseFile) &&238 239 new Set(r.files.map((file) => file.platform)).size === r.files.length;240 if (!valid) throw new BucketError("release record does not match §11.2");241 return value as ReleaseRecord;242}243244function isReleaseFile(value: unknown): value is ReleaseFile {245 const f = value as Record<string, unknown>;246 return (247 typeof value === "object" &&248 value !== null &&249 typeof f.platform === "string" &&250 isPlatform(f.platform) &&251 typeof f.name === "string" &&252 FILE_NAME.test(f.name) &&253 f.name !== "." &&254 f.name !== ".." &&255 isCount(f.size) &&256 typeof f.blake3 === "string" &&257 HEX64.test(f.blake3) &&258 typeof f.sha256 === "string" &&259 HEX64.test(f.sha256)260 );261}262263function parseSaveRecord(value: unknown): SaveRecord {264 const r = value as Record<string, unknown>;265 const valid =266 typeof value === "object" &&267 value !== null &&268 ["message", "machine", "agent", "session"].every((key) => typeof r[key] === "string") &&269 typeof r.id === "string" &&270 SAVE_ID.test(r.id) &&271 ["parent", "restores", "replayed_from"].every((key) => isOptionalSaveId(r[key])) &&272 isCount(r.created_at) &&273 isCount(r.size_bytes) &&274 typeof r.is_checkpoint === "boolean" &&275 Array.isArray(r.files) &&276 r.files.every(isSaveFile) &&277 new Set(r.files.map((file) => file.path)).size === r.files.length;278 if (!valid) throw new BucketError("save record does not match the Koh 3 schema");279 return value as SaveRecord;280}281282function parseEventRecord(value: unknown): EventRecord {283 const r = value as Record<string, unknown>;284 const valid =285 typeof value === "object" &&286 value !== null &&287 typeof r.id === "string" &&288 SAVE_ID.test(r.id) &&289 isOptionalSaveId(r.save_id) &&290 (r.path === "" || (typeof r.path === "string" && isSafePath(r.path))) &&291 292 (r.kind === "strip"293 ? r.path !== ""294 : (r.kind === "flag" || r.kind === "clear") && r.save_id !== "") &&295 typeof r.reason === "string" &&296 typeof r.machine === "string" &&297 isCount(r.created_at);298 if (!valid) throw new BucketError("event record does not match the Koh 3 schema");299 return value as EventRecord;300}301302function isOptionalSaveId(value: unknown): boolean {303 return value === "" || (typeof value === "string" && SAVE_ID.test(value));304}305306function isCount(value: unknown): boolean {307 return Number.isSafeInteger(value) && (value as number) >= 0;308}309310311export function isSafePath(path: string): boolean {312 return (313 !path.includes("\\") &&314 !CONTROL_CHAR.test(path) &&315 path.split("/").every((segment) => segment !== "" && segment !== "." && segment !== "..")316 );317}318319function isSaveFile(value: unknown): value is SaveFile {320 const f = value as Record<string, unknown>;321 return (322 typeof value === "object" &&323 value !== null &&324 typeof f.path === "string" &&325 isSafePath(f.path) &&326 typeof f.hash === "string" &&327 HEX64.test(f.hash) &&328 typeof f.executable === "boolean"329 );330}