import type { ErrorCode } from "./error"; export const ASSET_PREVIEW_IDENTITY_SCHEMA = 1 as const; export const ASSET_PREVIEW_SOURCE_MIME_TYPES = [ "image/png", "image/webp", "application/x-blender-preview-rgba8", ] as const; export const ASSET_PREVIEW_CONTENT_MIME_TYPES = ["image/png", "image/webp"] as const; export type AssetPreviewSourceMimeType = (typeof ASSET_PREVIEW_SOURCE_MIME_TYPES)[number]; export type AssetPreviewContentMimeType = (typeof ASSET_PREVIEW_CONTENT_MIME_TYPES)[number]; export interface AssetPreviewSourceIdentityIR { mimeType: AssetPreviewSourceMimeType; byteLength: number; sha256: string; } export interface AssetPreviewContentIdentityIR { mimeType: AssetPreviewContentMimeType; byteLength: number; sha256: string; width: number; height: number; pixelFormat: "RGBA8"; colorSpace: "SRGB"; alphaMode: "STRAIGHT" | "PREMULTIPLIED"; } export interface AssetPreviewGeneratorIdentityIR { name: string; version: string; executableSha256: string; scriptSha256: string; settingsSha256: string; } export interface AssetPreviewIdentityIR { schemaVersion: typeof ASSET_PREVIEW_IDENTITY_SCHEMA; assetId: string; slot: "PREVIEW"; source: AssetPreviewSourceIdentityIR; content: AssetPreviewContentIdentityIR; generator: AssetPreviewGeneratorIdentityIR; identitySha256: string; } export class AssetPreviewIdentityError extends Error { readonly code: ErrorCode; constructor(code: ErrorCode, message: string) { super(`${code}: ${message}`); this.name = "AssetPreviewIdentityError"; this.code = code; } } const SHA256 = /^[a-f0-9]{64}$/; const encoder = new TextEncoder(); function record(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function exactKeys(value: Record, allowed: readonly string[], path: string): void { const keys = new Set(allowed); if (Object.keys(value).some((key) => !keys.has(key))) { throw new AssetPreviewIdentityError("ASSET_MANIFEST_INVALID", `${path} contains unknown fields`); } } function text(value: unknown, path: string, maximum = 256): string { if (typeof value !== "string" || value.length === 0 || encoder.encode(value).byteLength > maximum) { throw new AssetPreviewIdentityError("ASSET_MANIFEST_INVALID", `${path} is invalid`); } return value; } function digest(value: unknown, path: string): string { if (typeof value !== "string" || !SHA256.test(value)) { throw new AssetPreviewIdentityError("ASSET_MANIFEST_INVALID", `${path} must be a lowercase SHA-256 digest`); } return value; } function positiveInteger(value: unknown, path: string): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) { throw new AssetPreviewIdentityError("ASSET_MANIFEST_INVALID", `${path} must be a positive safe integer`); } return value; } function parseSource(value: unknown): AssetPreviewSourceIdentityIR { if (!record(value)) throw new AssetPreviewIdentityError("ASSET_MANIFEST_INVALID", "source is invalid"); exactKeys(value, ["mimeType", "byteLength", "sha256"], "source"); if (!ASSET_PREVIEW_SOURCE_MIME_TYPES.includes(value.mimeType as AssetPreviewSourceMimeType)) { throw new AssetPreviewIdentityError("ASSET_MANIFEST_INVALID", "source.mimeType is unsupported"); } return { mimeType: value.mimeType as AssetPreviewSourceMimeType, byteLength: positiveInteger(value.byteLength, "source.byteLength"), sha256: digest(value.sha256, "source.sha256"), }; } function parseContent(value: unknown): AssetPreviewContentIdentityIR { if (!record(value)) throw new AssetPreviewIdentityError("ASSET_MANIFEST_INVALID", "content is invalid"); exactKeys(value, ["mimeType", "byteLength", "sha256", "width", "height", "pixelFormat", "colorSpace", "alphaMode"], "content"); if (!ASSET_PREVIEW_CONTENT_MIME_TYPES.includes(value.mimeType as AssetPreviewContentMimeType) || value.pixelFormat !== "RGBA8" || value.colorSpace !== "SRGB" || !["STRAIGHT", "PREMULTIPLIED"].includes(value.alphaMode as string)) { throw new AssetPreviewIdentityError("ASSET_MANIFEST_INVALID", "content encoding profile is unsupported"); } return { mimeType: value.mimeType as AssetPreviewContentMimeType, byteLength: positiveInteger(value.byteLength, "content.byteLength"), sha256: digest(value.sha256, "content.sha256"), width: positiveInteger(value.width, "content.width"), height: positiveInteger(value.height, "content.height"), pixelFormat: "RGBA8", colorSpace: "SRGB", alphaMode: value.alphaMode as AssetPreviewContentIdentityIR["alphaMode"], }; } function parseGenerator(value: unknown): AssetPreviewGeneratorIdentityIR { if (!record(value)) throw new AssetPreviewIdentityError("ASSET_MANIFEST_INVALID", "generator is invalid"); exactKeys(value, ["name", "version", "executableSha256", "scriptSha256", "settingsSha256"], "generator"); return { name: text(value.name, "generator.name", 128), version: text(value.version, "generator.version", 64), executableSha256: digest(value.executableSha256, "generator.executableSha256"), scriptSha256: digest(value.scriptSha256, "generator.scriptSha256"), settingsSha256: digest(value.settingsSha256, "generator.settingsSha256"), }; } function stableJSON(value: unknown): string { if (Array.isArray(value)) return `[${value.map(stableJSON).join(",")}]`; if (record(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJSON(value[key])}`).join(",")}}`; return JSON.stringify(value); } async function sha256Bytes(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(""); } export async function computeAssetPreviewIdentity(value: Omit): Promise { return sha256Bytes(encoder.encode(stableJSON(value)).buffer); } function parseBase(value: unknown): Omit { if (!record(value) || value.schemaVersion !== ASSET_PREVIEW_IDENTITY_SCHEMA || value.slot !== "PREVIEW") { throw new AssetPreviewIdentityError("PROTOCOL_MISMATCH", "unsupported asset preview identity schema or slot"); } return { schemaVersion: ASSET_PREVIEW_IDENTITY_SCHEMA, assetId: text(value.assetId, "assetId", 256), slot: "PREVIEW", source: parseSource(value.source), content: parseContent(value.content), generator: parseGenerator(value.generator), }; } export async function createAssetPreviewIdentity(value: Omit): Promise { const parsed = parseBase(value); return { ...parsed, identitySha256: await computeAssetPreviewIdentity(parsed) }; } export async function parseAssetPreviewIdentity(value: unknown): Promise { if (!record(value)) throw new AssetPreviewIdentityError("ASSET_MANIFEST_INVALID", "preview identity is invalid"); exactKeys(value, ["schemaVersion", "assetId", "slot", "source", "content", "generator", "identitySha256"], "preview"); const parsed = parseBase(value); const identitySha256 = digest(value.identitySha256, "identitySha256"); if (await computeAssetPreviewIdentity(parsed) !== identitySha256) { throw new AssetPreviewIdentityError("ASSET_PREVIEW_IDENTITY_MISMATCH", "preview identity hash does not match its fields"); } return { ...parsed, identitySha256 }; } export async function verifyAssetPreviewIdentity( value: unknown, sourceBytes: ArrayBuffer, contentBytes: ArrayBuffer, generator: AssetPreviewGeneratorIdentityIR, ): Promise { const identity = await parseAssetPreviewIdentity(value); const parsedGenerator = parseGenerator(generator); if (stableJSON(parsedGenerator) !== stableJSON(identity.generator)) { throw new AssetPreviewIdentityError("ASSET_PREVIEW_IDENTITY_MISMATCH", "preview generator identity changed"); } if (!(sourceBytes instanceof ArrayBuffer) || sourceBytes.byteLength !== identity.source.byteLength || await sha256Bytes(sourceBytes) !== identity.source.sha256) { throw new AssetPreviewIdentityError("ASSET_SOURCE_HASH_MISMATCH", "preview source bytes do not match the source identity"); } if (!(contentBytes instanceof ArrayBuffer) || contentBytes.byteLength !== identity.content.byteLength || await sha256Bytes(contentBytes) !== identity.content.sha256) { throw new AssetPreviewIdentityError("ASSET_SOURCE_HASH_MISMATCH", "preview content bytes do not match the content identity"); } return identity; }