Advance WebGPU volume and bounded workflows
This commit is contained in:
@@ -36,6 +36,7 @@ export interface AssetEntryIR {
|
||||
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 {
|
||||
@@ -101,6 +102,25 @@ export function verifyAssetSource(asset: AssetEntryIR, actualSha256: string): vo
|
||||
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");
|
||||
@@ -108,16 +128,32 @@ export function parseIORequest(value: unknown): IORequestIR {
|
||||
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;
|
||||
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);
|
||||
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");
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user