import type { IOFormatRuntimeIdentityIR, IOFormatRuntimeReceiptIR, IOFormatRuntimeReceiptSetIR } from "./io-format-runtime-receipt"; export const IO_FORMAT_RECEIPT_BINDING_SCHEMA = 1 as const; export const IO_FORMAT_RECEIPT_BINDING_TASK = "M12-05E" as const; export interface IOFormatReceiptBindingIR { sourceSha256: string; settingsSha256: string; runtimeSha256: string; } export interface IOFormatBoundRuntimeReceiptIR extends IOFormatRuntimeReceiptIR, IOFormatReceiptBindingIR {} export interface IOFormatBoundRuntimeReceiptSetIR { schemaVersion: typeof IO_FORMAT_RECEIPT_BINDING_SCHEMA; task: typeof IO_FORMAT_RECEIPT_BINDING_TASK; parentReceiptSetSha256: string; inventorySha256: string; runtime: IOFormatRuntimeIdentityIR; receipts: IOFormatBoundRuntimeReceiptIR[]; } export class IOFormatReceiptBindingError extends Error { readonly code = "IO_FORMAT_UNSUPPORTED" as const; constructor(message: string) { super(`IO_FORMAT_UNSUPPORTED: ${message}`); this.name = "IOFormatReceiptBindingError"; } } const SHA256 = /^[a-f0-9]{64}$/; const FORMAT_ORDER = ["GLTF", "GLB", "OBJ", "STL", "PLY", "USD", "ALEMBIC"] as const; function assertSha(value: unknown, path: string): string { if (typeof value !== "string" || !SHA256.test(value)) throw new IOFormatReceiptBindingError(`${path} is not SHA-256`); return value; } function record(value: unknown, path: string): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) throw new IOFormatReceiptBindingError(`${path} must be an object`); return value as Record; } function exactKeys(value: Record, expected: readonly string[], path: string): void { const actual = Object.keys(value).sort(); const allowed = [...expected].sort(); if (actual.length !== allowed.length || actual.some((key, index) => key !== allowed[index])) throw new IOFormatReceiptBindingError(`${path} contains undeclared fields`); } function parseReceipt(value: unknown, index: number): IOFormatBoundRuntimeReceiptIR { const path = `receipts[${index}]`; const input = record(value, path); exactKeys(input, ["format", "family", "operation", "operator", "registered", "rnaIdentifier", "buildOption", "buildOptionEnabled", "runtimeStatus", "variants", "extensions", "sourceSha256", "settingsSha256", "runtimeSha256"], path); if (typeof input.format !== "string" || !FORMAT_ORDER.includes(input.format as typeof FORMAT_ORDER[number])) throw new IOFormatReceiptBindingError(`${path}.format is invalid`); if (input.operation !== "IMPORT" && input.operation !== "EXPORT") throw new IOFormatReceiptBindingError(`${path}.operation is invalid`); if (typeof input.family !== "string" || !input.family || typeof input.operator !== "string" || !input.operator) throw new IOFormatReceiptBindingError(`${path} identity is invalid`); if (typeof input.registered !== "boolean" || (input.rnaIdentifier !== null && typeof input.rnaIdentifier !== "string") || (input.buildOption !== null && typeof input.buildOption !== "string") || (input.buildOptionEnabled !== null && typeof input.buildOptionEnabled !== "boolean")) throw new IOFormatReceiptBindingError(`${path} runtime fields are invalid`); if (input.runtimeStatus !== "AVAILABLE" && input.runtimeStatus !== "OPERATOR_UNREGISTERED") throw new IOFormatReceiptBindingError(`${path}.runtimeStatus is invalid`); if (!Array.isArray(input.variants) || !Array.isArray(input.extensions) || input.variants.length === 0 || input.extensions.length === 0 || input.variants.some((item) => typeof item !== "string") || input.extensions.some((item) => typeof item !== "string")) throw new IOFormatReceiptBindingError(`${path} variants/extensions are invalid`); return { format: input.format as IOFormatBoundRuntimeReceiptIR["format"], family: input.family, operation: input.operation as IOFormatBoundRuntimeReceiptIR["operation"], operator: input.operator, registered: input.registered, rnaIdentifier: input.rnaIdentifier as string | null, buildOption: input.buildOption as string | null, buildOptionEnabled: input.buildOptionEnabled as boolean | null, runtimeStatus: input.runtimeStatus as IOFormatBoundRuntimeReceiptIR["runtimeStatus"], variants: [...input.variants as string[]], extensions: [...input.extensions as string[]], sourceSha256: assertSha(input.sourceSha256, `${path}.sourceSha256`), settingsSha256: assertSha(input.settingsSha256, `${path}.settingsSha256`), runtimeSha256: assertSha(input.runtimeSha256, `${path}.runtimeSha256`), }; } function parseRuntime(value: unknown): IOFormatRuntimeIdentityIR { const input = record(value, "runtime"); if (!Array.isArray(input.versionTuple) || input.versionTuple.length !== 3 || input.versionTuple.some((item) => !Number.isSafeInteger(item))) throw new IOFormatReceiptBindingError("runtime.versionTuple is invalid"); if (typeof input.binarySha256 !== "string" || !SHA256.test(input.binarySha256)) throw new IOFormatReceiptBindingError("runtime.binarySha256 is invalid"); if (typeof input.blenderVersion !== "string" || typeof input.buildHash !== "string" || typeof input.buildBranch !== "string" || typeof input.buildPlatform !== "string" || typeof input.buildType !== "string" || typeof input.buildDate !== "string" || typeof input.buildTime !== "string" || !Number.isSafeInteger(input.buildCommitTimestamp) || typeof input.buildOptions !== "object" || input.buildOptions === null || Array.isArray(input.buildOptions)) throw new IOFormatReceiptBindingError("runtime identity is invalid"); return input as unknown as IOFormatRuntimeIdentityIR; } export function canonicalReceiptSource(receipt: IOFormatRuntimeReceiptIR): Record { return { format: receipt.format, family: receipt.family, operation: receipt.operation, operator: receipt.operator, registered: receipt.registered, rnaIdentifier: receipt.rnaIdentifier }; } export function canonicalReceiptSettings(receipt: IOFormatRuntimeReceiptIR): Record { return { buildOption: receipt.buildOption, buildOptionEnabled: receipt.buildOptionEnabled, variants: receipt.variants, extensions: receipt.extensions }; } export function canonicalRuntimeIdentity(runtime: IOFormatRuntimeIdentityIR): IOFormatRuntimeIdentityIR { return runtime; } export function validateIOFormatBoundReceiptSet(value: unknown, expectedParentReceiptSetSha256: string, expectedInventorySha256: string): IOFormatBoundRuntimeReceiptSetIR { const input = record(value, "input"); exactKeys(input, ["schemaVersion", "task", "parentReceiptSetSha256", "inventorySha256", "runtime", "receipts"], "input"); if (input.schemaVersion !== IO_FORMAT_RECEIPT_BINDING_SCHEMA || input.task !== IO_FORMAT_RECEIPT_BINDING_TASK) throw new IOFormatReceiptBindingError("receipt binding header is invalid"); if (!SHA256.test(expectedParentReceiptSetSha256) || !SHA256.test(expectedInventorySha256) || input.parentReceiptSetSha256 !== expectedParentReceiptSetSha256 || input.inventorySha256 !== expectedInventorySha256) throw new IOFormatReceiptBindingError("receipt binding parent identity drifted"); if (!Array.isArray(input.receipts) || input.receipts.length !== FORMAT_ORDER.length * 2) throw new IOFormatReceiptBindingError("bound receipt count is invalid"); const receipts = input.receipts.map(parseReceipt); const identities = receipts.map((receipt) => `${receipt.format}:${receipt.operation}`); if (new Set(identities).size !== identities.length || FORMAT_ORDER.some((format) => !["IMPORT", "EXPORT"].every((operation) => identities.includes(`${format}:${operation}`)))) throw new IOFormatReceiptBindingError("bound receipt identities are incomplete or duplicated"); return { schemaVersion: IO_FORMAT_RECEIPT_BINDING_SCHEMA, task: IO_FORMAT_RECEIPT_BINDING_TASK, parentReceiptSetSha256: input.parentReceiptSetSha256, inventorySha256: input.inventorySha256, runtime: parseRuntime(input.runtime), receipts }; } export function resolveBoundReceipt(receiptSet: IOFormatBoundRuntimeReceiptSetIR, format: IOFormatBoundRuntimeReceiptIR["format"], operation: IOFormatBoundRuntimeReceiptIR["operation"]): IOFormatBoundRuntimeReceiptIR { const receipt = receiptSet.receipts.find((candidate) => candidate.format === format && candidate.operation === operation); if (!receipt || !SHA256.test(receipt.sourceSha256) || !SHA256.test(receipt.settingsSha256) || !SHA256.test(receipt.runtimeSha256)) throw new IOFormatReceiptBindingError("bound runtime receipt is unavailable"); return receipt; }