import type { ErrorCode } from "./error"; export const SIMULATION_CACHE_SCHEMA = 2 as const; export const SIMULATION_CACHE_BLENDER_VERSION_PREFIX = "5.2." as const; export const SIMULATION_CACHE_BUDGET = { maxCacheBytes: 16 * 1024 * 1024 * 1024, maxProjectCacheBytes: 16 * 1024 * 1024 * 1024, maxFrameBytes: 512 * 1024 * 1024, maxFrames: 100_000, } as const; export interface SimulationCacheLRUCandidateIR { cacheKey: string; byteLength: number; createdAt: string; lastAccessAt: string; } export interface SimulationCacheLRUPlanIR { maxBytes: number; beforeBytes: number; remainingBytes: number; removedBytes: number; cacheKeys: string[]; protectedCacheKeys: string[]; budgetSatisfied: boolean; } export interface SimulationCacheFrameIR { frame: number; byteOffset: number; byteLength: number; sha256: string; } export interface SimulationCacheManifestIR { schemaVersion: typeof SIMULATION_CACHE_SCHEMA; graphId: string; graphHash: string; sourceBlendSha256: string; sourceRevision: number; inputHash: string; revisionHash: string; cacheSha256: string; blenderVersion: string; frameStart: number; frameEnd: number; byteLength: number; frames: SimulationCacheFrameIR[]; } export interface SimulationCacheRevisionBindingIR { graphId: string; graphHash: string; sourceBlendSha256: string; sourceRevision: number; inputHash: string; blenderVersion: string; frameStart: number; frameEnd: number; } export class SimulationCacheValidationError extends Error { readonly code: ErrorCode; constructor(code: ErrorCode, message: string) { super(message); this.name = "SimulationCacheValidationError"; this.code = code; } } const SHA256 = /^[a-f0-9]{64}$/; const CACHE_KEY = /^sim2-[a-f0-9]{64}$/; function record(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function integer(value: unknown, name: string, minimum = 0, maximum = Number.MAX_SAFE_INTEGER): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) { throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `${name} must be an integer from ${minimum} to ${maximum}`); } return value; } function digest(value: unknown, name: string): string { if (typeof value !== "string" || !SHA256.test(value)) { throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `${name} must be a lowercase SHA-256 digest`); } return value; } function exactKeys(value: Record, allowed: readonly string[], name: string): void { const allowedSet = new Set(allowed); if (Object.keys(value).some((key) => !allowedSet.has(key))) { throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `${name} contains undeclared fields`); } } function boundedText(value: unknown, name: string, maximumBytes: number): string { if (typeof value !== "string" || value.length === 0 || new TextEncoder().encode(value).byteLength > maximumBytes) { throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `${name} is outside its text budget`); } return value; } function revisionBindingText(binding: SimulationCacheRevisionBindingIR): string { return JSON.stringify([ "blender-web-simulation-cache-revision-v2", binding.graphId, binding.graphHash, binding.sourceBlendSha256, String(binding.sourceRevision), binding.inputHash, binding.blenderVersion, String(binding.frameStart), String(binding.frameEnd), ]); } export async function computeSimulationCacheRevisionHash( binding: SimulationCacheRevisionBindingIR, ): Promise { const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(revisionBindingText(binding))); return Array.from(new Uint8Array(hash), (value) => value.toString(16).padStart(2, "0")).join(""); } export function parseSimulationCacheManifest(value: unknown): SimulationCacheManifestIR { if (!record(value) || value.schemaVersion !== SIMULATION_CACHE_SCHEMA) { throw new SimulationCacheValidationError("PROTOCOL_MISMATCH", "Unsupported SimulationCache manifest schema"); } exactKeys(value, [ "schemaVersion", "graphId", "graphHash", "sourceBlendSha256", "sourceRevision", "inputHash", "revisionHash", "cacheSha256", "blenderVersion", "frameStart", "frameEnd", "byteLength", "frames", ], "manifest"); const graphId = boundedText(value.graphId, "graphId", 256); const blenderVersion = boundedText(value.blenderVersion, "blenderVersion", 64); if (!blenderVersion.startsWith(SIMULATION_CACHE_BLENDER_VERSION_PREFIX)) { throw new SimulationCacheValidationError("PROTOCOL_MISMATCH", `Simulation cache requires Blender ${SIMULATION_CACHE_BLENDER_VERSION_PREFIX}x`); } const sourceRevision = integer(value.sourceRevision, "sourceRevision"); const frameStart = integer(value.frameStart, "frameStart", -1_000_000, 1_000_000); const frameEnd = integer(value.frameEnd, "frameEnd", -1_000_000, 1_000_000); const byteLength = integer(value.byteLength, "byteLength", 1); if (byteLength > SIMULATION_CACHE_BUDGET.maxCacheBytes) { throw new SimulationCacheValidationError("SIMULATION_CACHE_BUDGET_EXCEEDED", "Simulation cache exceeds the byte budget"); } if (frameEnd < frameStart || !Array.isArray(value.frames)) { throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation frame range is invalid"); } if (frameEnd - frameStart + 1 > SIMULATION_CACHE_BUDGET.maxFrames) { throw new SimulationCacheValidationError("SIMULATION_CACHE_BUDGET_EXCEEDED", "Simulation frame range exceeds the budget"); } if (value.frames.length !== frameEnd - frameStart + 1) { throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache must contain every declared frame"); } let nextOffset = 0; const frames = value.frames.map((item, index): SimulationCacheFrameIR => { if (!record(item)) throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `frames[${index}] is invalid`); exactKeys(item, ["frame", "byteOffset", "byteLength", "sha256"], `frames[${index}]`); const frame = integer(item.frame, `frames[${index}].frame`, -1_000_000, 1_000_000); const byteOffset = integer(item.byteOffset, `frames[${index}].byteOffset`); const frameByteLength = integer(item.byteLength, `frames[${index}].byteLength`, 1); if (frameByteLength > SIMULATION_CACHE_BUDGET.maxFrameBytes) { throw new SimulationCacheValidationError("SIMULATION_CACHE_BUDGET_EXCEEDED", `frames[${index}] exceeds the byte budget`); } if (frame !== frameStart + index || byteOffset !== nextOffset || byteOffset > byteLength - frameByteLength) { throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `frames[${index}] is not contiguous or ordered`); } nextOffset += frameByteLength; return { frame, byteOffset, byteLength: frameByteLength, sha256: digest(item.sha256, `frames[${index}].sha256`) }; }); if (nextOffset !== byteLength) throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation frame ranges do not cover the cache payload"); return { schemaVersion: SIMULATION_CACHE_SCHEMA, graphId, graphHash: digest(value.graphHash, "graphHash"), sourceBlendSha256: digest(value.sourceBlendSha256, "sourceBlendSha256"), sourceRevision, inputHash: digest(value.inputHash, "inputHash"), revisionHash: digest(value.revisionHash, "revisionHash"), cacheSha256: digest(value.cacheSha256, "cacheSha256"), blenderVersion, frameStart, frameEnd, byteLength, frames, }; } export async function verifySimulationCacheRevisionBinding( manifestValue: unknown, ): Promise { const manifest = parseSimulationCacheManifest(manifestValue); const computed = await computeSimulationCacheRevisionHash(manifest); if (computed !== manifest.revisionHash) { throw new SimulationCacheValidationError( "SIMULATION_CACHE_REVISION_MISMATCH", "Simulation cache revision hash does not match its graph, source, revision, inputs, and frame range", ); } return manifest; } async function sha256(data: ArrayBuffer): Promise { const hash = await crypto.subtle.digest("SHA-256", data); return Array.from(new Uint8Array(hash), (value) => value.toString(16).padStart(2, "0")).join(""); } export async function verifySimulationCache(manifestValue: unknown, data: ArrayBuffer): Promise { return verifySimulationCacheCancellable(manifestValue, data); } export async function verifySimulationCacheCancellable( manifestValue: unknown, data: ArrayBuffer, checkCancelled: () => void = () => undefined, ): Promise { checkCancelled(); const manifest = await verifySimulationCacheRevisionBinding(manifestValue); checkCancelled(); if (data.byteLength !== manifest.byteLength || await sha256(data) !== manifest.cacheSha256) { throw new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", "Simulation cache payload does not match its manifest"); } checkCancelled(); for (const frame of manifest.frames) { const bytes = data.slice(frame.byteOffset, frame.byteOffset + frame.byteLength); if (await sha256(bytes) !== frame.sha256) { throw new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", `Simulation frame ${frame.frame} failed SHA-256 verification`); } checkCancelled(); } return manifest; } export function selectSimulationCacheFrame(manifestValue: unknown, frame: number): SimulationCacheFrameIR { const manifest = parseSimulationCacheManifest(manifestValue); if (!Number.isSafeInteger(frame) || frame < manifest.frameStart || frame > manifest.frameEnd) { throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", `Simulation cache has no frame ${frame}`); } const selected = manifest.frames[frame - manifest.frameStart]; if (!selected || selected.frame !== frame) { throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", `Simulation cache has no frame ${frame}`); } return selected; } export async function verifySimulationCacheFrame( manifestValue: unknown, frame: number, data: ArrayBuffer, ): Promise { const manifest = await verifySimulationCacheRevisionBinding(manifestValue); const selected = selectSimulationCacheFrame(manifest, frame); if (data.byteLength !== selected.byteLength || await sha256(data) !== selected.sha256) { throw new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", `Simulation frame ${frame} failed SHA-256 verification`); } return selected; } export function simulationCacheKey(manifest: SimulationCacheManifestIR): string { return `sim2-${manifest.revisionHash}`; } export function planSimulationCacheLRU( candidatesValue: readonly SimulationCacheLRUCandidateIR[], maxBytesValue: number, protectedCacheKeysValue: readonly string[] = [], ): SimulationCacheLRUPlanIR { const maxBytes = integer(maxBytesValue, "maxBytes", 0, SIMULATION_CACHE_BUDGET.maxProjectCacheBytes); const seen = new Set(); const candidates = candidatesValue.map((candidate, index) => { if (!record(candidate) || !CACHE_KEY.test(candidate.cacheKey) || seen.has(candidate.cacheKey)) { throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `LRU candidate ${index} has an invalid or duplicate cache key`); } seen.add(candidate.cacheKey); const byteLength = integer(candidate.byteLength, `LRU candidate ${index} byteLength`, 1, SIMULATION_CACHE_BUDGET.maxCacheBytes); for (const field of ["createdAt", "lastAccessAt"] as const) { if (typeof candidate[field] !== "string" || !Number.isFinite(Date.parse(candidate[field]))) { throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `LRU candidate ${index} ${field} is invalid`); } } return { ...candidate, byteLength }; }); const protectedCacheKeys = [...new Set(protectedCacheKeysValue)].sort(); if (protectedCacheKeys.some((cacheKey) => !CACHE_KEY.test(cacheKey))) { throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Protected Simulation cache key is invalid"); } const protectedSet = new Set(protectedCacheKeys); const beforeBytes = candidates.reduce((total, candidate) => { const next = total + candidate.byteLength; if (!Number.isSafeInteger(next)) { throw new SimulationCacheValidationError("SIMULATION_CACHE_BUDGET_EXCEEDED", "Simulation cache LRU byte total exceeds the safe integer range"); } return next; }, 0); let remainingBytes = beforeBytes; const cacheKeys: string[] = []; const removable = candidates.filter((candidate) => !protectedSet.has(candidate.cacheKey)).sort((left, right) => left.lastAccessAt.localeCompare(right.lastAccessAt) || left.createdAt.localeCompare(right.createdAt) || left.cacheKey.localeCompare(right.cacheKey)); for (const candidate of removable) { if (remainingBytes <= maxBytes) break; cacheKeys.push(candidate.cacheKey); remainingBytes -= candidate.byteLength; } return { maxBytes, beforeBytes, remainingBytes, removedBytes: beforeBytes - remainingBytes, cacheKeys, protectedCacheKeys, budgetSatisfied: remainingBytes <= maxBytes, }; }