import { inflateSync } from "node:zlib"; import { blake3 } from "@noble/hashes/blake3.js"; import { bytesToHex } from "@noble/hashes/utils.js"; import type { Bucket } 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; /** A save record as it arrived, less the files §5.5 quarantines, named here escaped. */ export type ArrivingSave = SaveRecord & { quarantined: string[] }; /** An object's stored zlib bytes, verified against its hash, and its inflated size. */ export type VerifiedObject = { compressed: Uint8Array; size: number }; export class BucketError extends Error {} /** An object that inflates past Koh's own file limit; the paths pointing at it are quarantined. */ export class OversizedObject extends BucketError {} const MAX_OBJECT = 512 * 1024 * 1024; // Twelve hex characters, or six: Koh 2's early saves keep their shorter IDs through migration // (§30, 2026-09-23). export const SAVE_ID = /^(?:[0-9a-f]{6}|[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(); // A run of whole UTF-8 sequences (RFC 3629), or one byte outside them, over a record read one // character per byte. const UTF8_RUN = /((?:[^\x80-\xff]|[\xc2-\xdf][\x80-\xbf]|\xe0[\xa0-\xbf][\x80-\xbf]|[\xe1-\xec\xee\xef][\x80-\xbf]{2}|\xed[\x80-\x9f][\x80-\xbf]|\xf0[\x90-\xbf][\x80-\xbf]{2}|[\xf1-\xf3][\x80-\xbf]{3}|\xf4[\x80-\x8f][\x80-\xbf]{2})+)|[\s\S]/g; // A surrogate pair escape, a lone surrogate escape, or any other escape (`\\` included), whole. const JSON_ESCAPE = /\\u[dD][89abAB][0-9a-fA-F]{2}\\u[dD][c-fC-F][0-9a-fA-F]{2}|\\u[dD][89a-fA-F][0-9a-fA-F]{2}|\\[\s\S]/g; /** * Every repo in the bucket: a top-level folder with a `head`. A folder without one is not a repo * yet, as when a first offer or a rename (which copies `head` last) is still under way. */ export async function listRepos(bucket: Bucket): Promise { const names = (await bucket.folders()).filter(isRepoName); const heads = await Promise.all(names.map((name) => bucket.get(`${name}/head`))); return names.filter((_, i) => heads[i] !== null); } 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: Bucket, 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: Bucket, 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: Bucket, 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(decodeRecord(bytes))); if (record.id !== id) throw new BucketError(`${repo}/saves/${id} holds save ${record.id}`); return { ...record, files: record.files.filter((file) => isSafePath(file.path)), quarantined: record.files .filter((file) => !isSafePath(file.path)) .map((file) => escapePath(file.path)), }; } /** * A record's text, read as Koh reads it (\u00A75.5): an unpaired surrogate escape stands for its three * bytes, and each byte outside a valid UTF-8 sequence becomes the lone surrogate U+DC00 + byte, so * the path holding it fails `isWellFormed` while a genuine U+FFFD stays legal. */ function decodeRecord(bytes: Uint8Array): string { const text = Buffer.from(bytes) .toString("latin1") .replace(JSON_ESCAPE, (sequence) => { if (sequence.length !== 6 || sequence[1] !== "u") return sequence; const unit = Number.parseInt(sequence.slice(2), 16); return String.fromCharCode( 0xe0 | (unit >> 12), 0x80 | ((unit >> 6) & 0x3f), 0x80 | (unit & 0x3f), ); }); return text.replace(UTF8_RUN, (run, valid: string | undefined) => valid === undefined ? String.fromCharCode(0xdc00 + run.charCodeAt(0)) : decoder.decode(Buffer.from(valid, "latin1")), ); } function objectKey(repo: string, hash: string): string { requireRepo(repo); if (!HEX64.test(hash)) throw new BucketError(`not an object hash: "${hash}"`); return `${repo}/objects/${hash}`; } /** An object's stored, compressed size, asked without reading it. */ export function objectSize(bucket: Bucket, repo: string, hash: string): Promise { return bucket.size(objectKey(repo, hash)); } /** * Fetches an object, inflates it and checks its Blake3 against its key, as steal does (§9.4). The * inflated bytes never leave this call, so reads in parallel hold one inflated object at a time. */ export async function readObject( bucket: Bucket, repo: string, hash: string, ): Promise { const compressed = await bucket.get(objectKey(repo, hash)); if (compressed === null) throw new BucketError(`${repo}/objects/${hash} is missing`); let bytes: Uint8Array; try { bytes = new Uint8Array(inflateSync(compressed, { maxOutputLength: MAX_OBJECT })); } catch (cause) { if (cause instanceof RangeError) { throw new OversizedObject(`${repo}/objects/${hash} inflates past 512 MiB`); } throw cause; } if (bytesToHex(blake3(bytes)) !== hash) { throw new BucketError(`${repo}/objects/${hash} does not match its hash`); } return { compressed, size: bytes.length }; } export async function listEventIds(bucket: Bucket, 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: Bucket, 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(decodeRecord(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: Bucket, 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: Bucket, 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: Bucket, 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) && typeof r.path === "string" && // A flag or clear is about one save and no path; a strip names a path, and one save or // (with no ID) all. Sync ignores a strip whose path is illegal, as Koh does (§5.5). (r.kind === "strip" || ((r.kind === "flag" || r.kind === "clear") && r.save_id !== "" && r.path === "")) && 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; } /** §5.5's legal path, clause for clause with Koh's `legal_path`: no empty, `.` or `..` segments, no backslash or control character, well-formed UTF-8. */ export function isSafePath(path: string): boolean { return ( path.isWellFormed() && !path.includes("\\") && !CONTROL_CHAR.test(path) && path.split("/").every((segment) => segment !== "" && segment !== "." && segment !== "..") ); } /** A name printable without its controls reaching the page, as Koh's `escape_path` prints it. */ export function escapePath(path: string): string { return path .replace(/[\\\p{Cc}\udc80-\udcff]/gu, (char) => { const code = char.charCodeAt(0); if (char === "\\") return "\\\\"; // A byte `decodeRecord` found outside UTF-8. if (code >= 0xdc80) return `\\x${(code - 0xdc00).toString(16)}`; const hex = code.toString(16).padStart(2, "0"); return code >= 0x80 ? `\\u00${hex}` : `\\x${hex}`; }) .toWellFormed(); } function isSaveFile(value: unknown): value is SaveFile { const f = value as Record; return ( typeof value === "object" && value !== null && typeof f.path === "string" && typeof f.hash === "string" && HEX64.test(f.hash) && typeof f.executable === "boolean" ); }