repo.ts

11.3 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/** An object's stored zlib bytes and the inflated bytes they were verified from. */68export 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}$/;77// A file name that is safe as one key segment and inside a quoted Content-Disposition filename.78const FILE_NAME = /^[^/\\"\p{Cc}]{1,255}$/u;79const decoder = new TextDecoder();8081// ponytail: one full listing per call; switch to a delimiter listing if the bucket grows large.82export 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}9394// Checked here rather than in route handlers, so no caller can build a key from unchecked input.95function 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}102103/** Applies §9.2's format rule: a missing key is version 1; anything but `koh-remote 1` is refused. */104export 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}136137/** Fetches an object, inflates it and checks its Blake3 against its key, as steal does (§9.4). */138export 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	// A folder remote's half-written temp files can show up in a listing; only IDs are events.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}176177/** §11.1's version rule, minus `.` and `..`, which would name a folder rather than a version. */178export 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}185186/** The body of `<repo>/releases/latest`, trimmed, or null when the repo has never released. */187export 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	if (bytes === null) return null;194	const version = decoder.decode(bytes).trim();195	if (!isVersion(version))196		throw new BucketError(`${repo}/releases/latest is not a version: "${version}"`);197	return version;198}199200/** Every version with a `release.json`: a version without one doesn't exist (§11.2). */201export async function listReleaseVersions(bucket: BucketReader, repo: string): Promise<string[]> {202	requireRepo(repo);203	return (await bucket.list(`${repo}/releases/`))204		.map((key) => key.split("/"))205		.filter((parts) => parts.length === 4 && parts[3] === "release.json")206		.map((parts) => parts[2] as string)207		.filter(isVersion);208}209210export async function readRelease(211	bucket: BucketReader,212	repo: string,213	version: string,214): Promise<ReleaseRecord> {215	requireRelease(repo, version);216	const key = `${repo}/releases/${version}/release.json`;217	const bytes = await bucket.get(key);218	if (bytes === null) throw new BucketError(`${key} is missing`);219	return checkRelease(repo, version, JSON.parse(decoder.decode(bytes)));220}221222/** A parsed `<repo>/releases/<version>/release.json`, if the sync would accept it (§11.2). */223export function checkRelease(repo: string, version: string, value: unknown): ReleaseRecord {224	requireRelease(repo, version);225	const record = parseReleaseRecord(value);226	if (record.repo !== repo || record.version !== version) {227		throw new BucketError(228			`${repo}/releases/${version}/release.json holds ${record.repo} ${record.version}`,229		);230	}231	return record;232}233234function requireRelease(repo: string, version: string): void {235	requireRepo(repo);236	if (!isVersion(version)) throw new BucketError(`not a release version: "${version}"`);237}238239function parseReleaseRecord(value: unknown): ReleaseRecord {240	const r = value as Record<string, unknown>;241	const valid =242		typeof value === "object" &&243		value !== null &&244		r.format === 1 &&245		typeof r.repo === "string" &&246		typeof r.version === "string" &&247		typeof r.save === "string" &&248		SAVE_ID.test(r.save) &&249		isCount(r.published_at) &&250		typeof r.machine === "string" &&251		typeof r.notes === "string" &&252		Array.isArray(r.files) &&253		r.files.every(isReleaseFile) &&254		// One file per platform per version (§11.2).255		new Set(r.files.map((file) => file.platform)).size === r.files.length;256	if (!valid) throw new BucketError("release record does not match §11.2");257	return value as ReleaseRecord;258}259260function isReleaseFile(value: unknown): value is ReleaseFile {261	const f = value as Record<string, unknown>;262	return (263		typeof value === "object" &&264		value !== null &&265		typeof f.platform === "string" &&266		isPlatform(f.platform) &&267		typeof f.name === "string" &&268		FILE_NAME.test(f.name) &&269		f.name !== "." &&270		f.name !== ".." &&271		isCount(f.size) &&272		typeof f.blake3 === "string" &&273		HEX64.test(f.blake3) &&274		typeof f.sha256 === "string" &&275		HEX64.test(f.sha256)276	);277}278279function parseSaveRecord(value: unknown): SaveRecord {280	const r = value as Record<string, unknown>;281	const valid =282		typeof value === "object" &&283		value !== null &&284		["message", "machine", "agent", "session"].every((key) => typeof r[key] === "string") &&285		typeof r.id === "string" &&286		SAVE_ID.test(r.id) &&287		["parent", "restores", "replayed_from"].every((key) => isOptionalSaveId(r[key])) &&288		isCount(r.created_at) &&289		isCount(r.size_bytes) &&290		typeof r.is_checkpoint === "boolean" &&291		Array.isArray(r.files) &&292		r.files.every(isSaveFile) &&293		new Set(r.files.map((file) => file.path)).size === r.files.length;294	if (!valid) throw new BucketError("save record does not match the Koh 3 schema");295	return value as SaveRecord;296}297298function parseEventRecord(value: unknown): EventRecord {299	const r = value as Record<string, unknown>;300	const valid =301		typeof value === "object" &&302		value !== null &&303		typeof r.id === "string" &&304		SAVE_ID.test(r.id) &&305		isOptionalSaveId(r.save_id) &&306		(r.path === "" || (typeof r.path === "string" && isSafePath(r.path))) &&307		// A flag or clear is about one save and no path; a strip names a path, and one save or308		// (with no ID) all.309		(r.kind === "strip"310			? r.path !== ""311			: (r.kind === "flag" || r.kind === "clear") && r.save_id !== "" && r.path === "") &&312		typeof r.reason === "string" &&313		typeof r.machine === "string" &&314		isCount(r.created_at);315	if (!valid) throw new BucketError("event record does not match the Koh 3 schema");316	return value as EventRecord;317}318319function isOptionalSaveId(value: unknown): boolean {320	return value === "" || (typeof value === "string" && SAVE_ID.test(value));321}322323function isCount(value: unknown): boolean {324	return Number.isSafeInteger(value) && (value as number) >= 0;325}326327/** A repo-relative path that can't escape or alias: no empty, `.` or `..` segments, no backslashes or control characters. */328export function isSafePath(path: string): boolean {329	return (330		!path.includes("\\") &&331		!CONTROL_CHAR.test(path) &&332		path.split("/").every((segment) => segment !== "" && segment !== "." && segment !== "..")333	);334}335336function isSaveFile(value: unknown): value is SaveFile {337	const f = value as Record<string, unknown>;338	return (339		typeof value === "object" &&340		value !== null &&341		typeof f.path === "string" &&342		isSafePath(f.path) &&343		typeof f.hash === "string" &&344		HEX64.test(f.hash) &&345		typeof f.executable === "boolean"346	);347}