163 lines
7.2 KiB
TypeScript
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}`;
|
|
}
|