import { inflateSync } from "node:zlib"; import { blake3 } from "@noble/hashes/blake3.js"; import { bytesToHex } from "@noble/hashes/utils.js"; import type { BucketReader } from "./reader.ts"; export type SaveFile = { path: string; hash: string; executable: boolean }; /** The save record Koh 3 writes to `/saves/` (§9.2). */ export type SaveRecord = { id: string; parent: string; message: string; created_at: number; machine: string; is_checkpoint: boolean; size_bytes: number; agent: string; session: string; restores: string; replayed_from: string; files: SaveFile[]; }; /** A flag, clear or strip event, as Koh 3 writes it to `/events/` (§9.2, §10). */ export type EventRecord = { id: string; kind: "flag" | "clear" | "strip"; save_id: string; path: string; reason: string; created_at: number; machine: string; }; /** One built file of a release (§11.2). */ export type ReleaseFile = { platform: string; name: string; size: number; blake3: string; sha256: string; }; /** The release record Koh 3 writes to `/releases//release.json` (§11.2). */ export type ReleaseRecord = { format: 1; repo: string; version: string; save: string; published_at: number; machine: string; notes: string; files: ReleaseFile[]; }; /** §11.1's platform names, Kepr 2's exact list: Peach Android and existing build files depend on them. */ export const PLATFORMS = [ "linux-x86_64", "linux-aarch64", "macos-aarch64", "macos-x86_64", "windows-x86_64", "android-arm64", "android-x86", ] as const; /** An object's stored zlib bytes and the inflated bytes they were verified from. */ export type VerifiedObject = { compressed: Uint8Array; bytes: Uint8Array }; export class BucketError extends Error {} const SAVE_ID = /^[0-9a-f]{12}$/; const REPO_NAME = /^[A-Za-z0-9._-]+$/; const CONTROL_CHAR = /\p{Cc}/u; const HEX64 = /^[0-9a-f]{64}$/; const VERSION = /^[A-Za-z0-9.-]{1,32}$/; // A file name that is safe as one key segment and inside a quoted Content-Disposition filename. const FILE_NAME = /^[^/\\"\p{Cc}]{1,255}$/u; const decoder = new TextDecoder(); // ponytail: one full listing per call; switch to a delimiter listing if the bucket grows large. export async function listRepos(bucket: BucketReader): Promise { const keys = await bucket.list(""); return keys .filter((key) => key.endsWith("/head") && key.split("/").length === 2) .map((key) => key.slice(0, -"/head".length)) .filter(isRepoName); } function isRepoName(name: string): boolean { return REPO_NAME.test(name) && name !== "." && name !== ".."; } // Checked here rather than in route handlers, so no caller can build a key from unchecked input. function requireRepo(repo: string): void { if (!isRepoName(repo)) throw new BucketError(`not a repo name: "${repo}"`); } function requireSaveId(id: string): void { if (!SAVE_ID.test(id)) throw new BucketError(`not a save ID: "${id}"`); } /** Applies §9.2's format rule: a missing key is version 1; anything but `koh-remote 1` is refused. */ export async function checkFormat(bucket: BucketReader, repo: string): Promise { requireRepo(repo); const bytes = await bucket.get(`${repo}/format`); if (bytes === null) return; const format = decoder.decode(bytes).trim(); if (format !== "koh-remote 1") { throw new BucketError(`${repo} uses remote format "${format}"; update Kepr to read it`); } } export async function readHead(bucket: BucketReader, repo: string): Promise { requireRepo(repo); const bytes = await bucket.get(`${repo}/head`); if (bytes === null) return null; const head = decoder.decode(bytes).trim(); if (!SAVE_ID.test(head)) throw new BucketError(`${repo}/head is not a save ID: "${head}"`); return head; } export async function readSave( bucket: BucketReader, repo: string, id: string, ): Promise { requireRepo(repo); requireSaveId(id); const bytes = await bucket.get(`${repo}/saves/${id}`); if (bytes === null) throw new BucketError(`${repo}/saves/${id} is missing`); const record = parseSaveRecord(JSON.parse(decoder.decode(bytes))); if (record.id !== id) throw new BucketError(`${repo}/saves/${id} holds save ${record.id}`); return record; } /** Fetches an object, inflates it and checks its Blake3 against its key, as steal does (§9.4). */ export async function readObject( bucket: BucketReader, repo: string, hash: string, ): Promise { requireRepo(repo); if (!HEX64.test(hash)) throw new BucketError(`not an object hash: "${hash}"`); const compressed = await bucket.get(`${repo}/objects/${hash}`); if (compressed === null) throw new BucketError(`${repo}/objects/${hash} is missing`); const bytes = new Uint8Array(inflateSync(compressed)); if (bytesToHex(blake3(bytes)) !== hash) { throw new BucketError(`${repo}/objects/${hash} does not match its hash`); } return { compressed, bytes }; } export async function listEventIds(bucket: BucketReader, repo: string): Promise { requireRepo(repo); const prefix = `${repo}/events/`; // A folder remote's half-written temp files can show up in a listing; only IDs are events. return (await bucket.list(prefix)) .map((key) => key.slice(prefix.length)) .filter((id) => SAVE_ID.test(id)); } export async function readEvent( bucket: BucketReader, repo: string, id: string, ): Promise { requireRepo(repo); requireSaveId(id); const bytes = await bucket.get(`${repo}/events/${id}`); if (bytes === null) throw new BucketError(`${repo}/events/${id} is missing`); const record = parseEventRecord(JSON.parse(decoder.decode(bytes))); if (record.id !== id) throw new BucketError(`${repo}/events/${id} holds event ${record.id}`); return record; } /** §11.1's version rule, minus `.` and `..`, which would name a folder rather than a version. */ export function isVersion(value: string): boolean { return VERSION.test(value) && value !== "." && value !== ".."; } export function isPlatform(value: string): boolean { return (PLATFORMS as readonly string[]).includes(value); } /** The body of `/releases/latest`, trimmed, or null when the repo has never released. */ export async function readReleasesLatest( bucket: BucketReader, repo: string, ): Promise { requireRepo(repo); const bytes = await bucket.get(`${repo}/releases/latest`); if (bytes === null) return null; const version = decoder.decode(bytes).trim(); if (!isVersion(version)) throw new BucketError(`${repo}/releases/latest is not a version: "${version}"`); return version; } /** Every version with a `release.json`: a version without one doesn't exist (§11.2). */ export async function listReleaseVersions(bucket: BucketReader, repo: string): Promise { requireRepo(repo); return (await bucket.list(`${repo}/releases/`)) .map((key) => key.split("/")) .filter((parts) => parts.length === 4 && parts[3] === "release.json") .map((parts) => parts[2] as string) .filter(isVersion); } export async function readRelease( bucket: BucketReader, repo: string, version: string, ): Promise { requireRelease(repo, version); const key = `${repo}/releases/${version}/release.json`; const bytes = await bucket.get(key); if (bytes === null) throw new BucketError(`${key} is missing`); return checkRelease(repo, version, JSON.parse(decoder.decode(bytes))); } /** A parsed `/releases//release.json`, if the sync would accept it (§11.2). */ export function checkRelease(repo: string, version: string, value: unknown): ReleaseRecord { requireRelease(repo, version); const record = parseReleaseRecord(value); if (record.repo !== repo || record.version !== version) { throw new BucketError( `${repo}/releases/${version}/release.json holds ${record.repo} ${record.version}`, ); } return record; } function requireRelease(repo: string, version: string): void { requireRepo(repo); if (!isVersion(version)) throw new BucketError(`not a release version: "${version}"`); } function parseReleaseRecord(value: unknown): ReleaseRecord { const r = value as Record; const valid = typeof value === "object" && value !== null && r.format === 1 && typeof r.repo === "string" && typeof r.version === "string" && typeof r.save === "string" && SAVE_ID.test(r.save) && isCount(r.published_at) && typeof r.machine === "string" && typeof r.notes === "string" && Array.isArray(r.files) && r.files.every(isReleaseFile) && // One file per platform per version (§11.2). new Set(r.files.map((file) => file.platform)).size === r.files.length; if (!valid) throw new BucketError("release record does not match §11.2"); return value as ReleaseRecord; } function isReleaseFile(value: unknown): value is ReleaseFile { const f = value as Record; return ( typeof value === "object" && value !== null && typeof f.platform === "string" && isPlatform(f.platform) && typeof f.name === "string" && FILE_NAME.test(f.name) && f.name !== "." && f.name !== ".." && isCount(f.size) && typeof f.blake3 === "string" && HEX64.test(f.blake3) && typeof f.sha256 === "string" && HEX64.test(f.sha256) ); } function parseSaveRecord(value: unknown): SaveRecord { const r = value as Record; const valid = typeof value === "object" && value !== null && ["message", "machine", "agent", "session"].every((key) => typeof r[key] === "string") && typeof r.id === "string" && SAVE_ID.test(r.id) && ["parent", "restores", "replayed_from"].every((key) => isOptionalSaveId(r[key])) && isCount(r.created_at) && isCount(r.size_bytes) && typeof r.is_checkpoint === "boolean" && Array.isArray(r.files) && r.files.every(isSaveFile) && new Set(r.files.map((file) => file.path)).size === r.files.length; if (!valid) throw new BucketError("save record does not match the Koh 3 schema"); return value as SaveRecord; } function parseEventRecord(value: unknown): EventRecord { const r = value as Record; const valid = typeof value === "object" && value !== null && typeof r.id === "string" && SAVE_ID.test(r.id) && isOptionalSaveId(r.save_id) && (r.path === "" || (typeof r.path === "string" && isSafePath(r.path))) && // A flag or clear is about one save; a strip names a path, and one save or (with no ID) all. (r.kind === "strip" ? r.path !== "" : (r.kind === "flag" || r.kind === "clear") && r.save_id !== "") && typeof r.reason === "string" && typeof r.machine === "string" && isCount(r.created_at); if (!valid) throw new BucketError("event record does not match the Koh 3 schema"); return value as EventRecord; } function isOptionalSaveId(value: unknown): boolean { return value === "" || (typeof value === "string" && SAVE_ID.test(value)); } function isCount(value: unknown): boolean { return Number.isSafeInteger(value) && (value as number) >= 0; } /** A repo-relative path that can't escape or alias: no empty, `.` or `..` segments, no backslashes or control characters. */ export function isSafePath(path: string): boolean { return ( !path.includes("\\") && !CONTROL_CHAR.test(path) && path.split("/").every((segment) => segment !== "" && segment !== "." && segment !== "..") ); } function isSaveFile(value: unknown): value is SaveFile { const f = value as Record; return ( typeof value === "object" && value !== null && typeof f.path === "string" && isSafePath(f.path) && typeof f.hash === "string" && HEX64.test(f.hash) && typeof f.executable === "boolean" ); }