203 lines
8.5 KiB
TypeScript
203 lines
8.5 KiB
TypeScript
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<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function exactKeys(value: Record<string, unknown>, 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<string> {
|
|
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<AssetPreviewIdentityIR, "identitySha256">): Promise<string> {
|
|
return sha256Bytes(encoder.encode(stableJSON(value)).buffer);
|
|
}
|
|
|
|
function parseBase(value: unknown): Omit<AssetPreviewIdentityIR, "identitySha256"> {
|
|
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<AssetPreviewIdentityIR, "identitySha256">): Promise<AssetPreviewIdentityIR> {
|
|
const parsed = parseBase(value);
|
|
return { ...parsed, identitySha256: await computeAssetPreviewIdentity(parsed) };
|
|
}
|
|
|
|
export async function parseAssetPreviewIdentity(value: unknown): Promise<AssetPreviewIdentityIR> {
|
|
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<AssetPreviewIdentityIR> {
|
|
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;
|
|
}
|