211 lines
9.5 KiB
TypeScript
211 lines
9.5 KiB
TypeScript
import type { ErrorCode } from "./error";
|
|
import { normalizeProjectAssetPath } from "./asset-path";
|
|
import type { StorageAssetPutResult, StorageAssetReadResult } from "./storage";
|
|
|
|
export const EXTERNAL_VFONT_SCHEMA_VERSION = 1 as const;
|
|
export const EXTERNAL_VFONT_MAX_BYTES = 32 * 1024 * 1024;
|
|
|
|
export type ExternalVFontFormat = "TTF" | "OTF" | "PFB";
|
|
|
|
export interface ExternalVFontImportRequestIR {
|
|
sourcePath: string;
|
|
mimeType: string;
|
|
byteLength: number;
|
|
sha256: string;
|
|
data: ArrayBuffer;
|
|
}
|
|
|
|
export interface ValidatedExternalVFontIR {
|
|
schemaVersion: typeof EXTERNAL_VFONT_SCHEMA_VERSION;
|
|
sourcePath: string;
|
|
fileName: string;
|
|
format: ExternalVFontFormat;
|
|
mimeType: string;
|
|
byteLength: number;
|
|
sha256: string;
|
|
data: ArrayBuffer;
|
|
}
|
|
|
|
export interface ExternalVFontMainImportProofIR {
|
|
schemaVersion: typeof EXTERNAL_VFONT_SCHEMA_VERSION;
|
|
projectId: string;
|
|
assetId: string;
|
|
assetPath: string;
|
|
sourcePath: string;
|
|
name: string;
|
|
format: ExternalVFontFormat;
|
|
mimeType: string;
|
|
byteLength: number;
|
|
sha256: string;
|
|
}
|
|
|
|
export interface ExternalVFontMainImportIR extends ExternalVFontMainImportProofIR {
|
|
data: ArrayBuffer;
|
|
}
|
|
|
|
export class ExternalVFontValidationError extends Error {
|
|
constructor(readonly code: ErrorCode, message: string) {
|
|
super(`${code}: ${message}`);
|
|
this.name = "ExternalVFontValidationError";
|
|
}
|
|
}
|
|
|
|
const SHA256 = /^[0-9a-f]{64}$/;
|
|
const PROJECT_ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
const FORMAT = {
|
|
".ttf": { format: "TTF", mimeTypes: new Set(["font/ttf", "application/x-font-ttf", "application/font-sfnt"]) },
|
|
".otf": { format: "OTF", mimeTypes: new Set(["font/otf", "application/vnd.ms-opentype", "application/font-sfnt"]) },
|
|
".pfb": { format: "PFB", mimeTypes: new Set(["application/x-font-type1", "application/x-font-pfb"]) },
|
|
} as const satisfies Record<string, { format: ExternalVFontFormat; mimeTypes: ReadonlySet<string> }>;
|
|
|
|
function fail(code: ErrorCode, message: string): never {
|
|
throw new ExternalVFontValidationError(code, message);
|
|
}
|
|
|
|
function classify(sourcePath: string, mimeType: string, data: ArrayBuffer): { format: ExternalVFontFormat; mimeType: string } {
|
|
const extension = sourcePath.slice(sourcePath.lastIndexOf(".")).toLowerCase() as keyof typeof FORMAT;
|
|
const declaration = FORMAT[extension];
|
|
if (!declaration || !declaration.mimeTypes.has(mimeType as never)) {
|
|
fail("NON_MESH_BINARY_INVALID", "font extension and MIME type must agree on TTF, OTF or PFB");
|
|
}
|
|
const bytes = new Uint8Array(data);
|
|
const sfnt = bytes.byteLength >= 4 && bytes[0] === 0x00 && bytes[1] === 0x01 && bytes[2] === 0x00 && bytes[3] === 0x00;
|
|
const otto = bytes.byteLength >= 4 && bytes[0] === 0x4f && bytes[1] === 0x54 && bytes[2] === 0x54 && bytes[3] === 0x4f;
|
|
const pfb = bytes.byteLength >= 6 && bytes[0] === 0x80 && bytes[1] === 0x01;
|
|
if (
|
|
(declaration.format === "TTF" && !sfnt) ||
|
|
(declaration.format === "OTF" && !otto) ||
|
|
(declaration.format === "PFB" && !pfb)
|
|
) {
|
|
fail("NON_MESH_BINARY_INVALID", `font bytes do not match the declared ${declaration.format} format`);
|
|
}
|
|
return { format: declaration.format, mimeType };
|
|
}
|
|
|
|
async function sha256(data: ArrayBuffer): Promise<string> {
|
|
return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", data))).map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
}
|
|
|
|
function mainName(fileName: string, digest: string): string {
|
|
const candidate = fileName.replace(/\.[^.]+$/, "");
|
|
return candidate && new TextEncoder().encode(candidate).byteLength <= 63
|
|
? candidate
|
|
: `ExternalFont-${digest.slice(0, 12)}`;
|
|
}
|
|
|
|
export function validateExternalVFontMainImportProof(value: ExternalVFontMainImportProofIR): ExternalVFontMainImportProofIR {
|
|
if (!value || typeof value !== "object" || value.schemaVersion !== EXTERNAL_VFONT_SCHEMA_VERSION) {
|
|
fail("NON_MESH_BINARY_INVALID", "external VFont Main import proof schema is invalid");
|
|
}
|
|
if (!PROJECT_ID.test(value.projectId) || !SHA256.test(value.sha256)) {
|
|
fail("NON_MESH_BINARY_INVALID", "external VFont project or hash identity is invalid");
|
|
}
|
|
if (value.assetId !== `sha256:${value.sha256}` ||
|
|
value.assetPath !== `projects/${value.projectId}/assets/sha256/${value.sha256.slice(0, 2)}/${value.sha256}`) {
|
|
fail("NON_MESH_RESOURCE_MISSING", "external VFont must reference its verified OPFS content-addressed asset");
|
|
}
|
|
let normalized: string;
|
|
try {
|
|
normalized = normalizeProjectAssetPath(value.sourcePath);
|
|
}
|
|
catch {
|
|
fail("NON_MESH_RESOURCE_OUTSIDE_PROJECT", "external VFont Main source path is invalid");
|
|
}
|
|
if (!normalized.startsWith("fonts/") || value.sourcePath !== `//${normalized}` ||
|
|
typeof value.name !== "string" || value.name.length === 0 || new TextEncoder().encode(value.name).byteLength > 63 ||
|
|
!Number.isSafeInteger(value.byteLength) || value.byteLength < 1 || value.byteLength > EXTERNAL_VFONT_MAX_BYTES) {
|
|
fail("NON_MESH_BINARY_INVALID", "external VFont Main metadata is invalid");
|
|
}
|
|
const declared = FORMAT[normalized.slice(normalized.lastIndexOf(".")).toLowerCase() as keyof typeof FORMAT];
|
|
if (!declared || declared.format !== value.format || !declared.mimeTypes.has(value.mimeType as never)) {
|
|
fail("NON_MESH_BINARY_INVALID", "external VFont Main format and MIME type do not agree");
|
|
}
|
|
return { ...value };
|
|
}
|
|
|
|
export function createExternalVFontMainImport(
|
|
validated: ValidatedExternalVFontIR,
|
|
stored: StorageAssetPutResult,
|
|
): ExternalVFontMainImportIR {
|
|
const normalized = validated.sourcePath.slice(2);
|
|
if (!stored.persisted || stored.projectId.length === 0 || stored.assetId !== `sha256:${validated.sha256}` ||
|
|
stored.sha256 !== validated.sha256 || stored.bytes !== validated.byteLength || stored.mimeType !== validated.mimeType ||
|
|
stored.sourcePath !== normalized) {
|
|
fail("ASSET_SOURCE_HASH_MISMATCH", "stored external VFont receipt does not match the validated font");
|
|
}
|
|
const proof = validateExternalVFontMainImportProof({
|
|
schemaVersion: EXTERNAL_VFONT_SCHEMA_VERSION,
|
|
projectId: stored.projectId,
|
|
assetId: stored.assetId,
|
|
assetPath: stored.path,
|
|
sourcePath: validated.sourcePath,
|
|
name: mainName(validated.fileName, validated.sha256),
|
|
format: validated.format,
|
|
mimeType: validated.mimeType,
|
|
byteLength: validated.byteLength,
|
|
sha256: validated.sha256,
|
|
});
|
|
return { ...proof, data: validated.data.slice(0) };
|
|
}
|
|
|
|
export async function validateStoredExternalVFontAsset(
|
|
projectId: string,
|
|
declaredSha256: string,
|
|
stored: StorageAssetReadResult,
|
|
): Promise<ValidatedExternalVFontIR> {
|
|
if (!PROJECT_ID.test(projectId) || !SHA256.test(declaredSha256) || !stored ||
|
|
typeof stored !== "object" || !(stored.data instanceof ArrayBuffer)) {
|
|
fail("NON_MESH_RESOURCE_MISSING", "stored external VFont asset identity is invalid");
|
|
}
|
|
const asset = stored.asset;
|
|
const expectedPath = `projects/${projectId}/assets/sha256/${declaredSha256.slice(0, 2)}/${declaredSha256}`;
|
|
if (!asset || asset.projectId !== projectId || asset.assetId !== `sha256:${declaredSha256}` ||
|
|
asset.sha256 !== declaredSha256 || asset.path !== expectedPath || typeof asset.sourcePath !== "string") {
|
|
fail("NON_MESH_RESOURCE_MISSING", "stored external VFont asset is not the declared project asset");
|
|
}
|
|
if (asset.bytes !== stored.data.byteLength) {
|
|
fail("ASSET_SOURCE_HASH_MISMATCH", "stored external VFont byte length does not match its metadata");
|
|
}
|
|
return validateExternalVFontImport({
|
|
sourcePath: `//${asset.sourcePath}`,
|
|
mimeType: asset.mimeType,
|
|
byteLength: asset.bytes,
|
|
sha256: asset.sha256,
|
|
data: stored.data,
|
|
});
|
|
}
|
|
|
|
export async function validateExternalVFontImport(request: ExternalVFontImportRequestIR): Promise<ValidatedExternalVFontIR> {
|
|
if (!request || typeof request !== "object") fail("NON_MESH_BINARY_INVALID", "font import request is missing");
|
|
let normalized: string;
|
|
try {
|
|
normalized = normalizeProjectAssetPath(request.sourcePath);
|
|
}
|
|
catch {
|
|
fail("NON_MESH_RESOURCE_OUTSIDE_PROJECT", "font source must be a project-relative path without traversal or URI syntax");
|
|
}
|
|
if (!normalized.startsWith("fonts/") || new TextEncoder().encode(normalized).byteLength > 1021) {
|
|
fail("NON_MESH_RESOURCE_OUTSIDE_PROJECT", "font source must be a bounded path under the project fonts directory");
|
|
}
|
|
if (!(request.data instanceof ArrayBuffer)) fail("NON_MESH_BINARY_INVALID", "font payload must be an ArrayBuffer");
|
|
if (!Number.isSafeInteger(request.byteLength) || request.byteLength < 1 || request.byteLength > EXTERNAL_VFONT_MAX_BYTES) {
|
|
fail("NON_MESH_DATA_BUDGET_EXCEEDED", "font payload exceeds the 32 MiB import budget");
|
|
}
|
|
if (request.data.byteLength !== request.byteLength) fail("NON_MESH_BINARY_INVALID", "font declared byte length does not match its payload");
|
|
if (typeof request.mimeType !== "string" || request.mimeType.length > 128) fail("NON_MESH_BINARY_INVALID", "font MIME type is invalid");
|
|
const classified = classify(normalized, request.mimeType.toLowerCase(), request.data);
|
|
if (typeof request.sha256 !== "string" || !SHA256.test(request.sha256)) fail("ASSET_SOURCE_HASH_MISMATCH", "font SHA-256 declaration is invalid");
|
|
const actualSha256 = await sha256(request.data);
|
|
if (actualSha256 !== request.sha256) fail("ASSET_SOURCE_HASH_MISMATCH", "font bytes do not match the declared SHA-256");
|
|
return {
|
|
schemaVersion: EXTERNAL_VFONT_SCHEMA_VERSION,
|
|
sourcePath: `//${normalized}`,
|
|
fileName: normalized.slice(normalized.lastIndexOf("/") + 1),
|
|
format: classified.format,
|
|
mimeType: classified.mimeType,
|
|
byteLength: request.byteLength,
|
|
sha256: actualSha256,
|
|
data: request.data.slice(0),
|
|
};
|
|
}
|