166 lines
7.8 KiB
JavaScript
166 lines
7.8 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import crypto from "node:crypto";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import test from "node:test";
|
|
import ts from "typescript";
|
|
|
|
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
|
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "external-vfont-unit-"));
|
|
for (const name of ["asset-path", "external-vfont"]) {
|
|
const sourcePath = path.join(repoRoot, `web/protocol/${name}.ts`);
|
|
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
|
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
|
fileName: sourcePath,
|
|
reportDiagnostics: true,
|
|
});
|
|
assert.deepEqual(transpiled.diagnostics, []);
|
|
fs.writeFileSync(path.join(temporary, `${name}.mjs`), transpiled.outputText.replace('"./asset-path"', '"./asset-path.mjs"'));
|
|
}
|
|
const font = await import(pathToFileURL(path.join(temporary, "external-vfont.mjs")));
|
|
const importSourcePath = path.join(repoRoot, "web/app/src/fonts/external-vfont-import.ts");
|
|
const importTranspiled = ts.transpileModule(fs.readFileSync(importSourcePath, "utf8"), {
|
|
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
|
fileName: importSourcePath,
|
|
reportDiagnostics: true,
|
|
});
|
|
assert.deepEqual(importTranspiled.diagnostics, []);
|
|
fs.writeFileSync(path.join(temporary, "external-vfont-import.mjs"), importTranspiled.outputText.replace('"../../../protocol/external-vfont"', '"./external-vfont.mjs"'));
|
|
const importer = await import(pathToFileURL(path.join(temporary, "external-vfont-import.mjs")));
|
|
const pfb = fs.readFileSync(path.join(repoRoot, "blender-5.2.0/release/datafiles/bfont.pfb"));
|
|
const data = pfb.buffer.slice(pfb.byteOffset, pfb.byteOffset + pfb.byteLength);
|
|
const digest = crypto.createHash("sha256").update(pfb).digest("hex");
|
|
const request = { sourcePath: "//fonts/bfont.pfb", mimeType: "application/x-font-type1", byteLength: pfb.byteLength, sha256: digest, data };
|
|
|
|
test("M9-01 validates a real project-relative PFB before storage or Main mutation", async () => {
|
|
const protocolSource = fs.readFileSync(path.join(repoRoot, "web/protocol/external-vfont.ts"), "utf8");
|
|
assert.doesNotMatch(protocolSource, /StorageClient|WebEngineClient|storage\.worker|web-engine\.worker/);
|
|
const validated = await font.validateExternalVFontImport(request);
|
|
assert.deepEqual({ ...validated, data: undefined }, {
|
|
schemaVersion: 1,
|
|
sourcePath: "//fonts/bfont.pfb",
|
|
fileName: "bfont.pfb",
|
|
format: "PFB",
|
|
mimeType: "application/x-font-type1",
|
|
byteLength: 25181,
|
|
sha256: "a33954fdab9fb09b9d308cb7f970518293128922ffc523c0a22b3b314a9a56c6",
|
|
data: undefined,
|
|
});
|
|
assert.notEqual(validated.data, request.data);
|
|
assert.deepEqual(new Uint8Array(validated.data), new Uint8Array(request.data));
|
|
});
|
|
|
|
test("M9-01 blocks path escape, type spoofing, size overflow and hash drift", async () => {
|
|
const cases = [
|
|
[{ ...request, sourcePath: "../../outside.pfb" }, "NON_MESH_RESOURCE_OUTSIDE_PROJECT"],
|
|
[{ ...request, sourcePath: "//assets/bfont.pfb" }, "NON_MESH_RESOURCE_OUTSIDE_PROJECT"],
|
|
[{ ...request, sourcePath: "//fonts/bfont.ttf", mimeType: "font/ttf" }, "NON_MESH_BINARY_INVALID"],
|
|
[{ ...request, mimeType: "font/otf" }, "NON_MESH_BINARY_INVALID"],
|
|
[{ ...request, byteLength: font.EXTERNAL_VFONT_MAX_BYTES + 1 }, "NON_MESH_DATA_BUDGET_EXCEEDED"],
|
|
[{ ...request, byteLength: request.byteLength - 1 }, "NON_MESH_BINARY_INVALID"],
|
|
[{ ...request, sha256: "0".repeat(64) }, "ASSET_SOURCE_HASH_MISMATCH"],
|
|
];
|
|
for (const [value, code] of cases) await assert.rejects(font.validateExternalVFontImport(value), (error) => error.code === code);
|
|
});
|
|
|
|
test("M9-02 accepts only a matching OPFS content-addressed receipt before Main import", async () => {
|
|
const validated = await font.validateExternalVFontImport(request);
|
|
const stored = {
|
|
assetId: `sha256:${digest}`,
|
|
projectId: "m9-vfont-unit",
|
|
sha256: digest,
|
|
bytes: pfb.byteLength,
|
|
mimeType: request.mimeType,
|
|
sourcePath: "fonts/bfont.pfb",
|
|
path: `projects/m9-vfont-unit/assets/sha256/${digest.slice(0, 2)}/${digest}`,
|
|
createdAt: "2026-08-16T00:00:00.000Z",
|
|
lastAccessAt: "2026-08-16T00:00:00.000Z",
|
|
persisted: true,
|
|
deduplicated: false,
|
|
};
|
|
const mainImport = font.createExternalVFontMainImport(validated, stored);
|
|
assert.deepEqual({ ...mainImport, data: undefined }, {
|
|
schemaVersion: 1,
|
|
projectId: stored.projectId,
|
|
assetId: stored.assetId,
|
|
assetPath: stored.path,
|
|
sourcePath: "//fonts/bfont.pfb",
|
|
name: "bfont",
|
|
format: "PFB",
|
|
mimeType: request.mimeType,
|
|
byteLength: pfb.byteLength,
|
|
sha256: digest,
|
|
data: undefined,
|
|
});
|
|
assert.notEqual(mainImport.data, validated.data);
|
|
for (const receipt of [
|
|
{ ...stored, persisted: false },
|
|
{ ...stored, sha256: "0".repeat(64) },
|
|
{ ...stored, bytes: stored.bytes - 1 },
|
|
{ ...stored, path: `indexeddb:${stored.projectId}:${digest}` },
|
|
]) {
|
|
assert.throws(() => font.createExternalVFontMainImport(validated, receipt), (error) =>
|
|
error.code === "ASSET_SOURCE_HASH_MISMATCH" || error.code === "NON_MESH_RESOURCE_MISSING");
|
|
}
|
|
});
|
|
|
|
test("M9-03 verifies the project asset before one Main font-style replacement", async () => {
|
|
const stored = {
|
|
asset: {
|
|
assetId: `sha256:${digest}`,
|
|
projectId: "m9-vfont-unit",
|
|
sha256: digest,
|
|
bytes: pfb.byteLength,
|
|
mimeType: request.mimeType,
|
|
sourcePath: "fonts/bfont.pfb",
|
|
path: `projects/m9-vfont-unit/assets/sha256/${digest.slice(0, 2)}/${digest}`,
|
|
createdAt: "2026-08-16T00:00:00.000Z",
|
|
lastAccessAt: "2026-08-16T00:00:00.000Z",
|
|
},
|
|
data: data.slice(0),
|
|
};
|
|
const vfont = { id: "vfont:bfont", name: "bfont", sourcePath: "//fonts/bfont.pfb", builtin: false, packed: true, packedByteLength: pfb.byteLength, sha256: digest };
|
|
const original = { regular: "vfont:Bfont", bold: "vfont:Bfont", italic: "vfont:Bfont", boldItalic: "vfont:Bfont" };
|
|
const snapshot = { nonMeshData: [{ id: "data:font", type: "FONT", fontLinks: original }], vfonts: [vfont] };
|
|
const events = [];
|
|
const result = await importer.replaceExternalVFontStyleInMain({
|
|
projectId: stored.asset.projectId,
|
|
sha256: digest,
|
|
dataId: "data:font",
|
|
vfontId: vfont.id,
|
|
style: "regular",
|
|
snapshot,
|
|
storage: { readAsset: async () => { events.push("storage:verified"); return stored; } },
|
|
engine: { applyCommand: async (command) => {
|
|
events.push("main:committed");
|
|
return { snapshot: { ...snapshot, nonMeshData: [{ ...snapshot.nonMeshData[0], fontLinks: command.links }] } };
|
|
} },
|
|
});
|
|
assert.deepEqual(events, ["storage:verified", "main:committed"]);
|
|
assert.deepEqual(result.previousLinks, original);
|
|
assert.equal(result.links.regular, vfont.id);
|
|
});
|
|
|
|
test("M9-03 blocks a missing project asset before Main replacement", async () => {
|
|
let mainCalls = 0;
|
|
const snapshot = {
|
|
nonMeshData: [{ id: "data:font", type: "FONT", fontLinks: { regular: "vfont:Bfont", bold: "vfont:Bfont", italic: "vfont:Bfont", boldItalic: "vfont:Bfont" } }],
|
|
vfonts: [{ id: "vfont:bfont", name: "bfont", sourcePath: "//fonts/bfont.pfb", builtin: false, packed: true, packedByteLength: pfb.byteLength, sha256: digest }],
|
|
};
|
|
await assert.rejects(importer.replaceExternalVFontStyleInMain({
|
|
projectId: "m9-vfont-unit",
|
|
sha256: digest,
|
|
dataId: "data:font",
|
|
vfontId: "vfont:bfont",
|
|
style: "regular",
|
|
snapshot,
|
|
storage: { readAsset: async () => { const error = new Error("missing"); error.code = "NON_MESH_RESOURCE_MISSING"; throw error; } },
|
|
engine: { applyCommand: async () => { mainCalls += 1; throw new Error("unexpected Main call"); } },
|
|
}), (error) => error.code === "NON_MESH_RESOURCE_MISSING");
|
|
assert.equal(mainCalls, 0);
|
|
});
|
|
|
|
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|