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 };789export 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};232425export 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};343536export type ReleaseFile = {37 platform: string;38 name: string;39 size: number;40 blake3: string;41 sha256: string;42};434445export 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};555657export 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;666768export type ArrivingSave = SaveRecord & { quarantined: string[] };697071export type VerifiedObject = { compressed: Uint8Array; bytes: Uint8Array };7273export class BucketError extends Error {}747576export 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}$/;8586const FILE_NAME = /^[^/\\"\p{Cc}]{1,255}$/u;87const decoder = new TextDecoder();88const strictDecoder = new TextDecoder("utf-8", { fatal: true });899091export 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}102103104function 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}111112113export 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 142 143 144 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}164165166export 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 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}212213214export 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}221222223export 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}235236237export 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}257258259export 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 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 344 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}362363364export 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}372373374export 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}