repo.ts

13.1 KB · raw

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 };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 and the inflated bytes they were verified from. */71export type VerifiedObject = { compressed: Uint8Array; bytes: Uint8Array };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;7980const SAVE_ID = /^[0-9a-f]{12}$/;81const REPO_NAME = /^[A-Za-z0-9._-]+$/;82const CONTROL_CHAR = /\p{Cc}/u;83const HEX64 = /^[0-9a-f]{64}$/;84const VERSION = /^[A-Za-z0-9.-]{1,32}$/;85// A file name that is safe as one key segment and inside a quoted Content-Disposition filename.86const FILE_NAME = /^[^/\\"\p{Cc}]{1,255}$/u;87const decoder = new TextDecoder();88const strictDecoder = new TextDecoder("utf-8", { fatal: true });8990// ponytail: one full listing per call; switch to a delimiter listing if the bucket grows large.91export async function listRepos(bucket: BucketReader): Promise<string[]> {92	const keys = await bucket.list("");93	return keys94		.filter((key) => key.endsWith("/head") && key.split("/").length === 2)95		.map((key) => key.slice(0, -"/head".length))96		.filter(isRepoName);97}9899function isRepoName(name: string): boolean {100	return REPO_NAME.test(name) && name !== "." && name !== "..";101}102103// Checked here rather than in route handlers, so no caller can build a key from unchecked input.104function requireRepo(repo: string): void {105	if (!isRepoName(repo)) throw new BucketError(`not a repo name: "${repo}"`);106}107108function requireSaveId(id: string): void {109	if (!SAVE_ID.test(id)) throw new BucketError(`not a save ID: "${id}"`);110}111112/** Applies §9.2's format rule: a missing key is version 1; anything but `koh-remote 1` is refused. */113export async function checkFormat(bucket: BucketReader, repo: string): Promise<void> {114	requireRepo(repo);115	const bytes = await bucket.get(`${repo}/format`);116	if (bytes === null) return;117	const format = decoder.decode(bytes).trim();118	if (format !== "koh-remote 1") {119		throw new BucketError(`${repo} uses remote format "${format}"; update Kepr to read it`);120	}121}122123export async function readHead(bucket: BucketReader, repo: string): Promise<string | null> {124	requireRepo(repo);125	const bytes = await bucket.get(`${repo}/head`);126	if (bytes === null) return null;127	const head = decoder.decode(bytes).trim();128	if (!SAVE_ID.test(head)) throw new BucketError(`${repo}/head is not a save ID: "${head}"`);129	return head;130}131132export async function readSave(133	bucket: BucketReader,134	repo: string,135	id: string,136): Promise<ArrivingSave> {137	requireRepo(repo);138	requireSaveId(id);139	const bytes = await bucket.get(`${repo}/saves/${id}`);140	if (bytes === null) throw new BucketError(`${repo}/saves/${id} is missing`);141	// Koh reads a record's bytes as they are and refuses a path that is not UTF-8. Decoding turns142	// such bytes into U+FFFD, so only in a record that fails strict decoding does U+FFFD mark one.143	// ponytail: a genuine U+FFFD name in that same broken record is quarantined too; Koh never144	// writes such a record, so byte-exact recovery waits until one is seen.145	let text: string;146	let lossy = false;147	try {148		text = strictDecoder.decode(bytes);149	} catch {150		text = decoder.decode(bytes);151		lossy = true;152	}153	const record = parseSaveRecord(JSON.parse(text));154	if (record.id !== id) throw new BucketError(`${repo}/saves/${id} holds save ${record.id}`);155	const legal = (path: string) => isSafePath(path) && !(lossy && path.includes("\uFFFD"));156	return {157		...record,158		files: record.files.filter((file) => legal(file.path)),159		quarantined: record.files160			.filter((file) => !legal(file.path))161			.map((file) => escapePath(file.path)),162	};163}164165/** Fetches an object, inflates it and checks its Blake3 against its key, as steal does (§9.4). */166export async function readObject(167	bucket: BucketReader,168	repo: string,169	hash: string,170): Promise<VerifiedObject> {171	requireRepo(repo);172	if (!HEX64.test(hash)) throw new BucketError(`not an object hash: "${hash}"`);173	const compressed = await bucket.get(`${repo}/objects/${hash}`);174	if (compressed === null) throw new BucketError(`${repo}/objects/${hash} is missing`);175	let bytes: Uint8Array;176	try {177		bytes = new Uint8Array(inflateSync(compressed, { maxOutputLength: MAX_OBJECT }));178	} catch (cause) {179		if (cause instanceof RangeError) {180			throw new OversizedObject(`${repo}/objects/${hash} inflates past 512 MiB`);181		}182		throw cause;183	}184	if (bytesToHex(blake3(bytes)) !== hash) {185		throw new BucketError(`${repo}/objects/${hash} does not match its hash`);186	}187	return { compressed, bytes };188}189190export async function listEventIds(bucket: BucketReader, repo: string): Promise<string[]> {191	requireRepo(repo);192	const prefix = `${repo}/events/`;193	// A folder remote's half-written temp files can show up in a listing; only IDs are events.194	return (await bucket.list(prefix))195		.map((key) => key.slice(prefix.length))196		.filter((id) => SAVE_ID.test(id));197}198199export async function readEvent(200	bucket: BucketReader,201	repo: string,202	id: string,203): Promise<EventRecord> {204	requireRepo(repo);205	requireSaveId(id);206	const bytes = await bucket.get(`${repo}/events/${id}`);207	if (bytes === null) throw new BucketError(`${repo}/events/${id} is missing`);208	const record = parseEventRecord(JSON.parse(decoder.decode(bytes)));209	if (record.id !== id) throw new BucketError(`${repo}/events/${id} holds event ${record.id}`);210	return record;211}212213/** §11.1's version rule, minus `.` and `..`, which would name a folder rather than a version. */214export function isVersion(value: string): boolean {215	return VERSION.test(value) && value !== "." && value !== "..";216}217218export function isPlatform(value: string): boolean {219	return (PLATFORMS as readonly string[]).includes(value);220}221222/** The body of `<repo>/releases/latest`, trimmed, or null when the repo has never released. */223export async function readReleasesLatest(224	bucket: BucketReader,225	repo: string,226): Promise<string | null> {227	requireRepo(repo);228	const bytes = await bucket.get(`${repo}/releases/latest`);229	if (bytes === null) return null;230	const version = decoder.decode(bytes).trim();231	if (!isVersion(version))232		throw new BucketError(`${repo}/releases/latest is not a version: "${version}"`);233	return version;234}235236/** Every version with a `release.json`: a version without one doesn't exist (§11.2). */237export async function listReleaseVersions(bucket: BucketReader, repo: string): Promise<string[]> {238	requireRepo(repo);239	return (await bucket.list(`${repo}/releases/`))240		.map((key) => key.split("/"))241		.filter((parts) => parts.length === 4 && parts[3] === "release.json")242		.map((parts) => parts[2] as string)243		.filter(isVersion);244}245246export async function readRelease(247	bucket: BucketReader,248	repo: string,249	version: string,250): Promise<ReleaseRecord> {251	requireRelease(repo, version);252	const key = `${repo}/releases/${version}/release.json`;253	const bytes = await bucket.get(key);254	if (bytes === null) throw new BucketError(`${key} is missing`);255	return checkRelease(repo, version, JSON.parse(decoder.decode(bytes)));256}257258/** A parsed `<repo>/releases/<version>/release.json`, if the sync would accept it (§11.2). */259export function checkRelease(repo: string, version: string, value: unknown): ReleaseRecord {260	requireRelease(repo, version);261	const record = parseReleaseRecord(value);262	if (record.repo !== repo || record.version !== version) {263		throw new BucketError(264			`${repo}/releases/${version}/release.json holds ${record.repo} ${record.version}`,265		);266	}267	return record;268}269270function requireRelease(repo: string, version: string): void {271	requireRepo(repo);272	if (!isVersion(version)) throw new BucketError(`not a release version: "${version}"`);273}274275function parseReleaseRecord(value: unknown): ReleaseRecord {276	const r = value as Record<string, unknown>;277	const valid =278		typeof value === "object" &&279		value !== null &&280		r.format === 1 &&281		typeof r.repo === "string" &&282		typeof r.version === "string" &&283		typeof r.save === "string" &&284		SAVE_ID.test(r.save) &&285		isCount(r.published_at) &&286		typeof r.machine === "string" &&287		typeof r.notes === "string" &&288		Array.isArray(r.files) &&289		r.files.every(isReleaseFile) &&290		// One file per platform per version (§11.2).291		new Set(r.files.map((file) => file.platform)).size === r.files.length;292	if (!valid) throw new BucketError("release record does not match §11.2");293	return value as ReleaseRecord;294}295296function isReleaseFile(value: unknown): value is ReleaseFile {297	const f = value as Record<string, unknown>;298	return (299		typeof value === "object" &&300		value !== null &&301		typeof f.platform === "string" &&302		isPlatform(f.platform) &&303		typeof f.name === "string" &&304		FILE_NAME.test(f.name) &&305		f.name !== "." &&306		f.name !== ".." &&307		isCount(f.size) &&308		typeof f.blake3 === "string" &&309		HEX64.test(f.blake3) &&310		typeof f.sha256 === "string" &&311		HEX64.test(f.sha256)312	);313}314315function parseSaveRecord(value: unknown): SaveRecord {316	const r = value as Record<string, unknown>;317	const valid =318		typeof value === "object" &&319		value !== null &&320		["message", "machine", "agent", "session"].every((key) => typeof r[key] === "string") &&321		typeof r.id === "string" &&322		SAVE_ID.test(r.id) &&323		["parent", "restores", "replayed_from"].every((key) => isOptionalSaveId(r[key])) &&324		isCount(r.created_at) &&325		isCount(r.size_bytes) &&326		typeof r.is_checkpoint === "boolean" &&327		Array.isArray(r.files) &&328		r.files.every(isSaveFile) &&329		new Set(r.files.map((file) => file.path)).size === r.files.length;330	if (!valid) throw new BucketError("save record does not match the Koh 3 schema");331	return value as SaveRecord;332}333334function parseEventRecord(value: unknown): EventRecord {335	const r = value as Record<string, unknown>;336	const valid =337		typeof value === "object" &&338		value !== null &&339		typeof r.id === "string" &&340		SAVE_ID.test(r.id) &&341		isOptionalSaveId(r.save_id) &&342		(r.path === "" || (typeof r.path === "string" && isSafePath(r.path))) &&343		// A flag or clear is about one save and no path; a strip names a path, and one save or344		// (with no ID) all.345		(r.kind === "strip"346			? r.path !== ""347			: (r.kind === "flag" || r.kind === "clear") && r.save_id !== "" && r.path === "") &&348		typeof r.reason === "string" &&349		typeof r.machine === "string" &&350		isCount(r.created_at);351	if (!valid) throw new BucketError("event record does not match the Koh 3 schema");352	return value as EventRecord;353}354355function isOptionalSaveId(value: unknown): boolean {356	return value === "" || (typeof value === "string" && SAVE_ID.test(value));357}358359function isCount(value: unknown): boolean {360	return Number.isSafeInteger(value) && (value as number) >= 0;361}362363/** §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. */364export function isSafePath(path: string): boolean {365	return (366		path.isWellFormed() &&367		!path.includes("\\") &&368		!CONTROL_CHAR.test(path) &&369		path.split("/").every((segment) => segment !== "" && segment !== "." && segment !== "..")370	);371}372373/** A name printable without its controls reaching the page, as Koh's `escape_path` prints it. */374export function escapePath(path: string): string {375	return path376		.replace(/[\\\p{Cc}]/gu, (char) => {377			const code = char.charCodeAt(0);378			if (char === "\\") return "\\\\";379			const hex = code.toString(16).padStart(2, "0");380			return code >= 0x80 ? `\\u00${hex}` : `\\x${hex}`;381		})382		.toWellFormed();383}384385function isSaveFile(value: unknown): value is SaveFile {386	const f = value as Record<string, unknown>;387	return (388		typeof value === "object" &&389		value !== null &&390		typeof f.path === "string" &&391		typeof f.hash === "string" &&392		HEX64.test(f.hash) &&393		typeof f.executable === "boolean"394	);395}