import { parseAssetPreviewIdentity, type AssetPreviewContentMimeType, type AssetPreviewIdentityIR, } from "./asset-preview"; import type { ErrorCode } from "./error"; export const ASSET_PREVIEW_DECODE_SCHEMA = 1 as const; export const ASSET_PREVIEW_DECODE_BUDGET = Object.freeze({ maxEncodedBytes: 16 * 1024 * 1024, maxWidth: 4096, maxHeight: 4096, maxPixels: 4096 * 4096, maxDecodedBytes: 64 * 1024 * 1024, maxCompressionRatio: 100, }); export interface AssetPreviewDecodePlanIR { schemaVersion: typeof ASSET_PREVIEW_DECODE_SCHEMA; identitySha256: string; mimeType: AssetPreviewContentMimeType; width: number; height: number; pixelCount: number; encodedByteLength: number; decodedByteLength: number; compressionRatio: number; } export class AssetPreviewDecodeError extends Error { readonly code: ErrorCode; constructor(code: ErrorCode, message: string) { super(`${code}: ${message}`); this.name = "AssetPreviewDecodeError"; this.code = code; } } function text(bytes: Uint8Array, offset: number, length: number): string { return String.fromCharCode(...bytes.subarray(offset, offset + length)); } function pngDimensions(bytes: Uint8Array): { width: number; height: number } | null { const signature = [137, 80, 78, 71, 13, 10, 26, 10]; if (bytes.byteLength < 24 || !signature.every((value, index) => bytes[index] === value)) return null; const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); if (view.getUint32(8, false) !== 13 || text(bytes, 12, 4) !== "IHDR") return null; return { width: view.getUint32(16, false), height: view.getUint32(20, false) }; } function webPDimensions(bytes: Uint8Array): { width: number; height: number } | null { if (bytes.byteLength < 30 || text(bytes, 0, 4) !== "RIFF" || text(bytes, 8, 4) !== "WEBP") return null; const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); if (view.getUint32(4, true) + 8 !== bytes.byteLength) return null; const chunk = text(bytes, 12, 4); if (chunk === "VP8X") { const width = 1 + bytes[24] + (bytes[25] << 8) + (bytes[26] << 16); const height = 1 + bytes[27] + (bytes[28] << 8) + (bytes[29] << 16); return { width, height }; } if (chunk === "VP8L" && bytes[20] === 0x2f) { const packed = view.getUint32(21, true); return { width: (packed & 0x3fff) + 1, height: ((packed >>> 14) & 0x3fff) + 1 }; } if (chunk === "VP8 " && bytes[23] === 0x9d && bytes[24] === 0x01 && bytes[25] === 0x2a) { return { width: view.getUint16(26, true) & 0x3fff, height: view.getUint16(28, true) & 0x3fff }; } return null; } function encodedDimensions(mimeType: AssetPreviewContentMimeType, bytes: Uint8Array): { width: number; height: number } { const dimensions = mimeType === "image/png" ? pngDimensions(bytes) : webPDimensions(bytes); if (!dimensions) throw new AssetPreviewDecodeError("ASSET_MANIFEST_INVALID", `encoded bytes are not a valid ${mimeType} header`); return dimensions; } async function sha256(bytes: ArrayBuffer): Promise { const result = await crypto.subtle.digest("SHA-256", bytes); return Array.from(new Uint8Array(result), (byte) => byte.toString(16).padStart(2, "0")).join(""); } function assertEncodedBudget(identity: AssetPreviewIdentityIR, encodedBytes: ArrayBuffer): void { if (!(encodedBytes instanceof ArrayBuffer)) { throw new AssetPreviewDecodeError("ASSET_MANIFEST_INVALID", "preview encoded payload must be an ArrayBuffer"); } if (identity.content.byteLength > ASSET_PREVIEW_DECODE_BUDGET.maxEncodedBytes || encodedBytes.byteLength > ASSET_PREVIEW_DECODE_BUDGET.maxEncodedBytes) { throw new AssetPreviewDecodeError("ASSET_BUDGET_EXCEEDED", "preview encoded byte length exceeds the pre-decode budget"); } if (encodedBytes.byteLength !== identity.content.byteLength) { throw new AssetPreviewDecodeError("ASSET_SOURCE_HASH_MISMATCH", "preview encoded byte length does not match the identity"); } } export async function planAssetPreviewDecode(value: unknown, encodedBytes: ArrayBuffer): Promise { const identity = await parseAssetPreviewIdentity(value); assertEncodedBudget(identity, encodedBytes); if (await sha256(encodedBytes) !== identity.content.sha256) { throw new AssetPreviewDecodeError("ASSET_SOURCE_HASH_MISMATCH", "preview encoded SHA-256 does not match the identity"); } const dimensions = encodedDimensions(identity.content.mimeType, new Uint8Array(encodedBytes)); if (dimensions.width !== identity.content.width || dimensions.height !== identity.content.height) { throw new AssetPreviewDecodeError("ASSET_MANIFEST_INVALID", "preview encoded dimensions do not match the identity"); } if (dimensions.width > ASSET_PREVIEW_DECODE_BUDGET.maxWidth || dimensions.height > ASSET_PREVIEW_DECODE_BUDGET.maxHeight) { throw new AssetPreviewDecodeError("ASSET_BUDGET_EXCEEDED", "preview dimensions exceed the pre-decode budget"); } const pixelCount = dimensions.width * dimensions.height; const decodedByteLength = pixelCount * 4; if (!Number.isSafeInteger(pixelCount) || pixelCount > ASSET_PREVIEW_DECODE_BUDGET.maxPixels || !Number.isSafeInteger(decodedByteLength) || decodedByteLength > ASSET_PREVIEW_DECODE_BUDGET.maxDecodedBytes) { throw new AssetPreviewDecodeError("ASSET_BUDGET_EXCEEDED", "preview pixel or decoded byte count exceeds the pre-decode budget"); } const compressionRatio = decodedByteLength / encodedBytes.byteLength; if (!Number.isFinite(compressionRatio) || compressionRatio > ASSET_PREVIEW_DECODE_BUDGET.maxCompressionRatio) { throw new AssetPreviewDecodeError("ASSET_BUDGET_EXCEEDED", "preview compression ratio exceeds the pre-decode budget"); } return { schemaVersion: ASSET_PREVIEW_DECODE_SCHEMA, identitySha256: identity.identitySha256, mimeType: identity.content.mimeType, width: dimensions.width, height: dimensions.height, pixelCount, encodedByteLength: encodedBytes.byteLength, decodedByteLength, compressionRatio, }; }