132 lines
5.9 KiB
TypeScript
132 lines
5.9 KiB
TypeScript
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<string> {
|
|
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<AssetPreviewDecodePlanIR> {
|
|
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,
|
|
};
|
|
}
|