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 };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; size: number };7273export class BucketError extends Error {}747576export class OversizedObject extends BucketError {}7778const MAX_OBJECT = 512 * 1024 * 1024;79808182export 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}$/;8788const FILE_NAME = /^[^/\\"\p{Cc}]{1,255}$/u;89const decoder = new TextDecoder();909192const 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;9495const 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;979899100101102export 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}111112113function 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}120121122export 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}156157158159160161162function 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}186187188export function objectSize(bucket: Bucket, repo: string, hash: string): Promise<number> {189 return bucket.size(objectKey(repo, hash));190}191192193194195196export 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 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}236237238export 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}245246247export 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}256257258export 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}278279280export 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 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 365 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}382383384export 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}392393394export 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 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}