Files
workinf_Blender_Wasm/web/protocol/asset-library-io.ts
2026-08-12 04:47:48 -04:00

136 lines
14 KiB
TypeScript

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 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<AssetKind>(["OBJECT", "COLLECTION", "MATERIAL", "WORLD", "NODE_GROUP", "ACTION", "IMAGE"]);
const FORMATS = new Set<IOFormat>(["GLB", "GLTF", "OBJ", "PLY", "STL", "USD", "ALEMBIC"]);
function record(value: unknown): value is Record<string, unknown> { 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<string, readonly string[]>, code: ErrorCode, label: string): void {
const active = new Set<string>(); const complete = new Set<string>();
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<string>();
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<string>();
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<string>();
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<string>(); 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}`);
}
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 totalUncompressed = 0;
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);
totalUncompressed += uncompressedBytes; if (!Number.isSafeInteger(totalUncompressed) || 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 };
});
return request;
}
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" };
}