Files
workinf_Blender_Wasm/web/protocol/asset-library-io.ts
2026-08-14 18:08:29 -04:00

172 lines
17 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 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<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}`);
}
async function sha256(data: ArrayBuffer): Promise<string> {
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<void> {
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<string>();
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" };
}