Files
workinf_Blender_Wasm/web/protocol/simulation-cache.ts

163 lines
7.2 KiB
TypeScript

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<string, unknown> {
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<string> {
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<SimulationCacheManifestIR> {
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<SimulationCacheFrameIR> {
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}`;
}