import type { ErrorCode } from "./error"; export const SIMULATION_CACHE_SCHEMA = 1 as const; export const SIMULATION_CACHE_BLENDER_VERSION_PREFIX = "5.2." as const; export const SIMULATION_CACHE_BUDGET = { maxCacheBytes: 16 * 1024 * 1024 * 1024, maxFrameBytes: 512 * 1024 * 1024, maxFrames: 100_000, } as const; 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; inputHash: string; cacheSha256: string; blenderVersion: string; frameStart: number; frameEnd: number; byteLength: number; frames: SimulationCacheFrameIR[]; } 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}$/; function record(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function integer(value: unknown, name: string, minimum = 0): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum) { throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `${name} must be an integer >= ${minimum}`); } 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; } export function parseSimulationCacheManifest(value: unknown): SimulationCacheManifestIR { if (!record(value) || value.schemaVersion !== SIMULATION_CACHE_SCHEMA) { throw new SimulationCacheValidationError("PROTOCOL_MISMATCH", "Unsupported SimulationCache manifest schema"); } for (const name of ["graphId", "blenderVersion"] as const) { if (typeof value[name] !== "string" || value[name].length === 0) { throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `${name} is required`); } } if (!(value.blenderVersion as string).startsWith(SIMULATION_CACHE_BLENDER_VERSION_PREFIX)) { throw new SimulationCacheValidationError("PROTOCOL_MISMATCH", `Simulation cache requires Blender ${SIMULATION_CACHE_BLENDER_VERSION_PREFIX}x`); } const frameStart = integer(value.frameStart, "frameStart", -1_000_000); const frameEnd = integer(value.frameEnd, "frameEnd", -1_000_000); const byteLength = integer(value.byteLength, "byteLength", 1); if (byteLength > SIMULATION_CACHE_BUDGET.maxCacheBytes) { throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation cache exceeds the byte budget"); } if (frameEnd < frameStart || frameEnd - frameStart + 1 > SIMULATION_CACHE_BUDGET.maxFrames || !Array.isArray(value.frames)) { throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation frame range is invalid"); } 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`); const frame = integer(item.frame, `frames[${index}].frame`, -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_INVALID", `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: value.graphId as string, graphHash: digest(value.graphHash, "graphHash"), sourceBlendSha256: digest(value.sourceBlendSha256, "sourceBlendSha256"), inputHash: digest(value.inputHash, "inputHash"), cacheSha256: digest(value.cacheSha256, "cacheSha256"), blenderVersion: value.blenderVersion as string, frameStart, frameEnd, byteLength, frames, }; } 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 { const manifest = parseSimulationCacheManifest(manifestValue); 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"); } 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`); } } 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 selected = selectSimulationCacheFrame(manifestValue, 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 `${manifest.graphHash.slice(0, 16)}-${manifest.sourceBlendSha256.slice(0, 16)}-${manifest.inputHash.slice(0, 16)}-${manifest.frameStart}-${manifest.frameEnd}`; }