repo.ts

14.6 KB · raw

1import { inflateSync } from "node:zlib";2import { blake3 } from "@noble/hashes/blake3.js";3import { bytesToHex } from "@noble/hashes/utils.js";4import type { Bucket } from "./reader.ts";56export type SaveFile = { path: string; hash: string; executable: boolean };78/** The save record Koh 3 writes to `<repo>/saves/<id>` (§9.2). */9export 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};2324/** A flag, clear or strip event, as Koh 3 writes it to `<repo>/events/<id>` (§9.2, §10). */25export 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};3435/** One built file of a release (§11.2). */36export type ReleaseFile = {37	platform: string;38	name: string;39	size: number;40	blake3: string;41	sha256: string;42};4344/** The release record Koh 3 writes to `<repo>/releases/<version>/release.json` (§11.2). */45export 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};5556/** §11.1's platform names, Kepr 2's exact list: Peach Android and existing build files depend on them. */57export 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;6667/** A save record as it arrived, less the files §5.5 quarantines, named here escaped. */68export type ArrivingSave = SaveRecord & { quarantined: string[] };6970/** An object's stored zlib bytes, verified against its hash, and its inflated size. */71export type VerifiedObject = { compressed: Uint8Array; size: number };7273export class BucketError extends Error {}7475/** An object that inflates past Koh's own file limit; the paths pointing at it are quarantined. */76export class OversizedObject extends BucketError {}7778const MAX_OBJECT = 512 * 1024 * 1024;7980// Twelve hex characters, or six: Koh 2's early saves keep their shorter IDs through migration81// (§30, 2026-09-23).82export const SAVE_ID = /^(?:[0-9a-f]{6}|[0-9a-f]{12})$/;83const REPO_NAME = /^[A-Za-z0-9._-]+$/;84const CONTROL_CHAR = /\p{Cc}/u;85const HEX64 = /^[0-9a-f]{64}$/;86const VERSION = /^[A-Za-z0-9.-]{1,32}$/;87// A file name that is safe as one key segment and inside a quoted Content-Disposition filename.88const FILE_NAME = /^[^/\\"\p{Cc}]{1,255}$/u;89const decoder = new TextDecoder();90// A run of whole UTF-8 sequences (RFC 3629), or one byte outside them, over a record read one91// character per byte.92const UTF8_RUN =93	/((?:[^\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;94// A surrogate pair escape, a lone surrogate escape, or any other escape (`\\` included), whole.95const JSON_ESCAPE =96	/\\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;9798/**99 * Every repo in the bucket: a top-level folder with a `head`. A folder without one is not a repo100 * yet, as when a first offer or a rename (which copies `head` last) is still under way.101 */102export async function listRepos(bucket: Bucket): Promise<string[]> {103	const names = (await bucket.folders()).filter(isRepoName);104	const heads = await Promise.all(names.map((name) => bucket.get(`${name}/head`)));105	return names.filter((_, i) => heads[i] !== null);106}107108function isRepoName(name: string): boolean {109	return REPO_NAME.test(name) && name !== "." && name !== "..";110}111112// Checked here rather than in route handlers, so no caller can build a key from unchecked input.113function requireRepo(repo: string): void {114	if (!isRepoName(repo)) throw new BucketError(`not a repo name: "${repo}"`);115}116117function requireSaveId(id: string): void {118	if (!SAVE_ID.test(id)) throw new BucketError(`not a save ID: "${id}"`);119}120121/** Applies §9.2's format rule: a missing key is version 1; anything but `koh-remote 1` is refused. */122export async function checkFormat(bucket: Bucket, repo: string): Promise<void> {123	requireRepo(repo);124	const bytes = await bucket.get(`${repo}/format`);125	if (bytes === null) return;126	const format = decoder.decode(bytes).trim();127	if (format !== "koh-remote 1") {128		throw new BucketError(`${repo} uses remote format "${format}"; update Kepr to read it`);129	}130}131132export async function readHead(bucket: Bucket, repo: string): Promise<string | null> {133	requireRepo(repo);134	const bytes = await bucket.get(`${repo}/head`);135	if (bytes === null) return null;136	const head = decoder.decode(bytes).trim();137	if (!SAVE_ID.test(head)) throw new BucketError(`${repo}/head is not a save ID: "${head}"`);138	return head;139}140141export async function readSave(bucket: Bucket, repo: string, id: string): Promise<ArrivingSave> {142	requireRepo(repo);143	requireSaveId(id);144	const bytes = await bucket.get(`${repo}/saves/${id}`);145	if (bytes === null) throw new BucketError(`${repo}/saves/${id} is missing`);146	const record = parseSaveRecord(JSON.parse(decodeRecord(bytes)));147	if (record.id !== id) throw new BucketError(`${repo}/saves/${id} holds save ${record.id}`);148	return {149		...record,150		files: record.files.filter((file) => isSafePath(file.path)),151		quarantined: record.files152			.filter((file) => !isSafePath(file.path))153			.map((file) => escapePath(file.path)),154	};155}156157/**158 * A record's text, read as Koh reads it (\u00A75.5): an unpaired surrogate escape stands for its three159 * bytes, and each byte outside a valid UTF-8 sequence becomes the lone surrogate U+DC00 + byte, so160 * the path holding it fails `isWellFormed` while a genuine U+FFFD stays legal.161 */162function decodeRecord(bytes: Uint8Array): string {163	const text = Buffer.from(bytes)164		.toString("latin1")165		.replace(JSON_ESCAPE, (sequence) => {166			if (sequence.length !== 6 || sequence[1] !== "u") return sequence;167			const unit = Number.parseInt(sequence.slice(2), 16);168			return String.fromCharCode(169				0xe0 | (unit >> 12),170				0x80 | ((unit >> 6) & 0x3f),171				0x80 | (unit & 0x3f),172			);173		});174	return text.replace(UTF8_RUN, (run, valid: string | undefined) =>175		valid === undefined176			? String.fromCharCode(0xdc00 + run.charCodeAt(0))177			: decoder.decode(Buffer.from(valid, "latin1")),178	);179}180181function objectKey(repo: string, hash: string): string {182	requireRepo(repo);183	if (!HEX64.test(hash)) throw new BucketError(`not an object hash: "${hash}"`);184	return `${repo}/objects/${hash}`;185}186187/** An object's stored, compressed size, asked without reading it. */188export function objectSize(bucket: Bucket, repo: string, hash: string): Promise<number> {189	return bucket.size(objectKey(repo, hash));190}191192/**193 * Fetches an object, inflates it and checks its Blake3 against its key, as steal does (§9.4). The194 * inflated bytes never leave this call, so reads in parallel hold one inflated object at a time.195 */196export async function readObject(197	bucket: Bucket,198	repo: string,199	hash: string,200): Promise<VerifiedObject> {201	const compressed = await bucket.get(objectKey(repo, hash));202	if (compressed === null) throw new BucketError(`${repo}/objects/${hash} is missing`);203	let bytes: Uint8Array;204	try {205		bytes = new Uint8Array(inflateSync(compressed, { maxOutputLength: MAX_OBJECT }));206	} catch (cause) {207		if (cause instanceof RangeError) {208			throw new OversizedObject(`${repo}/objects/${hash} inflates past 512 MiB`);209		}210		throw cause;211	}212	if (bytesToHex(blake3(bytes)) !== hash) {213		throw new BucketError(`${repo}/objects/${hash} does not match its hash`);214	}215	return { compressed, size: bytes.length };216}217218export async function listEventIds(bucket: Bucket, repo: string): Promise<string[]> {219	requireRepo(repo);220	const prefix = `${repo}/events/`;221	// A folder remote's half-written temp files can show up in a listing; only IDs are events.222	return (await bucket.list(prefix))223		.map((key) => key.slice(prefix.length))224		.filter((id) => SAVE_ID.test(id));225}226227export async function readEvent(bucket: Bucket, repo: string, id: string): Promise<EventRecord> {228	requireRepo(repo);229	requireSaveId(id);230	const bytes = await bucket.get(`${repo}/events/${id}`);231	if (bytes === null) throw new BucketError(`${repo}/events/${id} is missing`);232	const record = parseEventRecord(JSON.parse(decodeRecord(bytes)));233	if (record.id !== id) throw new BucketError(`${repo}/events/${id} holds event ${record.id}`);234	return record;235}236237/** §11.1's version rule, minus `.` and `..`, which would name a folder rather than a version. */238export function isVersion(value: string): boolean {239	return VERSION.test(value) && value !== "." && value !== "..";240}241242export function isPlatform(value: string): boolean {243	return (PLATFORMS as readonly string[]).includes(value);244}245246/** The body of `<repo>/releases/latest`, trimmed, or null when the repo has never released. */247export async function readReleasesLatest(bucket: Bucket, repo: string): Promise<string | null> {248	requireRepo(repo);249	const bytes = await bucket.get(`${repo}/releases/latest`);250	if (bytes === null) return null;251	const version = decoder.decode(bytes).trim();252	if (!isVersion(version))253		throw new BucketError(`${repo}/releases/latest is not a version: "${version}"`);254	return version;255}256257/** Every version with a `release.json`: a version without one doesn't exist (§11.2). */258export async function listReleaseVersions(bucket: Bucket, repo: string): Promise<string[]> {259	requireRepo(repo);260	return (await bucket.list(`${repo}/releases/`))261		.map((key) => key.split("/"))262		.filter((parts) => parts.length === 4 && parts[3] === "release.json")263		.map((parts) => parts[2] as string)264		.filter(isVersion);265}266267export async function readRelease(268	bucket: Bucket,269	repo: string,270	version: string,271): Promise<ReleaseRecord> {272	requireRelease(repo, version);273	const key = `${repo}/releases/${version}/release.json`;274	const bytes = await bucket.get(key);275	if (bytes === null) throw new BucketError(`${key} is missing`);276	return checkRelease(repo, version, JSON.parse(decoder.decode(bytes)));277}278279/** A parsed `<repo>/releases/<version>/release.json`, if the sync would accept it (§11.2). */280export function checkRelease(repo: string, version: string, value: unknown): ReleaseRecord {281	requireRelease(repo, version);282	const record = parseReleaseRecord(value);283	if (record.repo !== repo || record.version !== version) {284		throw new BucketError(285			`${repo}/releases/${version}/release.json holds ${record.repo} ${record.version}`,286		);287	}288	return record;289}290291function requireRelease(repo: string, version: string): void {292	requireRepo(repo);293	if (!isVersion(version)) throw new BucketError(`not a release version: "${version}"`);294}295296function parseReleaseRecord(value: unknown): ReleaseRecord {297	const r = value as Record<string, unknown>;298	const valid =299		typeof value === "object" &&300		value !== null &&301		r.format === 1 &&302		typeof r.repo === "string" &&303		typeof r.version === "string" &&304		typeof r.save === "string" &&305		SAVE_ID.test(r.save) &&306		isCount(r.published_at) &&307		typeof r.machine === "string" &&308		typeof r.notes === "string" &&309		Array.isArray(r.files) &&310		r.files.every(isReleaseFile) &&311		// One file per platform per version (§11.2).312		new Set(r.files.map((file) => file.platform)).size === r.files.length;313	if (!valid) throw new BucketError("release record does not match §11.2");314	return value as ReleaseRecord;315}316317function isReleaseFile(value: unknown): value is ReleaseFile {318	const f = value as Record<string, unknown>;319	return (320		typeof value === "object" &&321		value !== null &&322		typeof f.platform === "string" &&323		isPlatform(f.platform) &&324		typeof f.name === "string" &&325		FILE_NAME.test(f.name) &&326		f.name !== "." &&327		f.name !== ".." &&328		isCount(f.size) &&329		typeof f.blake3 === "string" &&330		HEX64.test(f.blake3) &&331		typeof f.sha256 === "string" &&332		HEX64.test(f.sha256)333	);334}335336function parseSaveRecord(value: unknown): SaveRecord {337	const r = value as Record<string, unknown>;338	const valid =339		typeof value === "object" &&340		value !== null &&341		["message", "machine", "agent", "session"].every((key) => typeof r[key] === "string") &&342		typeof r.id === "string" &&343		SAVE_ID.test(r.id) &&344		["parent", "restores", "replayed_from"].every((key) => isOptionalSaveId(r[key])) &&345		isCount(r.created_at) &&346		isCount(r.size_bytes) &&347		typeof r.is_checkpoint === "boolean" &&348		Array.isArray(r.files) &&349		r.files.every(isSaveFile) &&350		new Set(r.files.map((file) => file.path)).size === r.files.length;351	if (!valid) throw new BucketError("save record does not match the Koh 3 schema");352	return value as SaveRecord;353}354355function parseEventRecord(value: unknown): EventRecord {356	const r = value as Record<string, unknown>;357	const valid =358		typeof value === "object" &&359		value !== null &&360		typeof r.id === "string" &&361		SAVE_ID.test(r.id) &&362		isOptionalSaveId(r.save_id) &&363		typeof r.path === "string" &&364		// A flag or clear is about one save and no path; a strip names a path, and one save or365		// (with no ID) all. Sync ignores a strip whose path is illegal, as Koh does (§5.5).366		(r.kind === "strip" ||367			((r.kind === "flag" || r.kind === "clear") && r.save_id !== "" && r.path === "")) &&368		typeof r.reason === "string" &&369		typeof r.machine === "string" &&370		isCount(r.created_at);371	if (!valid) throw new BucketError("event record does not match the Koh 3 schema");372	return value as EventRecord;373}374375function isOptionalSaveId(value: unknown): boolean {376	return value === "" || (typeof value === "string" && SAVE_ID.test(value));377}378379function isCount(value: unknown): boolean {380	return Number.isSafeInteger(value) && (value as number) >= 0;381}382383/** §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. */384export function isSafePath(path: string): boolean {385	return (386		path.isWellFormed() &&387		!path.includes("\\") &&388		!CONTROL_CHAR.test(path) &&389		path.split("/").every((segment) => segment !== "" && segment !== "." && segment !== "..")390	);391}392393/** A name printable without its controls reaching the page, as Koh's `escape_path` prints it. */394export function escapePath(path: string): string {395	return path396		.replace(/[\\\p{Cc}\udc80-\udcff]/gu, (char) => {397			const code = char.charCodeAt(0);398			if (char === "\\") return "\\\\";399			// A byte `decodeRecord` found outside UTF-8.400			if (code >= 0xdc80) return `\\x${(code - 0xdc00).toString(16)}`;401			const hex = code.toString(16).padStart(2, "0");402			return code >= 0x80 ? `\\u00${hex}` : `\\x${hex}`;403		})404		.toWellFormed();405}406407function isSaveFile(value: unknown): value is SaveFile {408	const f = value as Record<string, unknown>;409	return (410		typeof value === "object" &&411		value !== null &&412		typeof f.path === "string" &&413		typeof f.hash === "string" &&414		HEX64.test(f.hash) &&415		typeof f.executable === "boolean"416	);417}