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();888990const UTF8_RUN =91 /((?:[^\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;9293const JSON_ESCAPE =94 /\\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;959697export async function listRepos(bucket: BucketReader): Promise<string[]> {98 const keys = await bucket.list("");99 return keys100 .filter((key) => key.endsWith("/head") && key.split("/").length === 2)101 .map((key) => key.slice(0, -"/head".length))102 .filter(isRepoName);103}104105function isRepoName(name: string): boolean {106 return REPO_NAME.test(name) && name !== "." && name !== "..";107}108109110function requireRepo(repo: string): void {111 if (!isRepoName(repo)) throw new BucketError(`not a repo name: "${repo}"`);112}113114function requireSaveId(id: string): void {115 if (!SAVE_ID.test(id)) throw new BucketError(`not a save ID: "${id}"`);116}117118119export async function checkFormat(bucket: BucketReader, repo: string): Promise<void> {120 requireRepo(repo);121 const bytes = await bucket.get(`${repo}/format`);122 if (bytes === null) return;123 const format = decoder.decode(bytes).trim();124 if (format !== "koh-remote 1") {125 throw new BucketError(`${repo} uses remote format "${format}"; update Kepr to read it`);126 }127}128129export async function readHead(bucket: BucketReader, repo: string): Promise<string | null> {130 requireRepo(repo);131 const bytes = await bucket.get(`${repo}/head`);132 if (bytes === null) return null;133 const head = decoder.decode(bytes).trim();134 if (!SAVE_ID.test(head)) throw new BucketError(`${repo}/head is not a save ID: "${head}"`);135 return head;136}137138export async function readSave(139 bucket: BucketReader,140 repo: string,141 id: string,142): Promise<ArrivingSave> {143 requireRepo(repo);144 requireSaveId(id);145 const bytes = await bucket.get(`${repo}/saves/${id}`);146 if (bytes === null) throw new BucketError(`${repo}/saves/${id} is missing`);147 const record = parseSaveRecord(JSON.parse(decodeRecord(bytes)));148 if (record.id !== id) throw new BucketError(`${repo}/saves/${id} holds save ${record.id}`);149 return {150 ...record,151 files: record.files.filter((file) => isSafePath(file.path)),152 quarantined: record.files153 .filter((file) => !isSafePath(file.path))154 .map((file) => escapePath(file.path)),155 };156}157158159160161162163function decodeRecord(bytes: Uint8Array): string {164 const text = Buffer.from(bytes)165 .toString("latin1")166 .replace(JSON_ESCAPE, (sequence) => {167 if (sequence.length !== 6 || sequence[1] !== "u") return sequence;168 const unit = Number.parseInt(sequence.slice(2), 16);169 return String.fromCharCode(170 0xe0 | (unit >> 12),171 0x80 | ((unit >> 6) & 0x3f),172 0x80 | (unit & 0x3f),173 );174 });175 return text.replace(UTF8_RUN, (run, valid: string | undefined) =>176 valid === undefined177 ? String.fromCharCode(0xdc00 + run.charCodeAt(0))178 : decoder.decode(Buffer.from(valid, "latin1")),179 );180}181182183export async function readObject(184 bucket: BucketReader,185 repo: string,186 hash: string,187): Promise<VerifiedObject> {188 requireRepo(repo);189 if (!HEX64.test(hash)) throw new BucketError(`not an object hash: "${hash}"`);190 const compressed = await bucket.get(`${repo}/objects/${hash}`);191 if (compressed === null) throw new BucketError(`${repo}/objects/${hash} is missing`);192 let bytes: Uint8Array;193 try {194 bytes = new Uint8Array(inflateSync(compressed, { maxOutputLength: MAX_OBJECT }));195 } catch (cause) {196 if (cause instanceof RangeError) {197 throw new OversizedObject(`${repo}/objects/${hash} inflates past 512 MiB`);198 }199 throw cause;200 }201 if (bytesToHex(blake3(bytes)) !== hash) {202 throw new BucketError(`${repo}/objects/${hash} does not match its hash`);203 }204 return { compressed, bytes };205}206207export async function listEventIds(bucket: BucketReader, repo: string): Promise<string[]> {208 requireRepo(repo);209 const prefix = `${repo}/events/`;210 211 return (await bucket.list(prefix))212 .map((key) => key.slice(prefix.length))213 .filter((id) => SAVE_ID.test(id));214}215216export async function readEvent(217 bucket: BucketReader,218 repo: string,219 id: string,220): Promise<EventRecord> {221 requireRepo(repo);222 requireSaveId(id);223 const bytes = await bucket.get(`${repo}/events/${id}`);224 if (bytes === null) throw new BucketError(`${repo}/events/${id} is missing`);225 const record = parseEventRecord(JSON.parse(decodeRecord(bytes)));226 if (record.id !== id) throw new BucketError(`${repo}/events/${id} holds event ${record.id}`);227 return record;228}229230231export function isVersion(value: string): boolean {232 return VERSION.test(value) && value !== "." && value !== "..";233}234235export function isPlatform(value: string): boolean {236 return (PLATFORMS as readonly string[]).includes(value);237}238239240export async function readReleasesLatest(241 bucket: BucketReader,242 repo: string,243): Promise<string | null> {244 requireRepo(repo);245 const bytes = await bucket.get(`${repo}/releases/latest`);246 if (bytes === null) return null;247 const version = decoder.decode(bytes).trim();248 if (!isVersion(version))249 throw new BucketError(`${repo}/releases/latest is not a version: "${version}"`);250 return version;251}252253254export async function listReleaseVersions(bucket: BucketReader, repo: string): Promise<string[]> {255 requireRepo(repo);256 return (await bucket.list(`${repo}/releases/`))257 .map((key) => key.split("/"))258 .filter((parts) => parts.length === 4 && parts[3] === "release.json")259 .map((parts) => parts[2] as string)260 .filter(isVersion);261}262263export async function readRelease(264 bucket: BucketReader,265 repo: string,266 version: string,267): Promise<ReleaseRecord> {268 requireRelease(repo, version);269 const key = `${repo}/releases/${version}/release.json`;270 const bytes = await bucket.get(key);271 if (bytes === null) throw new BucketError(`${key} is missing`);272 return checkRelease(repo, version, JSON.parse(decoder.decode(bytes)));273}274275276export function checkRelease(repo: string, version: string, value: unknown): ReleaseRecord {277 requireRelease(repo, version);278 const record = parseReleaseRecord(value);279 if (record.repo !== repo || record.version !== version) {280 throw new BucketError(281 `${repo}/releases/${version}/release.json holds ${record.repo} ${record.version}`,282 );283 }284 return record;285}286287function requireRelease(repo: string, version: string): void {288 requireRepo(repo);289 if (!isVersion(version)) throw new BucketError(`not a release version: "${version}"`);290}291292function parseReleaseRecord(value: unknown): ReleaseRecord {293 const r = value as Record<string, unknown>;294 const valid =295 typeof value === "object" &&296 value !== null &&297 r.format === 1 &&298 typeof r.repo === "string" &&299 typeof r.version === "string" &&300 typeof r.save === "string" &&301 SAVE_ID.test(r.save) &&302 isCount(r.published_at) &&303 typeof r.machine === "string" &&304 typeof r.notes === "string" &&305 Array.isArray(r.files) &&306 r.files.every(isReleaseFile) &&307 308 new Set(r.files.map((file) => file.platform)).size === r.files.length;309 if (!valid) throw new BucketError("release record does not match §11.2");310 return value as ReleaseRecord;311}312313function isReleaseFile(value: unknown): value is ReleaseFile {314 const f = value as Record<string, unknown>;315 return (316 typeof value === "object" &&317 value !== null &&318 typeof f.platform === "string" &&319 isPlatform(f.platform) &&320 typeof f.name === "string" &&321 FILE_NAME.test(f.name) &&322 f.name !== "." &&323 f.name !== ".." &&324 isCount(f.size) &&325 typeof f.blake3 === "string" &&326 HEX64.test(f.blake3) &&327 typeof f.sha256 === "string" &&328 HEX64.test(f.sha256)329 );330}331332function parseSaveRecord(value: unknown): SaveRecord {333 const r = value as Record<string, unknown>;334 const valid =335 typeof value === "object" &&336 value !== null &&337 ["message", "machine", "agent", "session"].every((key) => typeof r[key] === "string") &&338 typeof r.id === "string" &&339 SAVE_ID.test(r.id) &&340 ["parent", "restores", "replayed_from"].every((key) => isOptionalSaveId(r[key])) &&341 isCount(r.created_at) &&342 isCount(r.size_bytes) &&343 typeof r.is_checkpoint === "boolean" &&344 Array.isArray(r.files) &&345 r.files.every(isSaveFile) &&346 new Set(r.files.map((file) => file.path)).size === r.files.length;347 if (!valid) throw new BucketError("save record does not match the Koh 3 schema");348 return value as SaveRecord;349}350351function parseEventRecord(value: unknown): EventRecord {352 const r = value as Record<string, unknown>;353 const valid =354 typeof value === "object" &&355 value !== null &&356 typeof r.id === "string" &&357 SAVE_ID.test(r.id) &&358 isOptionalSaveId(r.save_id) &&359 typeof r.path === "string" &&360 361 362 (r.kind === "strip" ||363 ((r.kind === "flag" || r.kind === "clear") && r.save_id !== "" && r.path === "")) &&364 typeof r.reason === "string" &&365 typeof r.machine === "string" &&366 isCount(r.created_at);367 if (!valid) throw new BucketError("event record does not match the Koh 3 schema");368 return value as EventRecord;369}370371function isOptionalSaveId(value: unknown): boolean {372 return value === "" || (typeof value === "string" && SAVE_ID.test(value));373}374375function isCount(value: unknown): boolean {376 return Number.isSafeInteger(value) && (value as number) >= 0;377}378379380export function isSafePath(path: string): boolean {381 return (382 path.isWellFormed() &&383 !path.includes("\\") &&384 !CONTROL_CHAR.test(path) &&385 path.split("/").every((segment) => segment !== "" && segment !== "." && segment !== "..")386 );387}388389390export function escapePath(path: string): string {391 return path392 .replace(/[\\\p{Cc}\udc80-\udcff]/gu, (char) => {393 const code = char.charCodeAt(0);394 if (char === "\\") return "\\\\";395 396 if (code >= 0xdc80) return `\\x${(code - 0xdc00).toString(16)}`;397 const hex = code.toString(16).padStart(2, "0");398 return code >= 0x80 ? `\\u00${hex}` : `\\x${hex}`;399 })400 .toWellFormed();401}402403function isSaveFile(value: unknown): value is SaveFile {404 const f = value as Record<string, unknown>;405 return (406 typeof value === "object" &&407 value !== null &&408 typeof f.path === "string" &&409 typeof f.hash === "string" &&410 HEX64.test(f.hash) &&411 typeof f.executable === "boolean"412 );413}