import { normalizeProjectAssetPath } from "./asset-path"; import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates"; import type { ErrorCode } from "./error"; export const ASSET_LIBRARY_SCHEMA = 1 as const; export const ASSET_LIBRARY_BUDGET = { maxCatalogs: 10_000, maxAssets: 100_000, maxTagsPerAsset: 128, maxLibraries: 1_024, maxDependenciesPerLibrary: 1_024, maxArchiveEntries: 100_000, maxEntryBytes: 2 * 1024 * 1024 * 1024, maxArchiveBytes: 4 * 1024 * 1024 * 1024, maxCompressionRatio: 100, maxExternalUris: 10_000, } as const; export type AssetKind = "OBJECT" | "COLLECTION" | "MATERIAL" | "WORLD" | "NODE_GROUP" | "ACTION" | "IMAGE"; export type IOFormat = "GLB" | "GLTF" | "OBJ" | "PLY" | "STL" | "USD" | "ALEMBIC"; export interface AssetCatalogIR { id: string; name: string; parentId: string | null } export interface AssetPreviewIR { assetId: string; sha256: string; mimeType: "image/png" | "image/webp"; width: number; height: number; byteLength: number } export interface AssetEntryIR { id: string; name: string; kind: AssetKind; catalogId: string | null; tags: string[]; author: string; license: string; sourceSha256: string; sourcePath?: string; preview?: AssetPreviewIR; } export interface AssetLibraryIR { id: string; name: string; sourcePath: string; sourceSha256: string; dependencyIds: string[]; readOnly: boolean } export interface AssetLibraryManifestIR { schemaVersion: typeof ASSET_LIBRARY_SCHEMA; revision: number; catalogs: AssetCatalogIR[]; assets: AssetEntryIR[]; libraries: AssetLibraryIR[] } export interface IOArchiveEntryIR { path: string; compressedBytes: number; uncompressedBytes: number } export interface IOArchiveRangeIR extends IOArchiveEntryIR { compressedOffset: number } export interface IORequestIR { format: IOFormat; operation: "IMPORT" | "EXPORT" | "ANALYZE"; sourcePath?: string; sourceSha256?: string; byteLength?: number; externalUris: string[]; archiveEntries: IOArchiveEntryIR[] } export class AssetLibraryValidationError extends Error { readonly code: ErrorCode; constructor(code: ErrorCode, message: string) { super(`${code}: ${message}`); this.name = "AssetLibraryValidationError"; this.code = code; } } const SHA256 = /^[a-f0-9]{64}$/; const KINDS = new Set(["OBJECT", "COLLECTION", "MATERIAL", "WORLD", "NODE_GROUP", "ACTION", "IMAGE"]); const FORMATS = new Set(["GLB", "GLTF", "OBJ", "PLY", "STL", "USD", "ALEMBIC"]); function record(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function text(value: unknown, name: string, maximum = 256): string { if (typeof value !== "string" || value.length === 0 || value.length > maximum) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${name} is invalid`); return value; } function integer(value: unknown, name: string, minimum: number, maximum: number): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${name} is outside the bounded range`); return value; } function digest(value: unknown, name: string): string { if (typeof value !== "string" || !SHA256.test(value)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${name} must be a lowercase SHA-256 digest`); return value; } function projectPath(value: unknown, name: string, code: ErrorCode = "ASSET_MANIFEST_INVALID"): string { try { return normalizeProjectAssetPath(text(value, name, 2048)); } catch { throw new AssetLibraryValidationError(code, `${name} is outside the project`); } } function assertAcyclic(nodes: ReadonlyMap, code: ErrorCode, label: string): void { const active = new Set(); const complete = new Set(); const visit = (id: string): void => { if (active.has(id)) throw new AssetLibraryValidationError(code, `${label} cycle includes ${id}`); if (complete.has(id)) return; active.add(id); for (const dependency of nodes.get(id) ?? []) { if (!nodes.has(dependency)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${id} references missing ${dependency}`); visit(dependency); } active.delete(id); complete.add(id); }; nodes.forEach((_dependencies, id) => visit(id)); } export function parseAssetLibraryManifest(value: unknown): AssetLibraryManifestIR { if (!record(value) || value.schemaVersion !== ASSET_LIBRARY_SCHEMA || !Array.isArray(value.catalogs) || !Array.isArray(value.assets) || !Array.isArray(value.libraries)) throw new AssetLibraryValidationError("PROTOCOL_MISMATCH", "Unsupported asset library manifest schema"); if (value.catalogs.length > ASSET_LIBRARY_BUDGET.maxCatalogs || value.assets.length > ASSET_LIBRARY_BUDGET.maxAssets || value.libraries.length > ASSET_LIBRARY_BUDGET.maxLibraries) throw new AssetLibraryValidationError("ASSET_BUDGET_EXCEEDED", "Asset manifest exceeds the collection budget"); const catalogIds = new Set(); const catalogs = value.catalogs.map((item, index): AssetCatalogIR => { if (!record(item)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `catalogs[${index}] is invalid`); const id = text(item.id, `catalogs[${index}].id`); if (catalogIds.has(id)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `Duplicate catalog ${id}`); catalogIds.add(id); return { id, name: text(item.name, `catalogs[${index}].name`), parentId: item.parentId === null ? null : text(item.parentId, `catalogs[${index}].parentId`) }; }); const catalogGraph = new Map(catalogs.map((item) => [item.id, item.parentId === null ? [] : [item.parentId]])); assertAcyclic(catalogGraph, "ASSET_MANIFEST_INVALID", "Catalog"); const assetIds = new Set(); const assets = value.assets.map((item, index): AssetEntryIR => { const name = `assets[${index}]`; if (!record(item) || !KINDS.has(item.kind as AssetKind) || !Array.isArray(item.tags)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${name} is invalid`); const id = text(item.id, `${name}.id`); if (assetIds.has(id)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `Duplicate asset ${id}`); assetIds.add(id); const catalogId = item.catalogId === null ? null : text(item.catalogId, `${name}.catalogId`); if (catalogId !== null && !catalogIds.has(catalogId)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${name} references missing catalog ${catalogId}`); if (item.tags.length > ASSET_LIBRARY_BUDGET.maxTagsPerAsset) throw new AssetLibraryValidationError("ASSET_BUDGET_EXCEEDED", `${name}.tags exceeds the budget`); const tags = item.tags.map((tag, tagIndex) => text(tag, `${name}.tags[${tagIndex}]`, 64)); if (new Set(tags).size !== tags.length) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${name}.tags has duplicates`); const license = typeof item.license === "string" ? item.license.trim() : ""; if (!license) throw new AssetLibraryValidationError("ASSET_LICENSE_MISSING", `${name} has no license metadata`); const asset: AssetEntryIR = { id, name: text(item.name, `${name}.name`), kind: item.kind as AssetKind, catalogId, tags, author: text(item.author, `${name}.author`), license, sourceSha256: digest(item.sourceSha256, `${name}.sourceSha256`) }; if (item.sourcePath !== undefined) asset.sourcePath = projectPath(item.sourcePath, `${name}.sourcePath`); if (item.preview !== undefined) { const preview = item.preview; if (!record(preview) || !["image/png", "image/webp"].includes(preview.mimeType as string)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${name}.preview is invalid`); asset.preview = { assetId: text(preview.assetId, `${name}.preview.assetId`), sha256: digest(preview.sha256, `${name}.preview.sha256`), mimeType: preview.mimeType as AssetPreviewIR["mimeType"], width: integer(preview.width, `${name}.preview.width`, 1, 4096), height: integer(preview.height, `${name}.preview.height`, 1, 4096), byteLength: integer(preview.byteLength, `${name}.preview.byteLength`, 1, 64 * 1024 * 1024) }; } return asset; }); const libraryIds = new Set(); const libraries = value.libraries.map((item, index): AssetLibraryIR => { const name = `libraries[${index}]`; if (!record(item) || !Array.isArray(item.dependencyIds) || item.dependencyIds.length > ASSET_LIBRARY_BUDGET.maxDependenciesPerLibrary) throw new AssetLibraryValidationError("ASSET_BUDGET_EXCEEDED", `${name} is invalid or exceeds the dependency budget`); const id = text(item.id, `${name}.id`); if (libraryIds.has(id)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `Duplicate library ${id}`); libraryIds.add(id); const dependencyIds = item.dependencyIds.map((dependency, dependencyIndex) => text(dependency, `${name}.dependencyIds[${dependencyIndex}]`)); if (new Set(dependencyIds).size !== dependencyIds.length) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${name}.dependencyIds has duplicates`); return { id, name: text(item.name, `${name}.name`), sourcePath: projectPath(item.sourcePath, `${name}.sourcePath`), sourceSha256: digest(item.sourceSha256, `${name}.sourceSha256`), dependencyIds, readOnly: item.readOnly !== false }; }); assertAcyclic(new Map(libraries.map((item) => [item.id, item.dependencyIds])), "LIBRARY_DEPENDENCY_CYCLE", "Library dependency"); return { schemaVersion: ASSET_LIBRARY_SCHEMA, revision: integer(value.revision, "revision", 0, Number.MAX_SAFE_INTEGER), catalogs, assets, libraries }; } export function libraryLoadOrder(value: unknown): string[] { const manifest = parseAssetLibraryManifest(value); const byId = new Map(manifest.libraries.map((item) => [item.id, item])); const complete = new Set(); const result: string[] = []; const visit = (id: string): void => { if (complete.has(id)) return; for (const dependency of byId.get(id)?.dependencyIds ?? []) visit(dependency); complete.add(id); result.push(id); }; [...byId.keys()].sort().forEach(visit); return result; } export function verifyAssetSource(asset: AssetEntryIR, actualSha256: string): void { if (!SHA256.test(actualSha256) || actualSha256 !== asset.sourceSha256) throw new AssetLibraryValidationError("ASSET_SOURCE_HASH_MISMATCH", `Source hash does not match ${asset.id}`); } async function sha256(data: ArrayBuffer): Promise { const digest = await crypto.subtle.digest("SHA-256", data); return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join(""); } export async function verifyAssetPreview(preview: AssetPreviewIR, data: ArrayBuffer): Promise { if (!(data instanceof ArrayBuffer) || data.byteLength !== preview.byteLength || await sha256(data) !== preview.sha256) throw new AssetLibraryValidationError("ASSET_SOURCE_HASH_MISMATCH", `Preview hash or byte length does not match ${preview.assetId}`); const bytes = new Uint8Array(data); if (preview.mimeType === "image/png") { if (bytes.length < 24 || ![137, 80, 78, 71, 13, 10, 26, 10].every((value, index) => bytes[index] === value)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${preview.assetId} is not PNG data`); const view = new DataView(data); if (view.getUint32(16, false) !== preview.width || view.getUint32(20, false) !== preview.height) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${preview.assetId} PNG dimensions do not match the manifest`); } else { const riff = bytes.length >= 30 && String.fromCharCode(...bytes.subarray(0, 4)) === "RIFF" && String.fromCharCode(...bytes.subarray(8, 12)) === "WEBP"; if (!riff) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${preview.assetId} is not WebP data`); } } export function parseIORequest(value: unknown): IORequestIR { if (!record(value) || !FORMATS.has(value.format as IOFormat) || !["IMPORT", "EXPORT", "ANALYZE"].includes(value.operation as string) || !Array.isArray(value.externalUris) || !Array.isArray(value.archiveEntries)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", "IO request is invalid"); if (value.externalUris.length > ASSET_LIBRARY_BUDGET.maxExternalUris || value.archiveEntries.length > ASSET_LIBRARY_BUDGET.maxArchiveEntries) throw new AssetLibraryValidationError("ASSET_BUDGET_EXCEEDED", "IO request exceeds the resource budget"); const request: IORequestIR = { format: value.format as IOFormat, operation: value.operation as IORequestIR["operation"], externalUris: value.externalUris.map((uri, index) => projectPath(uri, `externalUris[${index}]`, "IO_EXTERNAL_URI_BLOCKED")), archiveEntries: [] }; if (value.sourcePath !== undefined) request.sourcePath = projectPath(value.sourcePath, "sourcePath", "IO_EXTERNAL_URI_BLOCKED"); if (value.sourceSha256 !== undefined) request.sourceSha256 = digest(value.sourceSha256, "sourceSha256"); if (value.byteLength !== undefined) request.byteLength = integer(value.byteLength, "byteLength", 0, ASSET_LIBRARY_BUDGET.maxArchiveBytes); let totalCompressed = 0; let totalUncompressed = 0; const archivePaths = new Set(); request.archiveEntries = value.archiveEntries.map((entry, index): IOArchiveEntryIR => { if (!record(entry)) throw new AssetLibraryValidationError("IO_ARCHIVE_UNSAFE", `archiveEntries[${index}] is invalid`); const path = projectPath(entry.path, `archiveEntries[${index}].path`, "IO_ARCHIVE_UNSAFE"); const compressedBytes = integer(entry.compressedBytes, `archiveEntries[${index}].compressedBytes`, 0, ASSET_LIBRARY_BUDGET.maxEntryBytes); const uncompressedBytes = integer(entry.uncompressedBytes, `archiveEntries[${index}].uncompressedBytes`, 0, ASSET_LIBRARY_BUDGET.maxEntryBytes); if (archivePaths.has(path) || [...archivePaths].some((existing) => existing.startsWith(`${path}/`) || path.startsWith(`${existing}/`))) throw new AssetLibraryValidationError("IO_ARCHIVE_UNSAFE", `Archive path ${path} is duplicated or conflicts with a file prefix`); archivePaths.add(path); totalCompressed += compressedBytes; totalUncompressed += uncompressedBytes; if (!Number.isSafeInteger(totalCompressed) || !Number.isSafeInteger(totalUncompressed) || totalCompressed > ASSET_LIBRARY_BUDGET.maxArchiveBytes || totalUncompressed > ASSET_LIBRARY_BUDGET.maxArchiveBytes || (uncompressedBytes > 0 && (compressedBytes === 0 || uncompressedBytes / compressedBytes > ASSET_LIBRARY_BUDGET.maxCompressionRatio))) throw new AssetLibraryValidationError("IO_ARCHIVE_UNSAFE", "Archive expansion exceeds the byte or compression-ratio budget"); return { path, compressedBytes, uncompressedBytes }; }); if (request.byteLength !== undefined && totalCompressed > request.byteLength) throw new AssetLibraryValidationError("IO_ARCHIVE_UNSAFE", "Archive compressed entries exceed the declared source byte length"); return request; } /** Builds a deterministic bounded range plan; it does not decode or trust an archive container. */ export function planIOArchiveRanges(value: unknown): IOArchiveRangeIR[] { const request = parseIORequest(value); let compressedOffset = 0; return [...request.archiveEntries].sort((left, right) => left.path.localeCompare(right.path)).map((entry) => { const range = { ...entry, compressedOffset }; compressedOffset += entry.compressedBytes; if (!Number.isSafeInteger(compressedOffset) || compressedOffset > ASSET_LIBRARY_BUDGET.maxArchiveBytes) throw new AssetLibraryValidationError("IO_ARCHIVE_UNSAFE", "Archive range offset exceeds the byte budget"); return range; }); } export function gateIORequest(value: unknown): CapabilityGateResult { const request = parseIORequest(value); const capability = `${request.format}_${request.operation}`; if ((request.format === "GLB" && (request.operation === "ANALYZE" || request.operation === "EXPORT")) || (request.format === "USD" && request.operation === "ANALYZE")) return readyGate("N-023", capability); return blockedGate("N-023", capability, [capabilityIssue("IO_FORMAT_UNSUPPORTED", `${capability} has no verified local or server executor`)]); } export function gateLibraryMutation(operation: "CATALOG_EDIT" | "APPEND" | "LINK" | "OVERRIDE" | "RELOAD" | "RELOCATE"): CapabilityGateResult { if (operation === "CATALOG_EDIT") return readyGate("N-023", operation); return blockedGate("N-023", operation, [capabilityIssue("LIBRARY_MUTATION_UNAVAILABLE", `${operation} requires a real Blender Main transaction`)]); } export function assetStorageCapabilities(scope: typeof globalThis = globalThis): { contentAddressedIndex: "LOCAL_BOUNDED"; opfs: "PROBE_REQUIRED" | "UNAVAILABLE" } { const storage = (scope.navigator as Navigator & { storage?: { getDirectory?: unknown } } | undefined)?.storage; return { contentAddressedIndex: "LOCAL_BOUNDED", opfs: storage && typeof storage.getDirectory === "function" ? "PROBE_REQUIRED" : "UNAVAILABLE" }; }