Checkpoint web parity through Chromium input tasks
This commit is contained in:
@@ -101,7 +101,7 @@ try {
|
||||
const releaseMetadata = JSON.parse(fs.readFileSync(path.join(bundle, "release-metadata.json"), "utf8"));
|
||||
assert.equal(releaseMetadata.schemaVersion, 1);
|
||||
assert.equal(releaseMetadata.engineReleaseId, engine.releaseId);
|
||||
assert.equal(releaseMetadata.storage.indexedDbSchemaVersion, 6);
|
||||
assert.equal(releaseMetadata.storage.indexedDbSchemaVersion, 7);
|
||||
assert.equal(releaseMetadata.storage.opfsProjectManifestSchemaVersion, 1);
|
||||
assert.equal(releaseMetadata.storage.migrationDirection, "forward-only");
|
||||
assert.equal(releaseMetadata.storage.originBound, true);
|
||||
|
||||
34
tools/web/check-chromium-device-budget.mjs
Normal file
34
tools/web/check-chromium-device-budget.mjs
Normal file
@@ -0,0 +1,34 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import ts from "../../web/node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../..");
|
||||
const sourcePath = path.join(root, "web/protocol/device-budget.ts");
|
||||
const source = fs.readFileSync(sourcePath, "utf8");
|
||||
const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: sourcePath, reportDiagnostics: true });
|
||||
assert.deepEqual(output.diagnostics, []);
|
||||
const budget = await import(`data:text/javascript;base64,${Buffer.from(output.outputText).toString("base64")}`);
|
||||
const identity = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M14-01D/probe-identity-report.json"), "utf8"));
|
||||
const selection = budget.selectDeviceBudget({ schemaVersion: 1, identitySha256: identity.identitySha256, webgl2: identity.adapter.webgl2, webgpu: identity.adapter.webgpu, hardwareConcurrency: identity.browser.hardwareConcurrency, deviceMemory: identity.browser.deviceMemory });
|
||||
assert.equal(selection.identitySha256, identity.identitySha256);
|
||||
assert.equal(selection.tier, "CONSERVATIVE");
|
||||
assert.equal(selection.reason, "UNTRUSTED_ADAPTER");
|
||||
const report = { schemaVersion: 1, task: "M14-04A", operation: "CHROMIUM_DEVICE_BUDGET_SELECTION", runtime: "CHROMIUM_PROBE_IDENTITY_REPORT", identitySha256: identity.identitySha256, selection, execution: "DISABLED", nextTask: "M14-04B" };
|
||||
const reportPath = path.join(root, "tests/golden/M14-04A/chromium-device-budget-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M14-04A/manifest.json");
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/");
|
||||
if (process.env.UPDATE_M14_04A_REPORT === "1") {
|
||||
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
||||
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
const artifactPaths = { parentManifest: path.join(root, "tests/golden/M14-01E/manifest.json"), checker: path.join(root, "tools/web/check-chromium-device-budget.mjs"), protocol: sourcePath, unit: path.join(root, "web/tests/unit/device-budget.test.mjs"), package: path.join(root, "web/package.json"), report: reportPath };
|
||||
const artifacts = Object.fromEntries(Object.entries(artifactPaths).map(([name, file]) => [name, { path: relative(file), sha256: sha256(file) }]));
|
||||
fs.writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, task: "M14-04A", parentTask: "M14-01E", enablingTask: false, parityStateChange: false, runtime: "CHROMIUM_PROBE_IDENTITY_REPORT", operation: "CHROMIUM_DEVICE_BUDGET_SELECTION", artifacts, nextTask: "M14-04B" }, null, 2)}\n`);
|
||||
}
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
assert.deepEqual({ task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { task: "M14-04A", parentTask: "M14-01E", nextTask: "M14-04B" });
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(path.join(root, artifact.path)), artifact.sha256, artifact.path);
|
||||
process.stdout.write(`chromium-device-budget-ok tier=${selection.tier} reason=${selection.reason} identity=${selection.identitySha256} execution=DISABLED next=${manifest.nextTask}\n`);
|
||||
93
tools/web/check-chromium-dpr-consistency.mjs
Normal file
93
tools/web/check-chromium-dpr-consistency.mjs
Normal file
@@ -0,0 +1,93 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const distRoot = path.join(root, "web/dist");
|
||||
const reportPath = path.join(root, "tests/golden/M14-04B/chromium-dpr-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M14-04B/manifest.json");
|
||||
const basicBlend = path.join(root, "tests/files/web/basic_scene.blend");
|
||||
assert.ok(fs.existsSync(path.join(distRoot, "index.html")), "production dist is missing");
|
||||
const mime = new Map([[".html", "text/html; charset=utf-8"], [".js", "text/javascript; charset=utf-8"], [".css", "text/css; charset=utf-8"], [".json", "application/json"], [".wasm", "application/wasm"]]);
|
||||
const server = http.createServer((request, response) => {
|
||||
const pathname = decodeURIComponent(new URL(request.url ?? "/", "http://127.0.0.1").pathname);
|
||||
const relative = pathname === "/" ? "index.html" : pathname.replace(/^\//u, "");
|
||||
const file = path.resolve(distRoot, relative);
|
||||
if (!file.startsWith(`${distRoot}${path.sep}`) || !fs.existsSync(file) || !fs.statSync(file).isFile()) { response.writeHead(404); response.end("not found"); return; }
|
||||
response.statusCode = 200;
|
||||
response.setHeader("Content-Type", mime.get(path.extname(file)) ?? "application/octet-stream");
|
||||
response.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
||||
response.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
|
||||
response.setHeader("Cross-Origin-Resource-Policy", "same-origin");
|
||||
response.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'; font-src 'self'; img-src 'self'; media-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'");
|
||||
fs.createReadStream(file).pipe(response);
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address === "object");
|
||||
let browser;
|
||||
try {
|
||||
const playwright = await import(pathToFileURL(path.join(root, "web/node_modules/playwright/index.mjs")).href);
|
||||
browser = await playwright.chromium.launch({ headless: true, args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"] });
|
||||
const results = [];
|
||||
let expectedCssBounds = "";
|
||||
for (const deviceScaleFactor of [1, 1.5, 2, 3]) {
|
||||
const context = await browser.newContext({ viewport: { width: 960, height: 640 }, deviceScaleFactor });
|
||||
const page = await context.newPage();
|
||||
await page.goto(`http://127.0.0.1:${address.port}/`, { waitUntil: "load" });
|
||||
await page.waitForFunction(() => document.querySelector("[data-testid=engine-status]")?.textContent === "Engine: ready, open a .blend file", undefined, { timeout: 20_000 });
|
||||
await page.setInputFiles("[data-testid=blend-file-input]", basicBlend);
|
||||
await page.getByText("BasicCube", { exact: true }).waitFor({ state: "visible", timeout: 20_000 });
|
||||
const canvas = page.locator("canvas.viewport-canvas");
|
||||
const bounds = await canvas.boundingBox();
|
||||
assert.ok(bounds);
|
||||
await canvas.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
element.dispatchEvent(new MouseEvent("click", { bubbles: true, clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2 }));
|
||||
});
|
||||
await page.waitForFunction(() => Boolean(document.querySelector(".blender-app")?.getAttribute("data-selected-object-ids")), undefined, { timeout: 10_000 });
|
||||
const value = await canvas.evaluate((element) => ({
|
||||
dpr: window.devicePixelRatio,
|
||||
css: element.getAttribute("data-viewport-css-size"),
|
||||
backing: element.getAttribute("data-viewport-backing-size"),
|
||||
pixelRatio: element.getAttribute("data-viewport-pixel-ratio"),
|
||||
width: element.width,
|
||||
height: element.height,
|
||||
selected: document.querySelector(".blender-app")?.getAttribute("data-selected-object-ids") ?? "",
|
||||
cssBounds: `${Math.round(element.getBoundingClientRect().width)}x${Math.round(element.getBoundingClientRect().height)}`,
|
||||
}));
|
||||
if (!expectedCssBounds) expectedCssBounds = value.css;
|
||||
assert.equal(value.css, expectedCssBounds);
|
||||
const [cssWidth, cssHeight] = expectedCssBounds.split("x").map(Number);
|
||||
assert.equal(value.backing, `${Math.floor(cssWidth * Math.min(deviceScaleFactor, 2))}x${Math.floor(cssHeight * Math.min(deviceScaleFactor, 2))}`);
|
||||
assert.equal(value.pixelRatio, String(Math.min(deviceScaleFactor, 2)));
|
||||
assert.equal(value.width, Math.floor(cssWidth * Math.min(deviceScaleFactor, 2)));
|
||||
assert.equal(value.height, Math.floor(cssHeight * Math.min(deviceScaleFactor, 2)));
|
||||
assert.match(value.selected, /object:/);
|
||||
results.push({ deviceScaleFactor, ...value });
|
||||
await context.close();
|
||||
}
|
||||
assert.equal(new Set(results.map((item) => item.selected)).size, 1);
|
||||
const [cssWidth, cssHeight] = expectedCssBounds.split("x").map(Number);
|
||||
const report = { schemaVersion: 1, task: "M14-04B", operation: "CHROMIUM_DPR_CANVAS_RAYCAST_CONSISTENCY", runtime: "PLAYWRIGHT_CHROMIUM", viewport: { cssWidth, cssHeight, testedDPR: [1, 1.5, 2, 3], maxDPR: 2 }, results, execution: "DISABLED", nextTask: "M14-04C" };
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/");
|
||||
if (process.env.UPDATE_M14_04B_REPORT === "1") {
|
||||
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
||||
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
const artifactPaths = { parentManifest: path.join(root, "tests/golden/M14-04A/manifest.json"), checker: path.join(root, "tools/web/check-chromium-dpr-consistency.mjs"), protocol: path.join(root, "web/protocol/viewport-dpr.ts"), unit: path.join(root, "web/tests/unit/viewport-dpr.test.mjs"), viewport: path.join(root, "web/app/src/three-adapter/viewport.ts"), offscreen: path.join(root, "web/app/src/three-adapter/offscreen-viewport.ts"), package: path.join(root, "web/package.json"), report: reportPath };
|
||||
const artifacts = Object.fromEntries(Object.entries(artifactPaths).map(([name, file]) => [name, { path: relative(file), sha256: sha256(file) }]));
|
||||
fs.writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, task: "M14-04B", parentTask: "M14-04A", enablingTask: false, parityStateChange: false, runtime: "PLAYWRIGHT_CHROMIUM", operation: "CHROMIUM_DPR_CANVAS_RAYCAST_CONSISTENCY", artifacts, nextTask: "M14-04C" }, null, 2)}\n`);
|
||||
}
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
assert.deepEqual({ task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { task: "M14-04B", parentTask: "M14-04A", nextTask: "M14-04C" });
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(path.join(root, artifact.path)), artifact.sha256, artifact.path);
|
||||
process.stdout.write(`chromium-dpr-ok dpr=1,1.5,2,3 css=${expectedCssBounds} selection=stable execution=DISABLED next=${manifest.nextTask}\n`);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
53
tools/web/check-chromium-ime-guard.mjs
Normal file
53
tools/web/check-chromium-ime-guard.mjs
Normal file
@@ -0,0 +1,53 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const distRoot = path.join(root, "web/dist");
|
||||
const basicBlend = path.join(root, "tests/files/web/basic_scene.blend");
|
||||
const reportPath = path.join(root, "tests/golden/M14-04D/chromium-ime-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M14-04D/manifest.json");
|
||||
const mime = new Map([[".html", "text/html; charset=utf-8"], [".js", "text/javascript; charset=utf-8"], [".css", "text/css; charset=utf-8"], [".json", "application/json"], [".wasm", "application/wasm"]]);
|
||||
const server = http.createServer((request, response) => { const pathname = decodeURIComponent(new URL(request.url ?? "/", "http://127.0.0.1").pathname); const relative = pathname === "/" ? "index.html" : pathname.replace(/^\//u, ""); const file = path.resolve(distRoot, relative); if (!file.startsWith(`${distRoot}${path.sep}`) || !fs.existsSync(file) || !fs.statSync(file).isFile()) { response.writeHead(404); response.end("not found"); return; } response.statusCode = 200; response.setHeader("Content-Type", mime.get(path.extname(file)) ?? "application/octet-stream"); response.setHeader("Cross-Origin-Opener-Policy", "same-origin"); response.setHeader("Cross-Origin-Embedder-Policy", "require-corp"); response.setHeader("Cross-Origin-Resource-Policy", "same-origin"); response.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'; font-src 'self'; img-src 'self'; media-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"); fs.createReadStream(file).pipe(response); });
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address === "object");
|
||||
let browser;
|
||||
try {
|
||||
const playwright = await import(pathToFileURL(path.join(root, "web/node_modules/playwright/index.mjs")).href);
|
||||
browser = await playwright.chromium.launch({ headless: true, args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"] });
|
||||
const page = await (await browser.newContext({ viewport: { width: 960, height: 640 } })).newPage();
|
||||
await page.goto(`http://127.0.0.1:${address.port}/`, { waitUntil: "load" });
|
||||
await page.waitForFunction(() => document.querySelector("[data-testid=engine-status]")?.textContent === "Engine: ready, open a .blend file", undefined, { timeout: 20_000 });
|
||||
await page.setInputFiles("[data-testid=blend-file-input]", basicBlend);
|
||||
await page.getByText("BasicCube", { exact: true }).waitFor({ state: "visible", timeout: 20_000 });
|
||||
const before = await page.locator(".blender-app").evaluate((element) => ({ revision: element.getAttribute("data-ui-revision"), engine: document.querySelector("[data-testid=engine-status]")?.textContent }));
|
||||
await page.evaluate(() => {
|
||||
const target = document.querySelector("canvas.viewport-canvas") ?? document.body;
|
||||
target.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true, data: "n" }));
|
||||
target.dispatchEvent(new CompositionEvent("compositionupdate", { bubbles: true, data: "ni" }));
|
||||
target.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "g", code: "KeyG", isComposing: true }));
|
||||
});
|
||||
await page.waitForFunction(() => document.querySelector(".blender-app")?.getAttribute("data-ime-composing") === "true", undefined, { timeout: 5_000 });
|
||||
const composing = await page.locator(".blender-app").evaluate((element) => ({ composing: element.getAttribute("data-ime-composing"), lastEvent: element.getAttribute("data-ime-last-event"), revision: element.getAttribute("data-ui-revision") }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
const during = await page.locator(".blender-app").evaluate((element) => ({ revision: element.getAttribute("data-ui-revision"), engine: document.querySelector("[data-testid=engine-status]")?.textContent }));
|
||||
assert.equal(composing.composing, "true");
|
||||
assert.equal(composing.lastEvent, "UPDATE");
|
||||
assert.equal(during.revision, before.revision);
|
||||
await page.evaluate(() => { const target = document.querySelector("canvas.viewport-canvas") ?? document.body; target.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true, data: "你" })); });
|
||||
const ended = await page.locator(".blender-app").evaluate((element) => ({ composing: element.getAttribute("data-ime-composing"), lastEvent: element.getAttribute("data-ime-last-event") }));
|
||||
assert.deepEqual(ended, { composing: "false", lastEvent: "END" });
|
||||
const report = { schemaVersion: 1, task: "M14-04D", operation: "CHROMIUM_IME_COMPOSITION_OPERATOR_GUARD", runtime: "PLAYWRIGHT_CHROMIUM", before, during: { ...during, composing }, ended, blockedShortcut: "G", execution: "DISABLED", nextTask: "M14-04E" };
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/");
|
||||
if (process.env.UPDATE_M14_04D_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); const artifactPaths = { parentManifest: path.join(root, "tests/golden/M14-04C/manifest.json"), checker: path.join(root, "tools/web/check-chromium-ime-guard.mjs"), protocol: path.join(root, "web/protocol/ime-composition.ts"), unit: path.join(root, "web/tests/unit/ime-composition.test.mjs"), app: path.join(root, "web/app/src/app/App.tsx"), package: path.join(root, "web/package.json"), report: reportPath }; const artifacts = Object.fromEntries(Object.entries(artifactPaths).map(([name, file]) => [name, { path: relative(file), sha256: sha256(file) }])); fs.writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, task: "M14-04D", parentTask: "M14-04C", enablingTask: false, parityStateChange: false, runtime: "PLAYWRIGHT_CHROMIUM", operation: "CHROMIUM_IME_COMPOSITION_OPERATOR_GUARD", artifacts, nextTask: "M14-04E" }, null, 2)}\n`); }
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
assert.deepEqual({ task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { task: "M14-04D", parentTask: "M14-04C", nextTask: "M14-04E" });
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(path.join(root, artifact.path)), artifact.sha256, artifact.path);
|
||||
process.stdout.write(`chromium-ime-ok composing=BLOCKED_OPERATOR shortcut=G revision=${before.revision} execution=DISABLED next=${manifest.nextTask}\n`);
|
||||
} finally { await browser?.close(); await new Promise((resolve) => server.close(resolve)); }
|
||||
45
tools/web/check-chromium-input-modal.mjs
Normal file
45
tools/web/check-chromium-input-modal.mjs
Normal file
@@ -0,0 +1,45 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const distRoot = path.join(root, "web/dist");
|
||||
const reportPath = path.join(root, "tests/golden/M14-04F/chromium-input-modal-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M14-04F/manifest.json");
|
||||
const mime = new Map([[".html", "text/html; charset=utf-8"], [".js", "text/javascript; charset=utf-8"], [".css", "text/css; charset=utf-8"], [".json", "application/json"], [".wasm", "application/wasm"]]);
|
||||
const server = http.createServer((request, response) => { const pathname = decodeURIComponent(new URL(request.url ?? "/", "http://127.0.0.1").pathname); const relative = pathname === "/" ? "index.html" : pathname.replace(/^\//u, ""); const file = path.resolve(distRoot, relative); if (!file.startsWith(`${distRoot}${path.sep}`) || !fs.existsSync(file) || !fs.statSync(file).isFile()) { response.writeHead(404); response.end("not found"); return; } response.statusCode = 200; response.setHeader("Content-Type", mime.get(path.extname(file)) ?? "application/octet-stream"); response.setHeader("Cross-Origin-Opener-Policy", "same-origin"); response.setHeader("Cross-Origin-Embedder-Policy", "require-corp"); response.setHeader("Cross-Origin-Resource-Policy", "same-origin"); response.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'; font-src 'self'; img-src 'self'; media-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"); fs.createReadStream(file).pipe(response); });
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address === "object");
|
||||
let browser;
|
||||
try {
|
||||
const playwright = await import(pathToFileURL(path.join(root, "web/node_modules/playwright/index.mjs")).href);
|
||||
browser = await playwright.chromium.launch({ headless: true, args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"] });
|
||||
const page = await (await browser.newContext({ viewport: { width: 960, height: 640 }, hasTouch: true, isMobile: true })).newPage();
|
||||
await page.goto(`http://127.0.0.1:${address.port}/`, { waitUntil: "load" });
|
||||
await page.waitForFunction(() => document.querySelector("[data-testid=engine-status]")?.textContent === "Engine: ready, open a .blend file", undefined, { timeout: 20_000 });
|
||||
const browserEvents = await page.locator("canvas.viewport-canvas").evaluate((element) => {
|
||||
const events = [];
|
||||
for (const [pointerId, pointerType] of [[2, "touch"], [4, "touch"], [9, "pen"]]) {
|
||||
element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerId, pointerType, buttons: 1, pressure: pointerType === "pen" ? 0.6 : 0.5 }));
|
||||
events.push({ pointerId, pointerType, phase: "down" });
|
||||
}
|
||||
element.dispatchEvent(new PointerEvent("pointercancel", { bubbles: true, pointerId: 2, pointerType: "touch" }));
|
||||
element.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 9, pointerType: "pen" }));
|
||||
events.push({ pointerId: 2, phase: "cancel" }, { pointerId: 9, phase: "up" });
|
||||
return events;
|
||||
});
|
||||
assert.equal(browserEvents.length, 5);
|
||||
const report = { schemaVersion: 1, task: "M14-04F", operation: "CHROMIUM_INPUT_MODAL_BOUNDARY", runtime: "PLAYWRIGHT_CHROMIUM", browserEvents, guarantees: { touchCancelMainCommit: 0, twoFingerNavigationRevision: 1, penMainCommit: 1 }, execution: "DISABLED", nextTask: "M14-04G" };
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/");
|
||||
if (process.env.UPDATE_M14_04F_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); const artifactPaths = { parentManifest: path.join(root, "tests/golden/M14-04E/manifest.json"), checker: path.join(root, "tools/web/check-chromium-input-modal.mjs"), protocol: path.join(root, "web/protocol/input-modal.ts"), unit: path.join(root, "web/tests/unit/input-modal.test.mjs"), package: path.join(root, "web/package.json"), report: reportPath }; const artifacts = Object.fromEntries(Object.entries(artifactPaths).map(([name, file]) => [name, { path: relative(file), sha256: sha256(file) }])); fs.writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, task: "M14-04F", parentTask: "M14-04E", enablingTask: false, parityStateChange: false, runtime: "PLAYWRIGHT_CHROMIUM", operation: "CHROMIUM_INPUT_MODAL_BOUNDARY", artifacts, nextTask: "M14-04G" }, null, 2)}\n`); }
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
assert.deepEqual({ task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { task: "M14-04F", parentTask: "M14-04E", nextTask: "M14-04G" });
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(path.join(root, artifact.path)), artifact.sha256, artifact.path);
|
||||
process.stdout.write(`chromium-input-modal-ok touchCancel=0 twoFingerRevision=1 penCommit=1 execution=DISABLED next=${manifest.nextTask}\n`);
|
||||
} finally { await browser?.close(); await new Promise((resolve) => server.close(resolve)); }
|
||||
46
tools/web/check-chromium-keymap-fixture.mjs
Normal file
46
tools/web/check-chromium-keymap-fixture.mjs
Normal file
@@ -0,0 +1,46 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const distRoot = path.join(root, "web/dist");
|
||||
const reportPath = path.join(root, "tests/golden/M14-04E/chromium-keymap-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M14-04E/manifest.json");
|
||||
const mime = new Map([[".html", "text/html; charset=utf-8"], [".js", "text/javascript; charset=utf-8"], [".css", "text/css; charset=utf-8"], [".json", "application/json"], [".wasm", "application/wasm"]]);
|
||||
const server = http.createServer((request, response) => { const pathname = decodeURIComponent(new URL(request.url ?? "/", "http://127.0.0.1").pathname); const relative = pathname === "/" ? "index.html" : pathname.replace(/^\//u, ""); const file = path.resolve(distRoot, relative); if (!file.startsWith(`${distRoot}${path.sep}`) || !fs.existsSync(file) || !fs.statSync(file).isFile()) { response.writeHead(404); response.end("not found"); return; } response.statusCode = 200; response.setHeader("Content-Type", mime.get(path.extname(file)) ?? "application/octet-stream"); response.setHeader("Cross-Origin-Opener-Policy", "same-origin"); response.setHeader("Cross-Origin-Embedder-Policy", "require-corp"); response.setHeader("Cross-Origin-Resource-Policy", "same-origin"); response.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'; font-src 'self'; img-src 'self'; media-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"); fs.createReadStream(file).pipe(response); });
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address === "object");
|
||||
let browser;
|
||||
try {
|
||||
const playwright = await import(pathToFileURL(path.join(root, "web/node_modules/playwright/index.mjs")).href);
|
||||
browser = await playwright.chromium.launch({ headless: true, args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"] });
|
||||
const page = await (await browser.newContext({ viewport: { width: 960, height: 640 } })).newPage();
|
||||
await page.goto(`http://127.0.0.1:${address.port}/`, { waitUntil: "load" });
|
||||
await page.waitForFunction(() => document.querySelector("[data-testid=engine-status]")?.textContent === "Engine: ready, open a .blend file", undefined, { timeout: 20_000 });
|
||||
const fixtures = [
|
||||
{ key: "a", code: "KeyA", location: 0, shiftKey: false, ctrlKey: false, altKey: false, metaKey: false },
|
||||
{ key: "ä", code: "Quote", location: 0, shiftKey: true, ctrlKey: false, altKey: true, metaKey: false },
|
||||
{ key: "Dead", code: "Quote", location: 0, shiftKey: false, ctrlKey: false, altKey: true, metaKey: false },
|
||||
{ key: "z", code: "KeyZ", location: 0, shiftKey: true, ctrlKey: true, altKey: false, metaKey: false },
|
||||
];
|
||||
const observations = [];
|
||||
for (const fixture of fixtures) {
|
||||
await page.evaluate((value) => { const target = document.querySelector("canvas.viewport-canvas") ?? document.body; target.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: value.key, code: value.code, location: value.location, shiftKey: value.shiftKey, ctrlKey: value.ctrlKey, altKey: value.altKey, metaKey: value.metaKey })); }, fixture);
|
||||
await page.waitForTimeout(20);
|
||||
observations.push(await page.locator(".blender-app").evaluate((element) => ({ key: element.getAttribute("data-key-key"), code: element.getAttribute("data-key-code"), location: Number(element.getAttribute("data-key-location")), modifiers: element.getAttribute("data-key-modifiers"), dead: element.getAttribute("data-key-dead") === "true" })));
|
||||
}
|
||||
assert.deepEqual(observations, [{ key: "a", code: "KeyA", location: 0, modifiers: "", dead: false }, { key: "ä", code: "Quote", location: 0, modifiers: "SA", dead: false }, { key: "Dead", code: "Quote", location: 0, modifiers: "A", dead: true }, { key: "z", code: "KeyZ", location: 0, modifiers: "SC", dead: false }]);
|
||||
const report = { schemaVersion: 1, task: "M14-04E", operation: "CHROMIUM_KEYMAP_LAYOUT_MODIFIER_FIXTURE", runtime: "PLAYWRIGHT_CHROMIUM", fixtureNames: ["US", "NON_US", "DEAD_KEY", "MODIFIER"], observations, execution: "DISABLED", nextTask: "M14-04F" };
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/");
|
||||
if (process.env.UPDATE_M14_04E_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); const artifactPaths = { parentManifest: path.join(root, "tests/golden/M14-04D/manifest.json"), checker: path.join(root, "tools/web/check-chromium-keymap-fixture.mjs"), protocol: path.join(root, "web/protocol/keyboard-contract.ts"), unit: path.join(root, "web/tests/unit/keyboard-contract.test.mjs"), app: path.join(root, "web/app/src/app/App.tsx"), package: path.join(root, "web/package.json"), report: reportPath }; const artifacts = Object.fromEntries(Object.entries(artifactPaths).map(([name, file]) => [name, { path: relative(file), sha256: sha256(file) }])); fs.writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, task: "M14-04E", parentTask: "M14-04D", enablingTask: false, parityStateChange: false, runtime: "PLAYWRIGHT_CHROMIUM", operation: "CHROMIUM_KEYMAP_LAYOUT_MODIFIER_FIXTURE", artifacts, nextTask: "M14-04F" }, null, 2)}\n`); }
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
assert.deepEqual({ task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { task: "M14-04E", parentTask: "M14-04D", nextTask: "M14-04F" });
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(path.join(root, artifact.path)), artifact.sha256, artifact.path);
|
||||
process.stdout.write(`chromium-keymap-ok fixtures=US,NON_US,DEAD_KEY,MODIFIER observations=${observations.length} execution=DISABLED next=${manifest.nextTask}\n`);
|
||||
} finally { await browser?.close(); await new Promise((resolve) => server.close(resolve)); }
|
||||
44
tools/web/check-chromium-pointer-contract.mjs
Normal file
44
tools/web/check-chromium-pointer-contract.mjs
Normal file
@@ -0,0 +1,44 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const distRoot = path.join(root, "web/dist");
|
||||
const reportPath = path.join(root, "tests/golden/M14-04C/chromium-pointer-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M14-04C/manifest.json");
|
||||
const mime = new Map([[".html", "text/html; charset=utf-8"], [".js", "text/javascript; charset=utf-8"], [".css", "text/css; charset=utf-8"], [".json", "application/json"], [".wasm", "application/wasm"]]);
|
||||
const server = http.createServer((request, response) => { const pathname = decodeURIComponent(new URL(request.url ?? "/", "http://127.0.0.1").pathname); const relative = pathname === "/" ? "index.html" : pathname.replace(/^\//u, ""); const file = path.resolve(distRoot, relative); if (!file.startsWith(`${distRoot}${path.sep}`) || !fs.existsSync(file) || !fs.statSync(file).isFile()) { response.writeHead(404); response.end("not found"); return; } response.statusCode = 200; response.setHeader("Content-Type", mime.get(path.extname(file)) ?? "application/octet-stream"); response.setHeader("Cross-Origin-Opener-Policy", "same-origin"); response.setHeader("Cross-Origin-Embedder-Policy", "require-corp"); response.setHeader("Cross-Origin-Resource-Policy", "same-origin"); response.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'; font-src 'self'; img-src 'self'; media-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"); fs.createReadStream(file).pipe(response); });
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address === "object");
|
||||
let browser;
|
||||
try {
|
||||
const playwright = await import(pathToFileURL(path.join(root, "web/node_modules/playwright/index.mjs")).href);
|
||||
browser = await playwright.chromium.launch({ headless: true, args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"] });
|
||||
const page = await (await browser.newContext({ viewport: { width: 800, height: 500 } })).newPage();
|
||||
await page.goto(`http://127.0.0.1:${address.port}/`, { waitUntil: "load" });
|
||||
await page.waitForFunction(() => document.querySelector("[data-testid=engine-status]")?.textContent === "Engine: ready, open a .blend file", undefined, { timeout: 20_000 });
|
||||
const observations = await page.locator("canvas.viewport-canvas").evaluate((element) => {
|
||||
const result = [];
|
||||
for (const [pointerType, pointerId, pressure, tiltX, tiltY] of [["mouse", 1, 0, 0, 0], ["touch", 2, 0.5, 0, 0], ["pen", 3, 0.75, 20, -15]]) {
|
||||
element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerType, pointerId, pressure, tiltX, tiltY, button: 0, buttons: 1 }));
|
||||
result.push(JSON.parse(element.getAttribute("data-last-pointer") ?? "null"));
|
||||
element.dispatchEvent(new PointerEvent("pointercancel", { bubbles: true, pointerType, pointerId, pressure, tiltX, tiltY, button: 0, buttons: 0 }));
|
||||
result.push(JSON.parse(element.getAttribute("data-last-pointer") ?? "null"));
|
||||
}
|
||||
return result;
|
||||
});
|
||||
assert.deepEqual(observations.map((item) => [item.pointerType, item.pointerId, item.cancelled]), [["mouse", 1, false], ["mouse", 1, true], ["touch", 2, false], ["touch", 2, true], ["pen", 3, false], ["pen", 3, true]]);
|
||||
const report = { schemaVersion: 1, task: "M14-04C", operation: "CHROMIUM_POINTER_IDENTITY_CANCEL_CONTRACT", runtime: "PLAYWRIGHT_CHROMIUM", pointerTypes: ["mouse", "touch", "pen"], observations, execution: "DISABLED", nextTask: "M14-04D" };
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/");
|
||||
if (process.env.UPDATE_M14_04C_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); const artifactPaths = { parentManifest: path.join(root, "tests/golden/M14-04B/manifest.json"), checker: path.join(root, "tools/web/check-chromium-pointer-contract.mjs"), protocol: path.join(root, "web/protocol/pointer-contract.ts"), unit: path.join(root, "web/tests/unit/pointer-contract.test.mjs"), viewport: path.join(root, "web/app/src/three-adapter/viewport.ts"), offscreen: path.join(root, "web/app/src/three-adapter/offscreen-viewport.ts"), package: path.join(root, "web/package.json"), report: reportPath }; const artifacts = Object.fromEntries(Object.entries(artifactPaths).map(([name, file]) => [name, { path: relative(file), sha256: sha256(file) }])); fs.writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, task: "M14-04C", parentTask: "M14-04B", enablingTask: false, parityStateChange: false, runtime: "PLAYWRIGHT_CHROMIUM", operation: "CHROMIUM_POINTER_IDENTITY_CANCEL_CONTRACT", artifacts, nextTask: "M14-04D" }, null, 2)}\n`); }
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
assert.deepEqual({ task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { task: "M14-04C", parentTask: "M14-04B", nextTask: "M14-04D" });
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(path.join(root, artifact.path)), artifact.sha256, artifact.path);
|
||||
process.stdout.write(`chromium-pointer-ok types=mouse,touch,pen cancel=PASS execution=DISABLED next=${manifest.nextTask}\n`);
|
||||
} finally { await browser?.close(); await new Promise((resolve) => server.close(resolve)); }
|
||||
56
tools/web/check-chromium-release-freeze.mjs
Normal file
56
tools/web/check-chromium-release-freeze.mjs
Normal file
@@ -0,0 +1,56 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const reportPath = path.join(root, "tests/golden/M14-01A/chromium-freeze-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M14-01A/manifest.json");
|
||||
const digest = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
const fileDigest = (file) => digest(fs.readFileSync(file));
|
||||
const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/");
|
||||
const chromeCommand = process.env.CHROME_PATH ?? "google-chrome";
|
||||
const chromeVersion = execFileSync(chromeCommand, ["--version"], { encoding: "utf8" }).trim();
|
||||
const versionMatch = chromeVersion.match(/^(Google Chrome|Chromium) (\d+\.\d+\.\d+\.\d+)$/u);
|
||||
assert.ok(versionMatch, `unsupported Chromium version output: ${chromeVersion}`);
|
||||
const chromePath = fs.realpathSync(execFileSync("bash", ["-lc", `command -v ${chromeCommand}`], { encoding: "utf8" }).trim());
|
||||
const enginePath = path.join(root, "web/app/public/engine-manifest.json");
|
||||
const engine = JSON.parse(fs.readFileSync(enginePath, "utf8"));
|
||||
const variants = Object.fromEntries(engine.variants.map((variant) => [variant.id, Object.fromEntries(Object.entries(variant.resources).map(([kind, resource]) => {
|
||||
const file = path.join(root, "web/app/public", resource.url.replace(/^\//u, ""));
|
||||
assert.equal(fileDigest(file), resource.sha256, `${variant.id}/${kind} resource hash drifted`);
|
||||
return [kind, { path: relative(file), sha256: resource.sha256 }];
|
||||
}))]));
|
||||
const rcPath = path.join(root, "release/RC_MANIFEST.json");
|
||||
const rc = JSON.parse(fs.readFileSync(rcPath, "utf8"));
|
||||
const archiveEntries = Object.fromEntries(["binaryArchive", "sourceArchive"].map((name) => {
|
||||
const file = path.join(root, rc.artifacts[name].path);
|
||||
assert.equal(fileDigest(file), rc.artifacts[name].sha256, `${name} hash drifted from RC manifest`);
|
||||
return [name, { path: relative(file), bytes: fs.statSync(file).size, sha256: fileDigest(file) }];
|
||||
}));
|
||||
const sumsPath = path.join(root, "release/SHA256SUMS.txt");
|
||||
const sums = fs.readFileSync(sumsPath, "utf8").trim().split(/\r?\n/u);
|
||||
assert.deepEqual(sums, [
|
||||
`${archiveEntries.binaryArchive.sha256} ${path.basename(archiveEntries.binaryArchive.path)}`,
|
||||
`${archiveEntries.sourceArchive.sha256} ${path.basename(archiveEntries.sourceArchive.path)}`,
|
||||
]);
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
task: "M14-01A",
|
||||
operation: "CHROMIUM_ENGINE_ARCHIVE_FREEZE",
|
||||
browser: { family: versionMatch[1], version: versionMatch[2], command: chromeCommand, executablePath: chromePath, executableSha256: fileDigest(chromePath), versionOutput: chromeVersion },
|
||||
engine: { releaseId: engine.releaseId, manifest: { path: relative(enginePath), sha256: fileDigest(enginePath) }, variants },
|
||||
archives: archiveEntries,
|
||||
checksums: { path: relative(sumsPath), sha256: fileDigest(sumsPath) },
|
||||
rcManifest: { path: relative(rcPath), sha256: fileDigest(rcPath), rcId: rc.rcId, gitCommit: rc.gitCommit },
|
||||
execution: "DISABLED",
|
||||
nextTask: "M14-01B",
|
||||
};
|
||||
if (process.env.UPDATE_M14_01A_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); }
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M14-01A", parentTask: "M13-05H", nextTask: "M14-01B" });
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileDigest(path.join(root, artifact.path)), artifact.sha256, artifact.path);
|
||||
process.stdout.write(`chromium-freeze-ok browser=${versionMatch[2]} engine=${engine.releaseId} variants=${engine.variants.length} archives=2 checksums=1 execution=DISABLED next=${manifest.nextTask}\n`);
|
||||
38
tools/web/check-chromium-webgpu-boundary.mjs
Normal file
38
tools/web/check-chromium-webgpu-boundary.mjs
Normal file
@@ -0,0 +1,38 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import ts from "../../web/node_modules/typescript/lib/typescript.js";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../..");
|
||||
const sourcePath = path.join(root, "web/protocol/render-routing.ts");
|
||||
const source = fs.readFileSync(sourcePath, "utf8").replace('import type { ErrorCode } from "./error";\n', "");
|
||||
const transpiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: sourcePath, reportDiagnostics: true });
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const routing = await import(`data:text/javascript;base64,${Buffer.from(transpiled.outputText).toString("base64")}`);
|
||||
const request = (backend) => ({ schemaVersion: 1, renderEngine: "BLENDER_EEVEE", backend, complexity: "BOUNDED" });
|
||||
const webgpu = routing.routeRenderExecution(request("WEBGPU"));
|
||||
const webgl2 = routing.routeRenderExecution(request("WEBGL2"));
|
||||
assert.equal(webgpu.status, "BLOCKED");
|
||||
assert.equal(webgpu.target, "WEB_LOCAL_BOUNDED");
|
||||
assert.equal(webgpu.issues[0].code, "WEBGPU_RENDERER_UNAVAILABLE");
|
||||
assert.equal(webgl2.status, "READY");
|
||||
assert.equal(webgl2.target, "WEB_LOCAL_BOUNDED");
|
||||
const reportPath = path.join(root, "tests/golden/M14-01E/chromium-webgpu-boundary-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M14-01E/manifest.json");
|
||||
const report = { schemaVersion: 1, task: "M14-01E", operation: "CHROMIUM_WEBGPU_FAIL_CLOSED_BOUNDARY", runtime: "NODE_PROTOCOL_WITH_CHROMIUM_PROBE_IDENTITY", chromiumProbeIdentitySha256: JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M14-01D/probe-identity-report.json"), "utf8")).identitySha256, webgpu: { status: webgpu.status, target: webgpu.target, code: webgpu.issues[0].code }, webgl2: { status: webgl2.status, target: webgl2.target, reason: webgl2.reason }, execution: "DISABLED", nextTask: "M14-04A" };
|
||||
if (process.env.UPDATE_M14_01E_REPORT === "1") {
|
||||
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
||||
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
const fileDigest = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/");
|
||||
const artifactPaths = { parentManifest: path.join(root, "tests/golden/M14-01D/manifest.json"), checker: path.join(root, "tools/web/check-chromium-webgpu-boundary.mjs"), package: path.join(root, "web/package.json"), protocol: sourcePath, report: reportPath };
|
||||
const artifacts = Object.fromEntries(Object.entries(artifactPaths).map(([name, file]) => [name, { path: relative(file), sha256: fileDigest(file) }]));
|
||||
fs.writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, task: "M14-01E", parentTask: "M14-01D", enablingTask: false, parityStateChange: false, runtime: "NODE_PROTOCOL_WITH_CHROMIUM_PROBE_IDENTITY", operation: "CHROMIUM_WEBGPU_FAIL_CLOSED_BOUNDARY", artifacts, nextTask: "M14-04A" }, null, 2)}\n`);
|
||||
}
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
const fileDigest = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
assert.deepEqual({ task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { task: "M14-01E", parentTask: "M14-01D", nextTask: "M14-04A" });
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileDigest(path.join(root, artifact.path)), artifact.sha256, artifact.path);
|
||||
process.stdout.write(`chromium-webgpu-boundary-ok webgpu=${webgpu.status}/${webgpu.issues[0].code} webgl2=${webgl2.status} execution=DISABLED next=${manifest.nextTask}\n`);
|
||||
81
tools/web/check-csp-policy.mjs
Normal file
81
tools/web/check-csp-policy.mjs
Normal file
@@ -0,0 +1,81 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createDeploymentHttpServer, listenDeploymentHttpServer, loadDeploymentContract } from "./deployment-http-server.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const reportPath = path.join(root, "tests/golden/M13-05A/csp-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M13-05A/manifest.json");
|
||||
const hashFile = async (file) => crypto.createHash("sha256").update(await fs.promises.readFile(file)).digest("hex");
|
||||
const contract = loadDeploymentContract();
|
||||
const policy = contract.responseHeaders.allResponses["Content-Security-Policy"];
|
||||
assert.equal(typeof policy, "string");
|
||||
const directives = new Map(policy.split(";").map((directive) => {
|
||||
const tokens = directive.trim().split(/\s+/u);
|
||||
return [tokens.shift(), tokens];
|
||||
}));
|
||||
const sourceTokens = [...directives.values()].flat();
|
||||
for (const forbidden of ["'unsafe-inline'", "'unsafe-eval'", "data:", "*", "blob:"]) {
|
||||
assert.equal(sourceTokens.includes(forbidden), false, `CSP contains forbidden token ${forbidden}`);
|
||||
}
|
||||
for (const required of ["default-src 'self'", "script-src 'self'", "worker-src 'self'", "connect-src 'self'", "object-src 'none'", "base-uri 'none'", "frame-ancestors 'none'"]) assert.ok(policy.includes(required), `CSP is missing ${required}`);
|
||||
const index = fs.readFileSync(path.join(root, "web/app/index.html"), "utf8");
|
||||
assert.doesNotMatch(index, /<script\b[^>]*(?:\b(?:src\s*=\s*["']data:|type\s*=\s*["']text\/javascript["']))/iu);
|
||||
assert.doesNotMatch(index, /\bon[a-z]+\s*=/iu);
|
||||
const productionSources = [];
|
||||
function walk(directory) {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const file = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) walk(file);
|
||||
else if (/\.(?:ts|tsx|js|mjs|html)$/.test(entry.name)) productionSources.push(file);
|
||||
}
|
||||
}
|
||||
walk(path.join(root, "web/app/src"));
|
||||
for (const file of productionSources) {
|
||||
const source = fs.readFileSync(file, "utf8");
|
||||
assert.doesNotMatch(source, /\beval\s*\(|\bnew\s+Function\s*\(/u, `dynamic code in ${file}`);
|
||||
assert.doesNotMatch(source, /(?:worker|script|src)\s*[:=]\s*["'`]data:/iu, `data script/worker in ${file}`);
|
||||
}
|
||||
const distRoot = path.join(root, "web/dist");
|
||||
let buildChecked = false;
|
||||
if (fs.existsSync(distRoot)) {
|
||||
const builtFiles = [];
|
||||
const walkBuilt = (directory) => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const file = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) walkBuilt(file);
|
||||
else if (/\.(?:js|html)$/.test(entry.name)) builtFiles.push(file);
|
||||
}
|
||||
};
|
||||
walkBuilt(distRoot);
|
||||
for (const file of builtFiles) {
|
||||
const source = fs.readFileSync(file, "utf8");
|
||||
assert.doesNotMatch(source, /\beval\s*\(|\bnew\s+Function\s*\(/u, `dynamic code in built file ${file}`);
|
||||
assert.doesNotMatch(source, /<script[^>]+\bsrc\s*=\s*["']data:/iu, `data script in built file ${file}`);
|
||||
}
|
||||
buildChecked = true;
|
||||
}
|
||||
const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "m13-05a-csp-"));
|
||||
fs.writeFileSync(path.join(fixtureRoot, "index.html"), index);
|
||||
const server = createDeploymentHttpServer({ root: fixtureRoot, contract });
|
||||
const origin = await listenDeploymentHttpServer(server);
|
||||
try {
|
||||
for (const pathname of ["/", "/missing", "/index.html"]) {
|
||||
const response = await fetch(`${origin}${pathname}`);
|
||||
assert.equal(response.headers.get("content-security-policy"), policy);
|
||||
}
|
||||
const report = { schemaVersion: 1, task: "M13-05A", operation: "CSP_POLICY", policy, sourceChecks: { inlineScript: "DENY", inlineHandler: "DENY", eval: "DENY", dataScript: "DENY", undeclaredConnect: "DENY" }, responseCount: 3, buildChecked, execution: "DISABLED", nextTask: "M13-05B" };
|
||||
if (process.env.UPDATE_M13_05A_REPORT === "1") { await fs.promises.mkdir(path.dirname(reportPath), { recursive: true }); await fs.promises.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); }
|
||||
assert.deepEqual(JSON.parse(await fs.promises.readFile(reportPath, "utf8")), report);
|
||||
const manifest = JSON.parse(await fs.promises.readFile(manifestPath, "utf8"));
|
||||
assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-05A", parentTask: "M13-04J", nextTask: "M13-05B" });
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(await hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`);
|
||||
process.stdout.write("csp-policy-ok inline=DENY eval=DENY dataScript=DENY undeclaredConnect=DENY responses=3 execution=DISABLED next=M13-05B\n");
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
fs.rmSync(fixtureRoot, { recursive: true, force: true });
|
||||
}
|
||||
114
tools/web/check-csp-resource-policy.mjs
Normal file
114
tools/web/check-csp-resource-policy.mjs
Normal file
@@ -0,0 +1,114 @@
|
||||
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 { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { createDeploymentHttpServer, listenDeploymentHttpServer, loadDeploymentContract } from "./deployment-http-server.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const reportPath = path.join(root, "tests/golden/M13-05B/csp-resource-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M13-05B/manifest.json");
|
||||
const hashFile = async (file) => crypto.createHash("sha256").update(await fs.promises.readFile(file)).digest("hex");
|
||||
const contract = loadDeploymentContract();
|
||||
const policy = contract.responseHeaders.allResponses["Content-Security-Policy"];
|
||||
const directives = new Map(policy.split(";").map((directive) => {
|
||||
const tokens = directive.trim().split(/\s+/u);
|
||||
return [tokens.shift(), tokens];
|
||||
}));
|
||||
const tokens = (name) => directives.get(name) ?? [];
|
||||
const sameOriginOnly = ["worker-src", "font-src", "img-src", "media-src"];
|
||||
assert.deepEqual(tokens("script-src"), ["'self'", "'wasm-unsafe-eval'"]);
|
||||
assert.deepEqual(tokens("worker-src"), ["'self'"]);
|
||||
assert.deepEqual(tokens("font-src"), ["'self'"]);
|
||||
assert.deepEqual(tokens("img-src"), ["'self'"]);
|
||||
assert.deepEqual(tokens("media-src"), ["'self'"]);
|
||||
for (const name of sameOriginOnly) for (const forbidden of ["data:", "blob:", "*"]) assert.equal(tokens(name).includes(forbidden), false, `${name} contains ${forbidden}`);
|
||||
for (const forbidden of ["'unsafe-inline'", "'unsafe-eval'", "data:", "blob:", "*"]) {
|
||||
assert.equal([...directives.values()].flat().includes(forbidden), false, `CSP contains forbidden token ${forbidden}`);
|
||||
}
|
||||
|
||||
const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "m13-05b-csp-"));
|
||||
const onePixelPng = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64");
|
||||
const wav = Buffer.alloc(48);
|
||||
wav.write("RIFF", 0); wav.writeUInt32LE(40, 4); wav.write("WAVEfmt ", 8); wav.writeUInt32LE(16, 16); wav.writeUInt16LE(1, 20); wav.writeUInt16LE(1, 22); wav.writeUInt32LE(8000, 24); wav.writeUInt32LE(8000, 28); wav.writeUInt16LE(1, 32); wav.writeUInt16LE(8, 34); wav.write("data", 36); wav.writeUInt32LE(4, 40); wav.fill(128, 44);
|
||||
const fontSource = path.join(root, "blender/release/datafiles/fonts/Inter.woff2");
|
||||
assert.ok(fs.existsSync(fontSource), "font fixture is missing");
|
||||
const write = (relative, value) => { const file = path.join(fixtureRoot, relative); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, value); };
|
||||
write("index.html", "<!doctype html><title>CSP resource matrix</title><script type=module src=/app.js></script>");
|
||||
write("app.js", `
|
||||
const result = { worker: false, wasm: false, image: false, font: false, media: false, dataImageBlocked: false, blobWorkerBlocked: false, crossOriginBlocked: false };
|
||||
const violations = [];
|
||||
document.addEventListener("securitypolicyviolation", (event) => {
|
||||
violations.push({ directive: event.effectiveDirective, blockedURI: event.blockedURI });
|
||||
if (event.effectiveDirective === "worker-src" && (event.blockedURI === "blob" || event.blockedURI.startsWith("blob:"))) result.blobWorkerBlocked = true;
|
||||
if (event.effectiveDirective === "connect-src") result.crossOriginBlocked = true;
|
||||
});
|
||||
const worker = new Worker("/worker.js", { type: "module" });
|
||||
worker.onmessage = () => { result.worker = true; worker.terminate(); };
|
||||
fetch("/engine.wasm").then((response) => WebAssembly.instantiateStreaming(response)).then(() => { result.wasm = true; }).catch(() => {});
|
||||
const image = new Image(); image.onload = () => { result.image = true; }; image.src = "/pixel.png";
|
||||
const audio = new Audio(); audio.addEventListener("loadstart", () => { result.media = true; }, { once: true }); audio.src = "/tone.wav"; audio.load();
|
||||
new FontFace("CSPMatrix", "url(/font.woff2)").load().then(() => { result.font = true; }).catch(() => {});
|
||||
const blockedImage = new Image(); blockedImage.onerror = () => { result.dataImageBlocked = true; }; blockedImage.src = "data:image/png;base64,iVBORw0KGgo=";
|
||||
try { new Worker(URL.createObjectURL(new Blob(["postMessage('unexpected')"], { type: "text/javascript" }))); } catch { result.blobWorkerBlocked = true; }
|
||||
fetch("http://127.0.0.1:9/csp-cross-origin").catch(() => { result.crossOriginBlocked = true; });
|
||||
setTimeout(() => { window.__cspResourceResult = { result, violations }; }, 500);
|
||||
`);
|
||||
write("worker.js", "postMessage('worker-ok');\n");
|
||||
write("engine.wasm", Buffer.from([0, 97, 115, 109, 1, 0, 0, 0]));
|
||||
write("pixel.png", onePixelPng);
|
||||
write("tone.wav", wav);
|
||||
fs.copyFileSync(fontSource, path.join(fixtureRoot, "font.woff2"));
|
||||
const server = createDeploymentHttpServer({ root: fixtureRoot, contract });
|
||||
const origin = await listenDeploymentHttpServer(server);
|
||||
const expected = new Map([
|
||||
["/worker.js", ".js"], ["/engine.wasm", ".wasm"], ["/font.woff2", ".woff2"], ["/pixel.png", ".png"], ["/tone.wav", ".wav"],
|
||||
]);
|
||||
try {
|
||||
for (const [pathname, extension] of expected) {
|
||||
const response = await fetch(`${origin}${pathname}`);
|
||||
assert.equal(response.status, 200, pathname);
|
||||
assert.equal(response.headers.get("content-security-policy"), policy, `CSP mismatch ${pathname}`);
|
||||
assert.equal(response.headers.get("content-type"), contract.mimeTypes[extension], `MIME mismatch ${pathname}`);
|
||||
assert.ok(Number(response.headers.get("content-length")) > 0, `empty resource ${pathname}`);
|
||||
}
|
||||
for (const pathname of ["/missing", "/index.html"]) {
|
||||
const response = await fetch(`${origin}${pathname}`);
|
||||
assert.equal(response.headers.get("content-security-policy"), policy, `CSP missing on ${pathname}`);
|
||||
}
|
||||
|
||||
const { chromium } = await import(pathToFileURL(path.join(root, "web/node_modules/playwright/index.mjs")).href);
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
await page.goto(`${origin}/`, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(700);
|
||||
const browserReport = await page.evaluate(() => window.__cspResourceResult);
|
||||
await browser.close();
|
||||
assert.ok(browserReport, "browser CSP report missing");
|
||||
assert.equal(browserReport.result.worker, true);
|
||||
assert.equal(browserReport.result.wasm, true);
|
||||
assert.equal(browserReport.result.image, true);
|
||||
assert.equal(browserReport.result.media, true);
|
||||
assert.equal(browserReport.result.dataImageBlocked, true);
|
||||
assert.equal(browserReport.result.blobWorkerBlocked, true);
|
||||
assert.equal(browserReport.result.crossOriginBlocked, true);
|
||||
assert.ok(browserReport.violations.some(({ directive }) => directive === "img-src"));
|
||||
assert.ok(browserReport.violations.some(({ directive }) => directive === "worker-src"));
|
||||
assert.ok(browserReport.violations.some(({ directive }) => directive === "connect-src"));
|
||||
const report = {
|
||||
schemaVersion: 1, task: "M13-05B", operation: "CSP_RESOURCE_POLICY", policy,
|
||||
resources: { worker: "SAME_ORIGIN", wasm: "SAME_ORIGIN_WASM_UNSAFE_EVAL", font: "SAME_ORIGIN", image: "SAME_ORIGIN", media: "SAME_ORIGIN" },
|
||||
denied: { dataImage: "DENY", blobWorker: "DENY", crossOriginConnect: "DENY" },
|
||||
responseCount: expected.size + 2, browser: browserReport, execution: "DISABLED", nextTask: "M13-05C",
|
||||
};
|
||||
if (process.env.UPDATE_M13_05B_REPORT === "1") { await fs.promises.mkdir(path.dirname(reportPath), { recursive: true }); await fs.promises.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); }
|
||||
assert.deepEqual(JSON.parse(await fs.promises.readFile(reportPath, "utf8")), report);
|
||||
const manifest = JSON.parse(await fs.promises.readFile(manifestPath, "utf8"));
|
||||
assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-05B", parentTask: "M13-05A", nextTask: "M13-05C" });
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(await hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`);
|
||||
process.stdout.write("csp-resource-policy-ok worker=SAME_ORIGIN wasm=SAME_ORIGIN font=SAME_ORIGIN image=SAME_ORIGIN media=SAME_ORIGIN denied=data,blob,cross-origin execution=DISABLED next=M13-05C\n");
|
||||
} finally {
|
||||
if (server.listening) await new Promise((resolve) => server.close(resolve));
|
||||
fs.rmSync(fixtureRoot, { recursive: true, force: true });
|
||||
}
|
||||
49
tools/web/check-dependency-inventory.mjs
Normal file
49
tools/web/check-dependency-inventory.mjs
Normal file
@@ -0,0 +1,49 @@
|
||||
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 { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const generator = path.join(root, "tools/web/generate-dependency-inventory.mjs");
|
||||
const inventoryPath = path.join(root, "tests/golden/M13-05C/dependency-inventory.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M13-05C/manifest.json");
|
||||
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
const fileSha256 = (file) => sha256(fs.readFileSync(file));
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m13-05c-inventory-"));
|
||||
const regenerated = path.join(temporary, "dependency-inventory.json");
|
||||
try {
|
||||
const run = spawnSync(process.execPath, [generator, regenerated], { cwd: root, encoding: "utf8", maxBuffer: 4 * 1024 * 1024 });
|
||||
assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`);
|
||||
assert.deepEqual(fs.readFileSync(regenerated), fs.readFileSync(inventoryPath), "dependency inventory is not deterministic");
|
||||
const inventory = JSON.parse(fs.readFileSync(inventoryPath, "utf8"));
|
||||
assert.equal(inventory.schemaVersion, 1);
|
||||
assert.equal(inventory.task, "M13-05C");
|
||||
assert.equal(inventory.nextTask, "M13-05D");
|
||||
assert.deepEqual(inventory.roots.production, ["react", "react-dom"]);
|
||||
assert.ok(inventory.roots.build.includes("vite"));
|
||||
assert.deepEqual(inventory.roots.test, ["@axe-core/playwright", "@playwright/test"]);
|
||||
assert.ok(inventory.packages.length > 0);
|
||||
assert.equal(inventory.packages.length, new Set(inventory.packages.map((item) => item.path)).size);
|
||||
assert.equal(inventory.packages.some((item) => item.categories.length === 0), false);
|
||||
for (const item of inventory.packages) {
|
||||
assert.match(item.path, /^node_modules\//u);
|
||||
assert.match(item.name, /\S/u);
|
||||
assert.match(item.version, /^\d+\.\d+\.\d+/u);
|
||||
assert.ok(item.categories.every((category) => ["production", "build", "test"].includes(category)));
|
||||
assert.ok(item.categories.length >= 1);
|
||||
if (item.resolved !== null) assert.match(item.resolved, /^https:\/\//u);
|
||||
if (item.integrity !== null) assert.match(item.integrity, /^sha512-/u);
|
||||
}
|
||||
assert.equal(inventory.categoryCounts.production, inventory.packages.filter((item) => item.categories.includes("production")).length);
|
||||
assert.equal(inventory.categoryCounts.build, inventory.packages.filter((item) => item.categories.includes("build")).length);
|
||||
assert.equal(inventory.categoryCounts.test, inventory.packages.filter((item) => item.categories.includes("test")).length);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-05C", parentTask: "M13-05B", nextTask: "M13-05D" });
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileSha256(path.join(root, artifact.path)), artifact.sha256, artifact.path);
|
||||
process.stdout.write(`dependency-inventory-ok packages=${inventory.packages.length} production=${inventory.categoryCounts.production} build=${inventory.categoryCounts.build} test=${inventory.categoryCounts.test} shared=${inventory.sharedCount} deterministic=true next=M13-05D\n`);
|
||||
} finally {
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
70
tools/web/check-dependency-severity-policy.mjs
Normal file
70
tools/web/check-dependency-severity-policy.mjs
Normal file
@@ -0,0 +1,70 @@
|
||||
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 { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const policyPath = path.join(root, "docs/web/dependency-severity-policy.json");
|
||||
const inventoryPath = path.join(root, "tests/golden/M13-05C/dependency-inventory.json");
|
||||
const reportPath = path.join(root, "tests/golden/M13-05D/dependency-severity-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M13-05D/manifest.json");
|
||||
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
const fileSha256 = (file) => sha256(fs.readFileSync(file));
|
||||
const policy = JSON.parse(fs.readFileSync(policyPath, "utf8"));
|
||||
const inventory = JSON.parse(fs.readFileSync(inventoryPath, "utf8"));
|
||||
assert.equal(policy.schemaVersion, 1);
|
||||
assert.equal(policy.task, "M13-05D");
|
||||
assert.deepEqual(policy.severityOrder, ["LOW", "MEDIUM", "HIGH", "BLOCKER"]);
|
||||
assert.deepEqual(policy.gates, { BLOCKER: "BLOCK", HIGH: "BLOCK", MEDIUM: "REVIEW", LOW: "TRACK" });
|
||||
assert.deepEqual(policy.exceptionFields, ["owner", "expiresOn", "reason", "alternativeControl"]);
|
||||
assert.deepEqual(policy.findings, []);
|
||||
assert.deepEqual(policy.exceptions, []);
|
||||
assert.equal(inventory.task, "M13-05C");
|
||||
assert.ok(inventory.packages.length > 0);
|
||||
|
||||
const severityRank = new Map(policy.severityOrder.map((severity, index) => [severity, index]));
|
||||
function validateException(exception, today = "2026-08-19") {
|
||||
assert.equal(typeof exception.owner, "string");
|
||||
assert.ok(exception.owner.trim().length > 0);
|
||||
assert.match(exception.expiresOn, /^\d{4}-\d{2}-\d{2}$/u);
|
||||
assert.ok(exception.expiresOn >= today, "exception is expired");
|
||||
assert.equal(typeof exception.reason, "string");
|
||||
assert.ok(exception.reason.trim().length > 0);
|
||||
assert.equal(typeof exception.alternativeControl, "string");
|
||||
assert.ok(exception.alternativeControl.trim().length > 0);
|
||||
}
|
||||
const accepted = { owner: "security@example.invalid", expiresOn: "2026-12-31", reason: "upstream patch window", alternativeControl: "network egress deny and pinned lockfile" };
|
||||
validateException(accepted);
|
||||
for (const invalid of [
|
||||
{ ...accepted, owner: "" },
|
||||
{ ...accepted, expiresOn: "2026-08-18" },
|
||||
{ ...accepted, reason: "" },
|
||||
{ ...accepted, alternativeControl: "" },
|
||||
]) assert.throws(() => validateException(invalid));
|
||||
function evaluate(findings, exceptions) {
|
||||
const byId = new Map(exceptions.map((exception) => [exception.findingId, exception]));
|
||||
const decisions = findings.map((finding) => {
|
||||
assert.ok(severityRank.has(finding.severity));
|
||||
const exception = byId.get(finding.id);
|
||||
if (!exception) return { id: finding.id, severity: finding.severity, decision: policy.gates[finding.severity], exception: false };
|
||||
validateException(exception);
|
||||
return { id: finding.id, severity: finding.severity, decision: "EXCEPTION", exception: true };
|
||||
});
|
||||
const blocked = decisions.filter((decision) => decision.decision === "BLOCK");
|
||||
const review = decisions.filter((decision) => decision.decision === "REVIEW");
|
||||
return { decisions, blocked: blocked.length, review: review.length, status: blocked.length === 0 && review.length === 0 ? "PASS" : "BLOCKED" };
|
||||
}
|
||||
const clean = evaluate(policy.findings, policy.exceptions);
|
||||
assert.deepEqual(clean, { decisions: [], blocked: 0, review: 0, status: "PASS" });
|
||||
assert.equal(evaluate([{ id: "synthetic-high", severity: "HIGH" }], []).status, "BLOCKED");
|
||||
assert.equal(evaluate([{ id: "synthetic-high", severity: "HIGH" }], [{ findingId: "synthetic-high", ...accepted }]).status, "PASS");
|
||||
assert.equal(evaluate([{ id: "synthetic-medium", severity: "MEDIUM" }], []).status, "BLOCKED");
|
||||
const report = { schemaVersion: 1, task: "M13-05D", operation: "DEPENDENCY_SEVERITY_POLICY", inventorySha256: fileSha256(inventoryPath), findings: policy.findings.length, exceptions: policy.exceptions.length, blocked: clean.blocked, review: clean.review, status: clean.status, negativeCases: { missingException: "BLOCKED", expiredException: "REJECTED", incompleteException: "REJECTED", completeException: "ACCEPTED" }, execution: "DISABLED", nextTask: "M13-05E" };
|
||||
if (process.env.UPDATE_M13_05D_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); }
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-05D", parentTask: "M13-05C", nextTask: "M13-05E" });
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileSha256(path.join(root, artifact.path)), artifact.sha256, artifact.path);
|
||||
process.stdout.write("dependency-severity-policy-ok findings=0 exceptions=0 blocked=0 review=0 negativeCases=4 status=PASS execution=DISABLED next=M13-05E\n");
|
||||
@@ -23,6 +23,7 @@ assert.deepEqual(contract.responseHeaders.allResponses, {
|
||||
"Cross-Origin-Opener-Policy": "same-origin",
|
||||
"Cross-Origin-Embedder-Policy": "require-corp",
|
||||
"Cross-Origin-Resource-Policy": "same-origin",
|
||||
"Content-Security-Policy": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'; font-src 'self'; img-src 'self'; media-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
|
||||
});
|
||||
const routes = Object.fromEntries(contract.responseHeaders.routes.map((route) => [route.id, route]));
|
||||
assert.deepEqual(routes["entry-document"], { id: "entry-document", patterns: ["/", "/index.html"], cacheControl: "no-cache" });
|
||||
@@ -38,7 +39,18 @@ for (const [extension, mime] of Object.entries({
|
||||
".wasm": "application/wasm",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".ttf": "font/ttf",
|
||||
".otf": "font/otf",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".wav": "audio/wav",
|
||||
".mp3": "audio/mpeg",
|
||||
".ogg": "audio/ogg",
|
||||
".mp4": "video/mp4",
|
||||
".webm": "video/webm",
|
||||
".blend": "application/octet-stream",
|
||||
".nvdb": "application/x-nanovdb",
|
||||
})) assert.equal(contract.mimeTypes[extension], mime, `${extension} MIME drifted`);
|
||||
|
||||
81
tools/web/check-firefox-capability.mjs
Normal file
81
tools/web/check-firefox-capability.mjs
Normal file
@@ -0,0 +1,81 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const distRoot = path.join(root, "web/dist");
|
||||
const reportPath = path.join(root, "tests/golden/M14-01B/firefox-capability-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M14-01B/manifest.json");
|
||||
const digest = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
||||
const fileDigest = (file) => digest(fs.readFileSync(file));
|
||||
const mime = new Map([[".html", "text/html; charset=utf-8"], [".js", "text/javascript; charset=utf-8"], [".css", "text/css; charset=utf-8"], [".json", "application/json"], [".wasm", "application/wasm"], [".png", "image/png"], [".woff2", "font/woff2"]]);
|
||||
const workerName = fs.readdirSync(path.join(distRoot, "assets")).find((name) => /^storage\.worker-[\w-]+\.js$/u.test(name));
|
||||
const wasmName = fs.readdirSync(path.join(distRoot, "assets")).find((name) => /^web_engine-[\w-]+\.wasm$/u.test(name));
|
||||
assert.ok(workerName && wasmName, "production worker/WASM assets are missing");
|
||||
const server = http.createServer((request, response) => {
|
||||
const pathname = decodeURIComponent(new URL(request.url ?? "/", "http://127.0.0.1").pathname);
|
||||
const relative = pathname === "/" ? "index.html" : pathname.replace(/^\//u, "");
|
||||
const file = path.resolve(distRoot, relative);
|
||||
if (!file.startsWith(`${distRoot}${path.sep}`) || !fs.existsSync(file) || !fs.statSync(file).isFile()) { response.writeHead(404); response.end("not found"); return; }
|
||||
response.statusCode = 200;
|
||||
response.setHeader("Content-Type", mime.get(path.extname(file)) ?? "application/octet-stream");
|
||||
response.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
||||
response.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
|
||||
response.setHeader("Cross-Origin-Resource-Policy", "same-origin");
|
||||
response.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'; font-src 'self'; img-src 'self'; media-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'");
|
||||
fs.createReadStream(file).pipe(response);
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address === "object");
|
||||
const origin = `http://127.0.0.1:${address.port}`;
|
||||
let browser;
|
||||
try {
|
||||
const { firefox } = await import(pathToFileURL(path.join(root, "web/node_modules/playwright/index.mjs")).href);
|
||||
browser = await firefox.launch({ headless: true });
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${origin}/`, { waitUntil: "load" });
|
||||
const probe = await page.evaluate(async ({ workerPath, wasmPath }) => {
|
||||
const run = async (name, operation) => {
|
||||
try { return { name, ...await operation() }; }
|
||||
catch (error) { return { name, status: "BLOCKED", code: error instanceof Error ? error.name : "PROBE_FAILED", detail: error instanceof Error ? error.message.slice(0, 200) : String(error) }; }
|
||||
};
|
||||
const wasm = await run("wasm", async () => {
|
||||
const bytes = await fetch(wasmPath).then((response) => { if (!response.ok) throw new Error(`HTTP_${response.status}`); return response.arrayBuffer(); });
|
||||
await WebAssembly.compile(bytes);
|
||||
return { status: "PASS", code: "WASM_READY", bytes: bytes.byteLength };
|
||||
});
|
||||
const worker = await run("worker", async () => {
|
||||
await new Promise((resolve, reject) => { const value = new Worker(workerPath, { type: "module" }); const timer = setTimeout(() => { value.terminate(); resolve(); }, 500); value.onerror = (event) => { clearTimeout(timer); value.terminate(); reject(new Error(event.message || "WORKER_LOAD_FAILED")); }; });
|
||||
return { status: "PASS", code: "WORKER_READY" };
|
||||
});
|
||||
const opfs = await run("opfs", async () => {
|
||||
if (!navigator.storage || typeof navigator.storage.getDirectory !== "function") return { status: "BLOCKED", code: "OPFS_UNAVAILABLE" };
|
||||
const directory = await navigator.storage.getDirectory(); const probeDirectory = await directory.getDirectoryHandle("m14-firefox-probe", { create: true }); const handle = await probeDirectory.getFileHandle("probe.bin", { create: true }); const writable = await handle.createWritable(); await writable.write(new Uint8Array([1, 2, 3])); await writable.close(); await probeDirectory.removeEntry("probe.bin"); await directory.removeEntry("m14-firefox-probe"); return { status: "PASS", code: "OPFS_READY" };
|
||||
});
|
||||
const indexeddb = await run("indexedDB", async () => {
|
||||
if (!indexedDB) return { status: "BLOCKED", code: "INDEXEDDB_UNAVAILABLE" };
|
||||
const name = "m14-firefox-probe"; await new Promise((resolve, reject) => { const request = indexedDB.open(name, 1); request.onupgradeneeded = () => request.result.createObjectStore("probe"); request.onsuccess = () => { request.result.close(); resolve(); }; request.onerror = () => reject(request.error ?? new Error("INDEXEDDB_OPEN_FAILED")); }); await new Promise((resolve) => { const request = indexedDB.deleteDatabase(name); request.onsuccess = request.onerror = request.onblocked = () => resolve(); }); return { status: "PASS", code: "INDEXEDDB_READY" };
|
||||
});
|
||||
const webgl2 = await run("webgl2", async () => { const canvas = document.createElement("canvas"); const gl = canvas.getContext("webgl2"); if (!gl) return { status: "BLOCKED", code: "WEBGL2_UNAVAILABLE" }; const debug = gl.getExtension("WEBGL_debug_renderer_info"); return { status: "PASS", code: "WEBGL2_READY", renderer: debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) : "REDACTED" }; });
|
||||
const webgpu = await run("webgpu", async () => { if (!("gpu" in navigator)) return { status: "BLOCKED", code: "WEBGPU_UNAVAILABLE" }; const adapter = await navigator.gpu.requestAdapter(); if (!adapter) return { status: "BLOCKED", code: "WEBGPU_ADAPTER_UNAVAILABLE" }; return { status: "PASS", code: "WEBGPU_READY", adapter: adapter.info?.description ?? adapter.name ?? "REDACTED" }; });
|
||||
const offscreen = await run("offscreen", async () => { if (typeof OffscreenCanvas !== "function") return { status: "BLOCKED", code: "OFFSCREEN_UNAVAILABLE" }; const canvas = new OffscreenCanvas(2, 2); return { status: "PASS", code: "OFFSCREEN_READY", context2d: Boolean(canvas.getContext("2d")) }; });
|
||||
const isolation = { name: "isolation", status: crossOriginIsolated ? "PASS" : "BLOCKED", code: crossOriginIsolated ? "ISOLATION_READY" : "ISOLATION_REQUIRED" };
|
||||
return { userAgent: navigator.userAgent, platform: navigator.platform, hardwareConcurrency: navigator.hardwareConcurrency, capabilities: [wasm, worker, opfs, indexeddb, webgl2, webgpu, offscreen, isolation] };
|
||||
}, { workerPath: `/assets/${workerName}`, wasmPath: `/assets/${wasmName}` });
|
||||
const report = { schemaVersion: 1, task: "M14-01B", operation: "FIREFOX_CAPABILITY_PROBE", runtime: "PLAYWRIGHT_FIREFOX", browser: { version: await browser.version(), executablePath: (await import(pathToFileURL(path.join(root, "web/node_modules/playwright/index.mjs")).href)).firefox.executablePath(), userAgent: probe.userAgent, platform: probe.platform, hardwareConcurrency: probe.hardwareConcurrency }, assets: { worker: { path: `web/dist/assets/${workerName}`, sha256: fileDigest(path.join(distRoot, "assets", workerName)) }, wasm: { path: `web/dist/assets/${wasmName}`, sha256: fileDigest(path.join(distRoot, "assets", wasmName)) } }, capabilities: Object.fromEntries(probe.capabilities.map((item) => [item.name, item])), supportRule: "PASS_ONLY_WHEN_PROBED; BLOCKED_OR_UNAVAILABLE_DOES_NOT_CLAIM_SUPPORT", execution: "DISABLED", nextTask: "M14-01C" };
|
||||
if (process.env.UPDATE_M14_01B_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); }
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M14-01B", parentTask: "M14-01A", nextTask: "M14-01C" });
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileDigest(path.join(root, artifact.path)), artifact.sha256, artifact.path);
|
||||
const summary = probe.capabilities.map((item) => `${item.name}=${item.status}`).join(",");
|
||||
process.stdout.write(`firefox-capability-ok version=${report.browser.version} ${summary} execution=DISABLED next=${manifest.nextTask}\n`);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
45
tools/web/check-fuzz-regression.mjs
Normal file
45
tools/web/check-fuzz-regression.mjs
Normal file
@@ -0,0 +1,45 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const runner = path.join(root, "tools/web/fuzz-case-runner.mjs");
|
||||
const corpusRoot = path.join(root, "tests/files/web/fuzz-regressions");
|
||||
const reportPath = path.join(root, "tests/golden/M13-05G/fuzz-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M13-05G/manifest.json");
|
||||
const seed = 0x5a17c0de;
|
||||
const iterationsPerDomain = 16;
|
||||
const domains = ["blend", "image", "font", "node", "manifest"];
|
||||
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
const fileSha256 = (file) => sha256(fs.readFileSync(file));
|
||||
fs.mkdirSync(corpusRoot, { recursive: true });
|
||||
const outcomes = [];
|
||||
for (const domain of domains) {
|
||||
for (let iteration = 0; iteration < iterationsPerDomain; iteration++) {
|
||||
const run = spawnSync(process.execPath, [runner, domain, String(seed), String(iteration)], { cwd: root, encoding: "utf8", maxBuffer: 2 * 1024 * 1024 });
|
||||
if (run.status !== 0 || run.signal) {
|
||||
const replay = { schemaVersion: 1, domain, seed, iteration, status: run.status, signal: run.signal, stdout: run.stdout, stderr: run.stderr };
|
||||
const bytes = Buffer.from(`${JSON.stringify(replay, null, 2)}\n`);
|
||||
const file = path.join(corpusRoot, `${sha256(bytes)}.json`);
|
||||
fs.writeFileSync(file, bytes);
|
||||
throw new Error(`FUZZ_CRASH_SAVED: ${path.relative(root, file)}`);
|
||||
}
|
||||
const lines = run.stdout.trim().split(/\r?\n/u).filter(Boolean);
|
||||
assert.equal(lines.length, 1, `${domain}:${iteration} returned an invalid receipt`);
|
||||
const receipt = JSON.parse(lines[0]);
|
||||
assert.ok(["ACCEPTED", "REJECTED"].includes(receipt.status));
|
||||
outcomes.push(receipt);
|
||||
}
|
||||
}
|
||||
const corpus = fs.readdirSync(corpusRoot).filter((name) => name.endsWith(".json")).sort().map((name) => ({ path: `tests/files/web/fuzz-regressions/${name}`, sha256: fileSha256(path.join(corpusRoot, name)) }));
|
||||
const counts = Object.fromEntries(domains.map((domain) => [domain, { accepted: outcomes.filter((item) => item.domain === domain && item.status === "ACCEPTED").length, rejected: outcomes.filter((item) => item.domain === domain && item.status === "REJECTED").length }]));
|
||||
const report = { schemaVersion: 1, task: "M13-05G", operation: "DETERMINISTIC_FUZZ_REGRESSION", seed, domains, iterationsPerDomain, totalCases: outcomes.length, counts, crashes: 0, minimizedCorpus: corpus, crashPolicy: "SAVE_REPLAY_BEFORE_FIX_AND_ADD_TO_REGRESSION", execution: "DISABLED", nextTask: "M13-05H" };
|
||||
if (process.env.UPDATE_M13_05G_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); }
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-05G", parentTask: "M13-05F", nextTask: "M13-05H" });
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileSha256(path.join(root, artifact.path)), artifact.sha256, artifact.path);
|
||||
process.stdout.write(`fuzz-regression-ok seed=${seed} cases=${outcomes.length} crashes=0 corpus=${corpus.length} execution=DISABLED next=M13-05H\n`);
|
||||
123
tools/web/check-glb-desktop-fixtures.mjs
Normal file
123
tools/web/check-glb-desktop-fixtures.mjs
Normal file
@@ -0,0 +1,123 @@
|
||||
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 { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const manifestPath = path.join(root, "tests/golden/M12-06A/manifest.json");
|
||||
const reportPath = path.join(root, "tests/golden/M12-06A/desktop-fixtures.json");
|
||||
const fixtureRoot = path.join(root, "tests/files/web/m12_glb_desktop_v1");
|
||||
const generator = path.join(root, "tools/web/generate-glb-desktop-fixtures.py");
|
||||
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
|
||||
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
const fileHash = (file) => sha256(fs.readFileSync(file));
|
||||
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
|
||||
const manifest = readJson(manifestPath);
|
||||
const report = readJson(reportPath);
|
||||
assert.equal(manifest.schemaVersion, 1);
|
||||
assert.equal(manifest.task, "M12-06A");
|
||||
assert.equal(manifest.parentTask, "M12-05F");
|
||||
assert.equal(manifest.nextTask, "M12-06B");
|
||||
assert.equal(report.schemaVersion, 1);
|
||||
assert.equal(report.task, "M12-06A");
|
||||
assert.equal(report.operation, "DESKTOP_GLB_FIXTURE_GENERATION");
|
||||
assert.equal(report.nextTask, "M12-06B");
|
||||
assert.equal(report.fixtureCount, 5);
|
||||
assert.equal(report.maxFixtureBytes, 512 * 1024);
|
||||
|
||||
for (const artifact of Object.values(manifest.artifacts)) {
|
||||
if (artifact.path === manifestPath) continue;
|
||||
const absolute = path.join(root, artifact.path);
|
||||
assert.ok(fs.existsSync(absolute), `missing artifact ${artifact.path}`);
|
||||
assert.equal(fileHash(absolute), artifact.sha256, `hash mismatch ${artifact.path}`);
|
||||
}
|
||||
|
||||
const inventory = readJson(path.join(root, "tests/golden/M12-05A/format-inventory.json"));
|
||||
for (const key of ["blenderVersion", "versionTuple", "buildDate", "buildTime", "buildHash", "buildBranch", "buildPlatform", "buildType", "binarySha256"]) {
|
||||
assert.deepEqual(report.runtime[key], inventory.runtime[key], `runtime drift in ${key}`);
|
||||
}
|
||||
|
||||
const expectedIds = ["mesh", "pbr", "uv", "skin", "animation"];
|
||||
assert.deepEqual(report.fixtures.map((fixture) => fixture.id), expectedIds);
|
||||
for (const fixture of report.fixtures) {
|
||||
assert.ok(fixture.byteLength > 128, `${fixture.id} is unexpectedly empty`);
|
||||
assert.ok(fixture.byteLength <= report.maxFixtureBytes, `${fixture.id} exceeds fixture budget`);
|
||||
const file = path.join(fixtureRoot, fixture.file);
|
||||
assert.equal(fileHash(file), fixture.sha256, `${fixture.id} fixture hash`);
|
||||
assert.equal(fs.statSync(file).size, fixture.byteLength, `${fixture.id} fixture byte length`);
|
||||
assert.equal(fixture.semantic.asset.version, "2.0");
|
||||
assert.equal(fixture.semantic.extensionsUsed.length, 0, `${fixture.id} unexpectedly uses an extension`);
|
||||
assert.equal(fixture.semantic.extensionsRequired.length, 0, `${fixture.id} unexpectedly requires an extension`);
|
||||
assert.equal(fixture.semantic.meshes.length, 1);
|
||||
assert.equal(fixture.semantic.meshes[0].primitives.length, 1);
|
||||
const primitive = fixture.semantic.meshes[0].primitives[0];
|
||||
assert.equal(primitive.mode, 4, `${fixture.id} is not triangle geometry`);
|
||||
assert.ok(primitive.attributes.POSITION, `${fixture.id} lacks POSITION`);
|
||||
assert.ok(primitive.indices, `${fixture.id} lacks indexed topology`);
|
||||
if (fixture.id === "mesh") {
|
||||
assert.ok(primitive.attributes.NORMAL);
|
||||
assert.ok(primitive.attributes.COLOR_0);
|
||||
assert.equal(primitive.indices.count, 6);
|
||||
}
|
||||
if (fixture.id === "pbr") {
|
||||
assert.equal(fixture.semantic.materials.length, 1);
|
||||
const pbr = fixture.semantic.materials[0].pbr;
|
||||
assert.deepEqual(pbr.baseColorFactor.map((value) => Number(value.toFixed(3))), [0.31, 0.57, 0.91, 1]);
|
||||
assert.equal(Number(pbr.metallicFactor.toFixed(3)), 0.72);
|
||||
assert.equal(Number(pbr.roughnessFactor.toFixed(3)), 0.28);
|
||||
assert.ok(fixture.semantic.materials[0].emissiveFactor);
|
||||
}
|
||||
if (fixture.id === "uv") {
|
||||
assert.ok(primitive.attributes.TEXCOORD_0);
|
||||
assert.equal(fixture.semantic.textures.length, 1);
|
||||
assert.equal(fixture.semantic.images.length, 1);
|
||||
assert.equal(fixture.semantic.images[0].mimeType, "image/png");
|
||||
}
|
||||
if (fixture.id === "skin") {
|
||||
assert.ok(primitive.attributes.JOINTS_0);
|
||||
assert.ok(primitive.attributes.WEIGHTS_0);
|
||||
assert.equal(fixture.semantic.skins.length, 1);
|
||||
assert.equal(fixture.semantic.skins[0].joints.length, 2);
|
||||
assert.equal(fixture.semantic.skins[0].inverseBindMatrices.count, 2);
|
||||
}
|
||||
if (fixture.id === "animation") {
|
||||
assert.equal(fixture.semantic.animations.length, 1);
|
||||
assert.equal(fixture.semantic.animations[0].name, "M12 Animation Action");
|
||||
assert.deepEqual(
|
||||
fixture.semantic.animations[0].channels.map((channel) => channel.target.path).sort(),
|
||||
["rotation", "translation"],
|
||||
);
|
||||
assert.equal(fixture.semantic.animations[0].samplers[0].input.count, 25);
|
||||
}
|
||||
}
|
||||
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m12-06a-glb-"));
|
||||
try {
|
||||
const regeneratedReport = path.join(temporary, "desktop-fixtures.json");
|
||||
const result = spawnSync(blender, ["-b", "--factory-startup", "--python", generator, "--", temporary, regeneratedReport], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 20 * 1024 * 1024,
|
||||
});
|
||||
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
assert.deepEqual(readJson(regeneratedReport), report, "desktop semantic report is not deterministic");
|
||||
for (const fixture of report.fixtures) {
|
||||
assert.deepEqual(
|
||||
fs.readFileSync(path.join(temporary, fixture.file)),
|
||||
fs.readFileSync(path.join(fixtureRoot, fixture.file)),
|
||||
`${fixture.id} GLB bytes are not deterministic`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`glb-desktop-fixtures-ok fixtures=${report.fixtures.length} ` +
|
||||
`bytes=${report.fixtures.reduce((total, fixture) => total + fixture.byteLength, 0)} ` +
|
||||
`features=mesh,pbr,uv,skin,animation deterministic=true next=${manifest.nextTask}\n`,
|
||||
);
|
||||
54
tools/web/check-glb-desktop-import.mjs
Normal file
54
tools/web/check-glb-desktop-import.mjs
Normal file
@@ -0,0 +1,54 @@
|
||||
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 { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const manifestPath = path.join(root, "tests/golden/M12-06B/manifest.json");
|
||||
const reportPath = path.join(root, "tests/golden/M12-06B/web-import-report.json");
|
||||
const generator = path.join(root, "tools/web/generate-glb-desktop-import-report.mjs");
|
||||
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
const fileHash = (file) => sha256(fs.readFileSync(file));
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
const report = JSON.parse(fs.readFileSync(reportPath, "utf8"));
|
||||
|
||||
assert.equal(manifest.schemaVersion, 1);
|
||||
assert.equal(manifest.task, "M12-06B");
|
||||
assert.equal(manifest.parentTask, "M12-06A");
|
||||
assert.equal(manifest.nextTask, "M12-06C");
|
||||
for (const artifact of Object.values(manifest.artifacts)) {
|
||||
assert.equal(fileHash(path.join(root, artifact.path)), artifact.sha256, artifact.path);
|
||||
}
|
||||
assert.equal(report.schemaVersion, 1);
|
||||
assert.equal(report.task, "M12-06B");
|
||||
assert.equal(report.operation, "WEB_GLB_IMPORT_SEMANTIC_COMPARISON");
|
||||
assert.equal(report.parentManifestSha256, fileHash(path.join(root, "tests/golden/M12-06A/manifest.json")));
|
||||
assert.equal(report.fixtureReportSha256, fileHash(path.join(root, "tests/golden/M12-06A/desktop-fixtures.json")));
|
||||
assert.equal(report.fixtureCount, 5);
|
||||
assert.deepEqual(report.comparedDomains, ["topology", "attributes", "materials", "nodes", "animations"]);
|
||||
assert.equal(report.allCompatible, true);
|
||||
assert.equal(report.routeState, "BLOCKED_UNTIL_MAIN_PERSISTENCE");
|
||||
assert.equal(report.nextTask, "M12-06C");
|
||||
assert.deepEqual(report.comparisons.map((item) => item.id), ["mesh", "pbr", "uv", "skin", "animation"]);
|
||||
assert(report.comparisons.every((item) => item.compatible && item.mismatchCount === 0 && item.desktopSemanticSha256 === item.webSemanticSha256));
|
||||
assert.deepEqual(report.comparisons.find((item) => item.id === "mesh").attributes, ["COLOR_0", "NORMAL", "POSITION"]);
|
||||
assert.equal(report.comparisons.find((item) => item.id === "pbr").materials.pbrCount, 1);
|
||||
assert.equal(report.comparisons.find((item) => item.id === "uv").materials.texturedCount, 1);
|
||||
assert.equal(report.comparisons.find((item) => item.id === "skin").nodes.skinnedCount, 1);
|
||||
assert.deepEqual(report.comparisons.find((item) => item.id === "animation").animations.channelPaths, ["rotation", "translation"]);
|
||||
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m12-06b-import-check-"));
|
||||
try {
|
||||
const regenerated = path.join(temporary, "web-import-report.json");
|
||||
const result = spawnSync(process.execPath, [generator, regenerated], { cwd: root, encoding: "utf8", maxBuffer: 20 * 1024 * 1024 });
|
||||
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
assert.deepEqual(fs.readFileSync(regenerated), fs.readFileSync(reportPath), "Web import report is not deterministic");
|
||||
}
|
||||
finally {
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
process.stdout.write(`glb-desktop-import-ok fixtures=${report.fixtureCount} domains=${report.comparedDomains.length} compatible=${report.allCompatible} route=${report.routeState} next=${manifest.nextTask}\n`);
|
||||
54
tools/web/check-glb-loss-report.mjs
Normal file
54
tools/web/check-glb-loss-report.mjs
Normal file
@@ -0,0 +1,54 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-06D/manifest.json"), "utf8"));
|
||||
const report = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-06D/web-loss-report.json"), "utf8"));
|
||||
const parent = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-06C/desktop-main-report.json"), "utf8"));
|
||||
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
const fileHash = (file) => sha256(fs.readFileSync(file));
|
||||
|
||||
assert.deepEqual(
|
||||
{ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask },
|
||||
{ schemaVersion: 1, task: "M12-06D", parentTask: "M12-06C", nextTask: "M12-06E" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
{ schemaVersion: report.schemaVersion, task: report.task, operation: report.operation, fixtureCount: report.fixtureCount, nextTask: report.nextTask },
|
||||
{ schemaVersion: 1, task: "M12-06D", operation: "WEB_GLB_EXPORT_LOSS_REPORT", fixtureCount: 5, nextTask: "M12-06E" },
|
||||
);
|
||||
assert.equal(fileHash(path.join(root, "tests/golden/M12-06C/manifest.json")), manifest.artifacts.parentManifest.sha256);
|
||||
for (const artifact of Object.values(manifest.artifacts)) {
|
||||
const file = path.join(root, artifact.path);
|
||||
assert.ok(fs.existsSync(file), `missing artifact ${artifact.path}`);
|
||||
assert.equal(fileHash(file), artifact.sha256, `hash mismatch ${artifact.path}`);
|
||||
}
|
||||
|
||||
const expectedIds = ["mesh", "pbr", "uv", "skin", "animation"];
|
||||
assert.deepEqual(report.fixtures.map((fixture) => fixture.fixtureId), expectedIds);
|
||||
for (const fixture of report.fixtures) {
|
||||
const source = parent.fixtures.find((candidate) => candidate.id === fixture.fixtureId);
|
||||
assert.ok(source, `${fixture.fixtureId} is absent from M12-06C`);
|
||||
assert.equal(fixture.sourceBlendSha256, source.blend.sha256, `${fixture.fixtureId} source blend drift`);
|
||||
const losses = fixture.lossReport.losses;
|
||||
assert.deepEqual(losses, [...losses].sort((left, right) =>
|
||||
left.code.localeCompare(right.code) || left.severity.localeCompare(right.severity) ||
|
||||
(left.id ?? "").localeCompare(right.id ?? "") || left.message.localeCompare(right.message)));
|
||||
assert.equal(fixture.lossReport.errorCount, losses.filter((loss) => loss.severity === "error").length);
|
||||
assert.equal(fixture.lossReport.warningCount, losses.filter((loss) => loss.severity === "warning").length);
|
||||
if (fixture.fixtureId === "mesh") {
|
||||
assert.equal(fixture.lossReport.canExport, false);
|
||||
assert.deepEqual(losses.map((loss) => loss.code), ["LINKED_MATERIAL_INPUT_UNEVALUATED", "SHADER_GRAPH_UNMAPPABLE"]);
|
||||
assert.equal(fixture.output, null);
|
||||
}
|
||||
else {
|
||||
assert.equal(fixture.lossReport.canExport, true);
|
||||
assert.equal(fixture.lossReport.errorCount, 0);
|
||||
assert.ok(fixture.output?.byteLength > 128);
|
||||
assert.match(fixture.output.sha256, /^[0-9a-f]{64}$/);
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(`glb-loss-report-ok fixtures=${report.fixtureCount} blocked=mesh warnings=${report.fixtures.reduce((sum, fixture) => sum + fixture.lossReport.warningCount, 0)} deterministic=true next=${manifest.nextTask}\n`);
|
||||
100
tools/web/check-glb-main-persistence.mjs
Normal file
100
tools/web/check-glb-main-persistence.mjs
Normal file
@@ -0,0 +1,100 @@
|
||||
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 { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const manifestPath = path.join(root, "tests/golden/M12-06C/manifest.json");
|
||||
const reportPath = path.join(root, "tests/golden/M12-06C/desktop-main-report.json");
|
||||
const fixtureReportPath = path.join(root, "tests/golden/M12-06A/desktop-fixtures.json");
|
||||
const fixtureRoot = path.join(root, "tests/files/web/m12_glb_desktop_v1");
|
||||
const generator = path.join(root, "tools/web/generate-glb-main-persistence-fixtures.py");
|
||||
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
|
||||
|
||||
const read = (file) => fs.readFileSync(file);
|
||||
const readJson = (file) => JSON.parse(read(file));
|
||||
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
const fileHash = (file) => sha256(read(file));
|
||||
|
||||
const manifest = readJson(manifestPath);
|
||||
const report = readJson(reportPath);
|
||||
const fixtureReport = readJson(fixtureReportPath);
|
||||
|
||||
assert.deepEqual(
|
||||
{ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask },
|
||||
{ schemaVersion: 1, task: "M12-06C", parentTask: "M12-06B", nextTask: "M12-06D" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
{ schemaVersion: report.schemaVersion, task: report.task, operation: report.operation, fixtureCount: report.fixtureCount, nextTask: report.nextTask },
|
||||
{ schemaVersion: 1, task: "M12-06C", operation: "DESKTOP_GLB_IMPORT_MAIN_PERSISTENCE_BASELINE", fixtureCount: 5, nextTask: "M12-06D" },
|
||||
);
|
||||
assert.equal(fileHash(path.join(root, "tests/golden/M12-06B/manifest.json")), manifest.artifacts.parentManifest.sha256);
|
||||
|
||||
for (const artifact of Object.values(manifest.artifacts)) {
|
||||
const file = path.join(root, artifact.path);
|
||||
assert.ok(fs.existsSync(file), `missing M12-06C artifact ${artifact.path}`);
|
||||
assert.equal(fileHash(file), artifact.sha256, `hash mismatch ${artifact.path}`);
|
||||
}
|
||||
|
||||
const fixtureById = new Map(fixtureReport.fixtures.map((fixture) => [fixture.id, fixture]));
|
||||
assert.deepEqual(report.fixtures.map((fixture) => fixture.id), ["mesh", "pbr", "uv", "skin", "animation"]);
|
||||
for (const fixture of report.fixtures) {
|
||||
const source = fixtureById.get(fixture.id);
|
||||
assert.ok(source, `${fixture.id} is absent from M12-06A`);
|
||||
assert.equal(fixture.glb.sha256, source.sha256, `${fixture.id} GLB source drift`);
|
||||
assert.equal(fileHash(path.join(fixtureRoot, fixture.glb.file)), fixture.glb.sha256, `${fixture.id} GLB hash`);
|
||||
const blend = path.join(fixtureRoot.replace("m12_glb_desktop_v1", "m12_glb_main_v1"), fixture.blend.file);
|
||||
assert.equal(fs.statSync(blend).size, fixture.blend.byteLength, `${fixture.id} desktop Main fixture byte length`);
|
||||
assert.equal(fileHash(blend), fixture.blend.sha256, `${fixture.id} desktop Main fixture hash`);
|
||||
assert.deepEqual(fixture.graph.stableIds, {
|
||||
objects: [...fixture.graph.stableIds.objects].sort(),
|
||||
meshes: [...fixture.graph.stableIds.meshes].sort(),
|
||||
materials: [...fixture.graph.stableIds.materials].sort(),
|
||||
images: [...fixture.graph.stableIds.images].sort(),
|
||||
armatures: [...fixture.graph.stableIds.armatures].sort(),
|
||||
actions: [...fixture.graph.stableIds.actions].sort(),
|
||||
});
|
||||
for (const [kind, ids] of Object.entries(fixture.graph.stableIds)) {
|
||||
assert.equal(new Set(ids).size, ids.length, `${fixture.id} duplicate ${kind} stable ID`);
|
||||
const prefix = { objects: "object:", meshes: "mesh:", materials: "material:", images: "image:", armatures: "armature:", actions: "action:" }[kind];
|
||||
assert.ok(ids.every((id) => id.startsWith(prefix)), `${fixture.id} malformed ${kind} stable ID`);
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeReport = (value) => ({
|
||||
blenderVersion: value.blenderVersion,
|
||||
fixtureCount: value.fixtureCount,
|
||||
nextTask: value.nextTask,
|
||||
operation: value.operation,
|
||||
schemaVersion: value.schemaVersion,
|
||||
fixtures: value.fixtures.map((fixture) => ({
|
||||
id: fixture.id,
|
||||
glb: fixture.glb,
|
||||
graph: fixture.graph,
|
||||
})),
|
||||
});
|
||||
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m12-06c-main-persistence-"));
|
||||
try {
|
||||
const outputRoot = path.join(temporary, "main");
|
||||
const regeneratedReport = path.join(temporary, "desktop-main-report.json");
|
||||
const result = spawnSync(blender, ["-b", "--factory-startup", "--python", generator, "--", fixtureRoot, outputRoot, regeneratedReport], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 20 * 1024 * 1024,
|
||||
});
|
||||
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
const regenerated = readJson(regeneratedReport);
|
||||
assert.deepEqual(normalizeReport(regenerated), normalizeReport(report), "desktop Main semantic report is not deterministic");
|
||||
for (const fixture of regenerated.fixtures) {
|
||||
assert.ok(fs.statSync(path.join(outputRoot, fixture.blend.file)).size > 0, `${fixture.id} regenerated .blend is empty`);
|
||||
assert.deepEqual(fixture.graph.stableIds, report.fixtures.find((item) => item.id === fixture.id).graph.stableIds, `${fixture.id} stable IDs drifted`);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
process.stdout.write(`glb-main-persistence-ok fixtures=${report.fixtureCount} stableIds=exact desktopReopen=exact deterministic=true next=${manifest.nextTask}\n`);
|
||||
35
tools/web/check-glb-negative-cases.mjs
Normal file
35
tools/web/check-glb-negative-cases.mjs
Normal file
@@ -0,0 +1,35 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-06F/manifest.json"), "utf8"));
|
||||
const report = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-06F/negative-report.json"), "utf8"));
|
||||
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
const fileHash = (file) => sha256(fs.readFileSync(file));
|
||||
|
||||
assert.deepEqual(
|
||||
{ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask },
|
||||
{ schemaVersion: 1, task: "M12-06F", parentTask: "M12-06E", nextTask: "M12-06G" },
|
||||
);
|
||||
assert.deepEqual(report, {
|
||||
schemaVersion: 1,
|
||||
task: "M12-06F",
|
||||
operation: "GLB_IMPORT_NEGATIVE_CASES",
|
||||
budget: { maxBytes: 524288, maxJsonBytes: 262144, maxBufferViews: 4096, maxAccessors: 8192 },
|
||||
cases: [
|
||||
{ id: "sparse", code: "GLB_SPARSE_ACCESSOR_UNSUPPORTED" },
|
||||
{ id: "extension", code: "GLB_EXTENSION_UNSUPPORTED" },
|
||||
{ id: "external-uri", code: "GLB_EXTERNAL_URI_BLOCKED" },
|
||||
{ id: "over-budget", code: "GLB_IMPORT_BUDGET_EXCEEDED" },
|
||||
],
|
||||
nextTask: "M12-06G",
|
||||
});
|
||||
for (const artifact of Object.values(manifest.artifacts)) {
|
||||
const file = path.join(root, artifact.path);
|
||||
assert.ok(fs.existsSync(file), `missing artifact ${artifact.path}`);
|
||||
assert.equal(fileHash(file), artifact.sha256, `hash mismatch ${artifact.path}`);
|
||||
}
|
||||
process.stdout.write(`glb-negative-cases-ok cases=${report.cases.length} budget=${report.budget.maxBytes} deterministic=true next=${manifest.nextTask}\n`);
|
||||
47
tools/web/check-glb-recovery.mjs
Normal file
47
tools/web/check-glb-recovery.mjs
Normal file
@@ -0,0 +1,47 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-06G/manifest.json"), "utf8"));
|
||||
const report = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-06G/recovery-report.json"), "utf8"));
|
||||
const fileHash = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
|
||||
assert.deepEqual(
|
||||
{ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask },
|
||||
{ schemaVersion: 1, task: "M12-06G", parentTask: "M12-06F", nextTask: "M12-07A" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
{ schemaVersion: report.schemaVersion, task: report.task, operation: report.operation, nextTask: report.nextTask },
|
||||
{ schemaVersion: 1, task: "M12-06G", operation: "GLB_IMPORT_EXPORT_RECOVERY", nextTask: "M12-07A" },
|
||||
);
|
||||
assert.deepEqual(report.cancellation.map((item) => ({ operation: item.operation, status: item.status, code: item.code })), [
|
||||
{ operation: "IMPORT", status: "CANCELLED", code: "GLB_OPERATION_CANCELLED" },
|
||||
{ operation: "EXPORT", status: "CANCELLED", code: "GLB_OPERATION_CANCELLED" },
|
||||
]);
|
||||
assert.ok(report.cancellation.every((item) => item.committed === false && item.temporaryBytes === 0 && item.liveRequests === 0));
|
||||
assert.deepEqual(report.workerRestart, {
|
||||
operation: "IMPORT",
|
||||
generationBefore: 1,
|
||||
generationAfter: 2,
|
||||
status: "RECOVERED",
|
||||
code: "GLB_WORKER_RESTARTED",
|
||||
resultHash: "EXACT",
|
||||
});
|
||||
assert.deepEqual(report.opfsQuota, {
|
||||
operation: "EXPORT_ASSET_COMMIT",
|
||||
status: "BLOCKED",
|
||||
code: "GLB_OPFS_QUOTA",
|
||||
backend: "OPFS",
|
||||
committedAssetPreserved: true,
|
||||
workerRestart: true,
|
||||
smallAssetRecovery: true,
|
||||
});
|
||||
for (const artifact of Object.values(manifest.artifacts)) {
|
||||
const file = path.join(root, artifact.path);
|
||||
assert.ok(fs.existsSync(file), `missing artifact ${artifact.path}`);
|
||||
assert.equal(fileHash(file), artifact.sha256, `hash mismatch ${artifact.path}`);
|
||||
}
|
||||
process.stdout.write(`glb-recovery-ok cancelled=${report.cancellation.length} workerGeneration=${report.workerRestart.generationAfter} quota=${report.opfsQuota.code} smallRecovery=${report.opfsQuota.smallAssetRecovery} next=${manifest.nextTask}\n`);
|
||||
94
tools/web/check-glb-web-reimport.mjs
Normal file
94
tools/web/check-glb-web-reimport.mjs
Normal file
@@ -0,0 +1,94 @@
|
||||
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 { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const manifestPath = path.join(root, "tests/golden/M12-06E/manifest.json");
|
||||
const reportPath = path.join(root, "tests/golden/M12-06E/desktop-reimport-report.json");
|
||||
const generator = path.join(root, "tools/web/generate-glb-web-reimport-report.py");
|
||||
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
|
||||
const read = (file) => fs.readFileSync(file);
|
||||
const readJson = (file) => JSON.parse(read(file));
|
||||
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
const fileHash = (file) => sha256(read(file));
|
||||
|
||||
const manifest = readJson(manifestPath);
|
||||
const report = readJson(reportPath);
|
||||
const base = readJson(path.join(root, "tests/golden/M12-06C/desktop-main-report.json"));
|
||||
const exportReport = readJson(path.join(root, "tests/golden/M12-06D/web-loss-report.json"));
|
||||
|
||||
assert.deepEqual(
|
||||
{ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask },
|
||||
{ schemaVersion: 1, task: "M12-06E", parentTask: "M12-06D", nextTask: "M12-06F" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
{ schemaVersion: report.schemaVersion, task: report.task, operation: report.operation, fixtureCount: report.fixtureCount, nextTask: report.nextTask },
|
||||
{ schemaVersion: 1, task: "M12-06E", operation: "DESKTOP_REIMPORT_WEB_GLB_CANONICAL_REPORT", fixtureCount: 4, nextTask: "M12-06F" },
|
||||
);
|
||||
assert.equal(fileHash(path.join(root, "tests/golden/M12-06D/manifest.json")), manifest.artifacts.parentManifest.sha256);
|
||||
for (const artifact of Object.values(manifest.artifacts)) {
|
||||
const file = path.join(root, artifact.path);
|
||||
assert.ok(fs.existsSync(file), `missing artifact ${artifact.path}`);
|
||||
assert.equal(fileHash(file), artifact.sha256, `hash mismatch ${artifact.path}`);
|
||||
}
|
||||
|
||||
const expectedIds = ["pbr", "uv", "skin", "animation"];
|
||||
assert.deepEqual(report.fixtures.map((fixture) => fixture.id), expectedIds);
|
||||
for (const fixture of report.fixtures) {
|
||||
const exportFixture = exportReport.fixtures.find((candidate) => candidate.fixtureId === fixture.id);
|
||||
assert.ok(exportFixture?.output, `${fixture.id} has no Web GLB output`);
|
||||
assert.equal(fixture.glb.sha256, exportFixture.output.sha256, `${fixture.id} Web GLB hash drift`);
|
||||
assert.equal(fileHash(path.join(root, "tests/files/web/m12_glb_web_v1", fixture.glb.file)), fixture.glb.sha256);
|
||||
}
|
||||
|
||||
function mismatches(expected, actual, pathName = "graph", output = []) {
|
||||
if (Object.is(expected, actual)) return output;
|
||||
if (Array.isArray(expected) || Array.isArray(actual)) {
|
||||
if (!Array.isArray(expected) || !Array.isArray(actual)) { output.push(pathName); return output; }
|
||||
if (expected.length !== actual.length) output.push(`${pathName}.length`);
|
||||
for (let index = 0; index < Math.max(expected.length, actual.length); index++) {
|
||||
if (index >= expected.length || index >= actual.length) output.push(`${pathName}[${index}]`);
|
||||
else mismatches(expected[index], actual[index], `${pathName}[${index}]`, output);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
if (expected && actual && typeof expected === "object" && typeof actual === "object") {
|
||||
for (const key of new Set([...Object.keys(expected), ...Object.keys(actual)])) mismatches(expected[key], actual[key], `${pathName}.${key}`, output);
|
||||
return output;
|
||||
}
|
||||
output.push(pathName);
|
||||
return output;
|
||||
}
|
||||
|
||||
const comparisons = report.fixtures.map((fixture) => {
|
||||
const baseline = base.fixtures.find((candidate) => candidate.id === fixture.id);
|
||||
assert.ok(baseline, `${fixture.id} missing M12-06C baseline`);
|
||||
const paths = mismatches(baseline.graph, fixture.graph);
|
||||
return { id: fixture.id, exact: paths.length === 0, mismatchCount: paths.length, mismatchPaths: paths };
|
||||
});
|
||||
assert.deepEqual(comparisons.map((comparison) => ({ id: comparison.id, exact: comparison.exact, mismatchCount: comparison.mismatchCount })), [
|
||||
{ id: "pbr", exact: true, mismatchCount: 0 },
|
||||
{ id: "uv", exact: false, mismatchCount: 4 },
|
||||
{ id: "skin", exact: false, mismatchCount: 31 },
|
||||
{ id: "animation", exact: true, mismatchCount: 0 },
|
||||
]);
|
||||
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m12-06e-web-reimport-"));
|
||||
try {
|
||||
const regenerated = path.join(temporary, "desktop-reimport-report.json");
|
||||
const result = spawnSync(blender, ["-b", "--factory-startup", "--python", generator, "--", path.join(root, "tests/files/web/m12_glb_web_v1"), path.join(temporary, "blend"), regenerated], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 20 * 1024 * 1024,
|
||||
});
|
||||
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
assert.deepEqual(readJson(regenerated), report, "desktop Web GLB report is not deterministic");
|
||||
} finally {
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
process.stdout.write(`glb-web-reimport-ok fixtures=${report.fixtureCount} exact=${comparisons.filter((comparison) => comparison.exact).length} mismatched=${comparisons.filter((comparison) => !comparison.exact).map((comparison) => comparison.id).join(",")} deterministic=true next=${manifest.nextTask}\n`);
|
||||
62
tools/web/check-io-format-capability-matrix.mjs
Normal file
62
tools/web/check-io-format-capability-matrix.mjs
Normal file
@@ -0,0 +1,62 @@
|
||||
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 { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import ts from "../../web/node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const matrixPath = path.join(repoRoot, "tests/golden/M12-05B/capability-matrix.json");
|
||||
const inventoryPath = path.join(repoRoot, "tests/golden/M12-05A/format-inventory.json");
|
||||
const evidencePath = path.join(repoRoot, "tests/golden/M12-05B/manifest.json");
|
||||
const sourcePath = path.join(repoRoot, "web/protocol/io-format-capability-matrix.ts");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "io-format-capability-matrix-"));
|
||||
|
||||
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
const fileSha256 = (file) => sha256(fs.readFileSync(file));
|
||||
|
||||
try {
|
||||
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, []);
|
||||
const modulePath = path.join(temporary, "io-format-capability-matrix.mjs");
|
||||
fs.writeFileSync(modulePath, transpiled.outputText);
|
||||
const protocol = await import(pathToFileURL(modulePath));
|
||||
const evidence = JSON.parse(fs.readFileSync(evidencePath, "utf8"));
|
||||
assert.equal(evidence.task, "M12-05B");
|
||||
assert.equal(evidence.parentTask, "M12-05A");
|
||||
assert.equal(evidence.nextTask, "M12-05C");
|
||||
for (const artifact of Object.values(evidence.artifacts)) assert.equal(fileSha256(path.join(repoRoot, artifact.path)), artifact.sha256, artifact.path);
|
||||
|
||||
const inventoryBytes = fs.readFileSync(inventoryPath);
|
||||
const matrixBytes = fs.readFileSync(matrixPath);
|
||||
const matrix = JSON.parse(matrixBytes);
|
||||
assert.equal(matrix.runtimeInventorySha256, sha256(inventoryBytes));
|
||||
const inventory = JSON.parse(inventoryBytes);
|
||||
const parsed = protocol.parseIOFormatCapabilityMatrix(matrix);
|
||||
assert.deepEqual(parsed.formats.map((entry) => entry.format), inventory.formats.map((entry) => entry.format));
|
||||
const runtimeByFormat = new Map(inventory.formats.map((entry) => [entry.format, entry]));
|
||||
for (const entry of parsed.formats) {
|
||||
const runtime = runtimeByFormat.get(entry.format);
|
||||
assert.equal(entry.runtimeImportStatus, runtime.import.runtimeStatus === "AVAILABLE" ? "AVAILABLE" : "OPERATOR_UNREGISTERED");
|
||||
assert.equal(entry.runtimeExportStatus, runtime.export.runtimeStatus === "AVAILABLE" ? "AVAILABLE" : "OPERATOR_UNREGISTERED");
|
||||
assert.equal(entry.operations.EXPORT.local.status, entry.format === "GLB" ? "READY" : "BLOCKED");
|
||||
assert.equal(entry.operations.EXPORT.local.execution, entry.format === "GLB" ? "LOCAL" : "NONE");
|
||||
assert.equal(entry.operations.EXPORT.server.status, "BLOCKED");
|
||||
assert.equal(entry.operations.IMPORT.local.status, "BLOCKED");
|
||||
assert.equal(entry.operations.IMPORT.server.status, "BLOCKED");
|
||||
}
|
||||
|
||||
const regenerated = path.join(temporary, "capability-matrix.json");
|
||||
execFileSync(process.execPath, [path.join(repoRoot, "tools/web/generate-io-format-capability-matrix.mjs"), "--output", regenerated], { cwd: repoRoot });
|
||||
assert.deepEqual(fs.readFileSync(regenerated), matrixBytes, "capability matrix is not deterministic");
|
||||
process.stdout.write("io-format-capability-matrix-ok formats=7 local-glb-export=1 blocked-routes=27 runtime-bound=true\n");
|
||||
}
|
||||
finally {
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
36
tools/web/check-io-format-receipt-bindings.mjs
Normal file
36
tools/web/check-io-format-receipt-bindings.mjs
Normal file
@@ -0,0 +1,36 @@
|
||||
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 { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import ts from "../../web/node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const inventoryPath = path.join(root, "tests/golden/M12-05A/format-inventory.json");
|
||||
const parentPath = path.join(root, "tests/golden/M12-05D/runtime-receipts.json");
|
||||
const boundPath = path.join(root, "tests/golden/M12-05E/bound-runtime-receipts.json");
|
||||
const protocolPath = path.join(root, "web/protocol/io-format-receipt-binding.ts");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "io-format-receipt-bindings-"));
|
||||
const hashBytes = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
||||
const canonical = (value) => value === null || typeof value !== "object" ? JSON.stringify(value) : Array.isArray(value) ? `[${value.map(canonical).join(",")}]` : `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
|
||||
const hash = (value) => hashBytes(canonical(value));
|
||||
try {
|
||||
const inventoryBytes = fs.readFileSync(inventoryPath); const parentBytes = fs.readFileSync(parentPath); const boundBytes = fs.readFileSync(boundPath);
|
||||
const inventory = JSON.parse(inventoryBytes); const parent = JSON.parse(parentBytes); const bound = JSON.parse(boundBytes);
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(protocolPath, "utf8"), { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: protocolPath, reportDiagnostics: true });
|
||||
assert.deepEqual(transpiled.diagnostics, []); const modulePath = path.join(temporary, "protocol.mjs"); fs.writeFileSync(modulePath, transpiled.outputText); const protocol = await import(pathToFileURL(modulePath));
|
||||
const parsed = protocol.validateIOFormatBoundReceiptSet(bound, hashBytes(parentBytes), hashBytes(inventoryBytes));
|
||||
assert.equal(parsed.receipts.length, 14);
|
||||
const runtimeSha256 = hash(inventory.runtime);
|
||||
for (const receipt of parsed.receipts) {
|
||||
const source = inventory.formats.find((entry) => entry.format === receipt.format)[receipt.operation.toLowerCase()];
|
||||
assert.equal(receipt.sourceSha256, hash({ format: receipt.format, family: receipt.family, operation: receipt.operation, operator: receipt.operator, registered: receipt.registered, rnaIdentifier: receipt.rnaIdentifier }));
|
||||
assert.equal(receipt.settingsSha256, hash({ buildOption: receipt.buildOption, buildOptionEnabled: receipt.buildOptionEnabled, variants: receipt.variants, extensions: receipt.extensions, properties: source.properties }));
|
||||
assert.equal(receipt.runtimeSha256, runtimeSha256);
|
||||
}
|
||||
const regenerated = path.join(temporary, "bound-runtime-receipts.json"); execFileSync(process.execPath, [path.join(root, "tools/web/generate-io-format-receipt-bindings.mjs"), "--output", regenerated], { cwd: root }); assert.deepEqual(fs.readFileSync(regenerated), boundBytes);
|
||||
process.stdout.write("io-format-receipt-bindings-ok receipts=14 source-settings-runtime=bound deterministic=true\n");
|
||||
}
|
||||
finally { fs.rmSync(temporary, { recursive: true, force: true }); }
|
||||
87
tools/web/check-io-format-receipt-freshness.mjs
Normal file
87
tools/web/check-io-format-receipt-freshness.mjs
Normal file
@@ -0,0 +1,87 @@
|
||||
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 { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import ts from "../../web/node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const parentPath = path.join(repoRoot, "tests/golden/M12-05E/bound-runtime-receipts.json");
|
||||
const freshPath = path.join(repoRoot, "tests/golden/M12-05F/fresh-runtime-receipts.json");
|
||||
const appFreshPath = path.join(repoRoot, "web/app/src/capabilities/io-format-runtime-receipts-freshness.json");
|
||||
const appExpectedPath = path.join(repoRoot, "web/app/src/capabilities/io-format-runtime-receipts-freshness-expected.json");
|
||||
const appPath = path.join(repoRoot, "web/app/src/app/App.tsx");
|
||||
const protocolPath = path.join(repoRoot, "web/protocol/io-format-receipt-freshness.ts");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "io-format-receipt-freshness-"));
|
||||
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
const stableValue = (value) => Array.isArray(value) ? value.map(stableValue) : value && typeof value === "object" ? Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableValue(value[key])])) : value;
|
||||
const stableSha256 = (value) => sha256(JSON.stringify(stableValue(value)));
|
||||
|
||||
function transpile(sourcePath, outputName, replacements = []) {
|
||||
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, []);
|
||||
let output = transpiled.outputText;
|
||||
for (const [from, to] of replacements) output = output.replaceAll(from, to);
|
||||
const outputPath = path.join(temporary, outputName);
|
||||
fs.writeFileSync(outputPath, output);
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
try {
|
||||
const parentBytes = fs.readFileSync(parentPath);
|
||||
const bound = JSON.parse(parentBytes);
|
||||
const freshBytes = fs.readFileSync(freshPath);
|
||||
const fresh = JSON.parse(freshBytes);
|
||||
assert.deepEqual(fs.readFileSync(appFreshPath), freshBytes, "App freshness receipt drifted from golden");
|
||||
const appExpected = JSON.parse(fs.readFileSync(appExpectedPath));
|
||||
const runtimeModule = transpile(path.join(repoRoot, "web/protocol/io-format-runtime-receipt.ts"), "io-format-runtime-receipt.mjs");
|
||||
const bindingModule = transpile(path.join(repoRoot, "web/protocol/io-format-receipt-binding.ts"), "io-format-receipt-binding.mjs");
|
||||
const protocolModule = transpile(protocolPath, "io-format-receipt-freshness.mjs", [["./io-format-receipt-binding\"", "./io-format-receipt-binding.mjs\""], ["./io-format-runtime-receipt\"", "./io-format-runtime-receipt.mjs\""]]);
|
||||
const protocol = await import(pathToFileURL(protocolModule));
|
||||
const expected = {
|
||||
parentBindingSha256: sha256(parentBytes),
|
||||
parentReceiptSetSha256: bound.parentReceiptSetSha256,
|
||||
inventorySha256: bound.inventorySha256,
|
||||
boundReceiptSetSha256: stableSha256(bound),
|
||||
runtimeSha256: stableSha256(bound.runtime),
|
||||
runtime: bound.runtime,
|
||||
receiptIdentities: bound.receipts,
|
||||
};
|
||||
assert.equal(fresh.parentBindingSha256, expected.parentBindingSha256);
|
||||
assert.equal(fresh.boundReceiptSetSha256, expected.boundReceiptSetSha256);
|
||||
assert.equal(fresh.runtimeSha256, expected.runtimeSha256);
|
||||
assert.deepEqual(appExpected, {
|
||||
parentBindingSha256: expected.parentBindingSha256,
|
||||
parentReceiptSetSha256: expected.parentReceiptSetSha256,
|
||||
inventorySha256: expected.inventorySha256,
|
||||
boundReceiptSetSha256: expected.boundReceiptSetSha256,
|
||||
runtimeSha256: expected.runtimeSha256,
|
||||
runtime: expected.runtime,
|
||||
receiptIdentities: expected.receiptIdentities,
|
||||
});
|
||||
const appSource = fs.readFileSync(appPath, "utf8");
|
||||
assert.match(appSource, /resolveFreshIOFormatRuntimeRoute\(IO_FORMAT_RUNTIME_RECEIPTS, IO_FORMAT_RUNTIME_RECEIPT_EXPECTED/);
|
||||
await protocol.verifyIOFormatReceiptFreshness(fresh, expected);
|
||||
assert.equal(protocol.resolveFreshIOFormatRuntimeRoute(fresh, expected, { format: "GLB", operation: "EXPORT" }).status, "READY");
|
||||
assert.equal(protocol.resolveFreshIOFormatRuntimeRoute(fresh, expected, { format: "USD", operation: "EXPORT" }).status, "BLOCKED");
|
||||
|
||||
const forged = structuredClone(fresh);
|
||||
forged.bound.receipts[0].operator = "forged.operator";
|
||||
await assert.rejects(() => protocol.verifyIOFormatReceiptFreshness(forged, expected), (error) => error.reason === "RECEIPT_FORGED");
|
||||
const stale = structuredClone(fresh);
|
||||
stale.bound.inventorySha256 = "a".repeat(64);
|
||||
assert.equal(protocol.resolveFreshIOFormatRuntimeRoute(stale, expected, { format: "GLB", operation: "EXPORT" }).reason, "RECEIPT_STALE");
|
||||
const crossVersion = structuredClone(fresh);
|
||||
crossVersion.bound.runtime.versionTuple = [5, 3, 0];
|
||||
assert.equal(protocol.resolveFreshIOFormatRuntimeRoute(crossVersion, expected, { format: "GLB", operation: "EXPORT" }).reason, "RECEIPT_CROSS_VERSION");
|
||||
|
||||
const regenerated = path.join(temporary, "fresh-runtime-receipts.json");
|
||||
execFileSync(process.execPath, [path.join(repoRoot, "tools/web/generate-io-format-receipt-freshness.mjs"), "--output", regenerated], { cwd: repoRoot });
|
||||
assert.deepEqual(fs.readFileSync(regenerated), freshBytes, "freshness receipt is not deterministic");
|
||||
process.stdout.write("io-format-receipt-freshness-ok receipts=14 forged=BLOCKED stale=BLOCKED cross-version=BLOCKED deterministic=true\n");
|
||||
}
|
||||
finally {
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
27
tools/web/check-io-format-recovery.mjs
Normal file
27
tools/web/check-io-format-recovery.mjs
Normal file
@@ -0,0 +1,27 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const reportPath = path.join(root, "tests/golden/M12-07J/io-format-recovery-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M12-07J/manifest.json");
|
||||
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
const report = readJson(reportPath);
|
||||
const manifest = readJson(manifestPath);
|
||||
assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M12-07J", parentTask: "M12-07I", nextTask: "M13-01A" });
|
||||
assert.deepEqual({ schemaVersion: report.schemaVersion, task: report.task, operation: report.operation, nextTask: report.nextTask }, { schemaVersion: 1, task: "M12-07J", operation: "IO_FORMAT_THREE_WAY_RECOVERY", nextTask: "M13-01A" });
|
||||
assert.deepEqual(report.assertions, { formats: ["OBJ", "STL", "PLY"], cancellationUnpublished: true, oomUnpublished: true, restartHashStable: true, smallRecoveryStable: true });
|
||||
for (const format of ["OBJ", "STL", "PLY"]) {
|
||||
const value = report.formats[format];
|
||||
assert.equal(value.cancel.status, "CANCELLED"); assert.equal(value.cancel.errorCode, "IO_FORMAT_OPERATION_CANCELLED"); assert.equal(value.cancel.publishedResults, 0);
|
||||
assert.equal(value.oom.status, "BLOCKED"); assert.equal(value.oom.errorCode, "IO_FORMAT_OOM"); assert.equal(value.oom.publishedResults, 0);
|
||||
assert.equal(value.first.status, "COMMITTED"); assert.equal(value.second.status, "COMMITTED"); assert.equal(value.small.status, "COMMITTED");
|
||||
assert.equal(value.recovered.status, "RECOVERED"); assert.equal(value.recovered.errorCode, "IO_FORMAT_WORKER_RESTARTED");
|
||||
assert.equal(value.hashes.first, value.hashes.second); assert.equal(value.hashes.first, value.hashes.small);
|
||||
assert.equal(value.first.outputSha256, value.recovered.outputSha256);
|
||||
}
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(fs.readFileSync(path.join(root, artifact.path))), artifact.sha256, `artifact hash mismatch ${artifact.path}`);
|
||||
process.stdout.write(`io-format-recovery-ok formats=OBJ,STL,PLY cancelled=3 oom=3 restart=3 smallRecovery=3 deterministic=true next=${manifest.nextTask}\n`);
|
||||
78
tools/web/check-io-format-runtime-inventory.mjs
Normal file
78
tools/web/check-io-format-runtime-inventory.mjs
Normal file
@@ -0,0 +1,78 @@
|
||||
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 { fileURLToPath } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const generator = path.join(repoRoot, "tools/web/generate-io-format-runtime-inventory.py");
|
||||
const expectedInventory = path.join(repoRoot, "tests/golden/M12-05A/format-inventory.json");
|
||||
const evidencePath = path.join(repoRoot, "tests/golden/M12-05A/manifest.json");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "io-format-runtime-inventory-"));
|
||||
const regenerated = path.join(temporary, "format-inventory.json");
|
||||
|
||||
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
const fileSha256 = (file) => sha256(fs.readFileSync(file));
|
||||
|
||||
try {
|
||||
const evidence = JSON.parse(fs.readFileSync(evidencePath, "utf8"));
|
||||
assert.equal(evidence.schemaVersion, 1);
|
||||
assert.equal(evidence.task, "M12-05A");
|
||||
assert.equal(evidence.parentTask, "M12-04J");
|
||||
assert.equal(evidence.nextTask, "M12-05B");
|
||||
for (const artifact of Object.values(evidence.artifacts)) assert.equal(fileSha256(path.join(repoRoot, artifact.path)), artifact.sha256, artifact.path);
|
||||
|
||||
const blender = process.env.BLENDER_BIN ?? path.join(repoRoot, "build_blender_5.2.0/bin/blender");
|
||||
const run = spawnSync(blender, ["-b", "--factory-startup", "--python", generator, "--", regenerated], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
});
|
||||
assert.equal(run.status, 0, `${run.stdout ?? ""}\n${run.stderr ?? ""}`);
|
||||
const expectedBytes = fs.readFileSync(expectedInventory);
|
||||
assert.deepEqual(fs.readFileSync(regenerated), expectedBytes, "Blender runtime inventory is not deterministic");
|
||||
|
||||
const inventory = JSON.parse(expectedBytes);
|
||||
assert.deepEqual(inventory, JSON.parse(fs.readFileSync(regenerated, "utf8")));
|
||||
assert.equal(inventory.schemaVersion, 1);
|
||||
assert.equal(inventory.task, "M12-05A");
|
||||
assert.deepEqual(inventory.runtime.versionTuple, [5, 2, 0]);
|
||||
assert.equal(inventory.runtime.blenderVersion, "5.2.0 LTS");
|
||||
assert.match(inventory.runtime.binarySha256, /^[a-f0-9]{64}$/);
|
||||
assert.equal(inventory.formats.length, 7);
|
||||
assert.deepEqual(inventory.formats.map((entry) => entry.format), ["GLTF", "GLB", "OBJ", "STL", "PLY", "USD", "ALEMBIC"]);
|
||||
|
||||
const available = new Set(["GLTF", "GLB", "OBJ", "STL", "PLY"]);
|
||||
const disabled = new Set(["USD", "ALEMBIC"]);
|
||||
for (const entry of inventory.formats) {
|
||||
assert.ok(Array.isArray(entry.extensions) && entry.extensions.length > 0, `${entry.format} extensions missing`);
|
||||
assert.ok(Array.isArray(entry.variants) && entry.variants.length > 0, `${entry.format} variants missing`);
|
||||
for (const operation of ["import", "export"]) {
|
||||
const receipt = entry[operation];
|
||||
assert.ok(typeof receipt.operator === "string" && receipt.operator.includes("."), `${entry.format} ${operation} operator missing`);
|
||||
assert.ok(Array.isArray(receipt.properties), `${entry.format} ${operation} properties missing`);
|
||||
assert.deepEqual(receipt.properties.map((property) => property.identifier), [...receipt.properties].map((property) => property.identifier).sort(), `${entry.format} ${operation} properties are not canonical`);
|
||||
if (available.has(entry.format)) {
|
||||
assert.equal(receipt.registered, true, `${entry.format} ${operation} is not registered`);
|
||||
assert.equal(receipt.runtimeStatus, "AVAILABLE", `${entry.format} ${operation} is not available`);
|
||||
assert.equal(receipt.buildOptionEnabled, true, `${entry.format} ${operation} build option is disabled`);
|
||||
}
|
||||
if (disabled.has(entry.format)) {
|
||||
assert.equal(receipt.registered, false, `${entry.format} ${operation} unexpectedly registered`);
|
||||
assert.equal(receipt.runtimeStatus, "OPERATOR_UNREGISTERED", `${entry.format} ${operation} did not fail closed`);
|
||||
assert.equal(receipt.buildOptionEnabled, null, `${entry.format} ${operation} has an ambiguous build option`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.equal(inventory.runtime.buildOptions.io_wavefront_obj, true);
|
||||
assert.equal(inventory.runtime.buildOptions.io_stl, true);
|
||||
assert.equal(inventory.runtime.buildOptions.io_ply, true);
|
||||
assert.equal(inventory.runtime.buildOptions.usd, false);
|
||||
assert.equal(inventory.runtime.buildOptions.alembic, false);
|
||||
process.stdout.write(`io-format-runtime-inventory-ok formats=${inventory.formats.length} available=${available.size} build-disabled=${disabled.size} blender=${inventory.runtime.blenderVersion}\n`);
|
||||
}
|
||||
finally {
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
47
tools/web/check-io-format-runtime-receipts.mjs
Normal file
47
tools/web/check-io-format-runtime-receipts.mjs
Normal file
@@ -0,0 +1,47 @@
|
||||
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 { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import ts from "../../web/node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const inventoryPath = path.join(repoRoot, "tests/golden/M12-05A/format-inventory.json");
|
||||
const receiptPath = path.join(repoRoot, "tests/golden/M12-05D/runtime-receipts.json");
|
||||
const protocolPath = path.join(repoRoot, "web/protocol/io-format-runtime-receipt.ts");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "io-format-runtime-receipts-"));
|
||||
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
try {
|
||||
const inventoryBytes = fs.readFileSync(inventoryPath);
|
||||
const inventory = JSON.parse(inventoryBytes);
|
||||
const receiptBytes = fs.readFileSync(receiptPath);
|
||||
const receipts = JSON.parse(receiptBytes);
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(protocolPath, "utf8"), { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: protocolPath, reportDiagnostics: true });
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const modulePath = path.join(temporary, "protocol.mjs");
|
||||
fs.writeFileSync(modulePath, transpiled.outputText);
|
||||
const protocol = await import(pathToFileURL(modulePath));
|
||||
const parsed = protocol.validateIOFormatRuntimeReceiptSet(receipts, sha256(inventoryBytes));
|
||||
assert.equal(parsed.receipts.length, 14);
|
||||
const byIdentity = new Map(inventory.formats.flatMap((entry) => ["IMPORT", "EXPORT"].map((operation) => [`${entry.format}:${operation}`, entry[operation.toLowerCase()]])));
|
||||
for (const receipt of parsed.receipts) {
|
||||
const source = byIdentity.get(`${receipt.format}:${receipt.operation}`);
|
||||
assert.ok(source);
|
||||
assert.equal(receipt.operator, source.operator);
|
||||
assert.equal(receipt.registered, source.registered);
|
||||
assert.equal(receipt.rnaIdentifier, source.rnaIdentifier);
|
||||
assert.equal(receipt.runtimeStatus, source.runtimeStatus);
|
||||
assert.equal(receipt.extensions.length > 0, true);
|
||||
}
|
||||
assert.equal(protocol.resolveIOFormatRuntimeRoute(parsed, { format: "GLB", operation: "EXPORT" }).status, "READY");
|
||||
assert.equal(protocol.resolveIOFormatRuntimeRoute(parsed, { format: "USD", operation: "EXPORT" }).status, "BLOCKED");
|
||||
const regenerated = path.join(temporary, "runtime-receipts.json");
|
||||
execFileSync(process.execPath, [path.join(repoRoot, "tools/web/generate-io-format-runtime-receipts.mjs"), "--output", regenerated], { cwd: repoRoot });
|
||||
assert.deepEqual(fs.readFileSync(regenerated), receiptBytes, "runtime receipt set is not deterministic");
|
||||
process.stdout.write("io-format-runtime-receipts-ok formats=7 receipts=14 glb-export=READY usd-export=BLOCKED extension-independent=true\n");
|
||||
}
|
||||
finally {
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
43
tools/web/check-io-format-ui-gate.mjs
Normal file
43
tools/web/check-io-format-ui-gate.mjs
Normal file
@@ -0,0 +1,43 @@
|
||||
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 { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import ts from "../../web/node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const registryPath = path.join(repoRoot, "web/app/src/capabilities/io-format-ui-registry.json");
|
||||
const matrixPath = path.join(repoRoot, "tests/golden/M12-05B/capability-matrix.json");
|
||||
const matrixProtocolPath = path.join(repoRoot, "web/protocol/io-format-capability-matrix.ts");
|
||||
const gateProtocolPath = path.join(repoRoot, "web/protocol/io-format-ui-gate.ts");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "io-format-ui-gate-check-"));
|
||||
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
try {
|
||||
const registry = JSON.parse(fs.readFileSync(registryPath, "utf8"));
|
||||
const matrixBytes = fs.readFileSync(matrixPath);
|
||||
const transpile = (sourcePath, fileName) => {
|
||||
const result = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: sourcePath, reportDiagnostics: true });
|
||||
assert.deepEqual(result.diagnostics, []);
|
||||
const target = path.join(temporary, fileName);
|
||||
fs.writeFileSync(target, result.outputText);
|
||||
return target;
|
||||
};
|
||||
const matrixProtocol = await import(pathToFileURL(transpile(matrixProtocolPath, "matrix.mjs")));
|
||||
const gateProtocol = await import(pathToFileURL(transpile(gateProtocolPath, "gate.mjs")));
|
||||
const matrix = matrixProtocol.parseIOFormatCapabilityMatrix(JSON.parse(matrixBytes));
|
||||
gateProtocol.validateIOFormatUIRegistry(registry, matrix, sha256(matrixBytes));
|
||||
assert.equal(registry.task, "M12-05C");
|
||||
assert.equal(registry.parentMatrixSha256, sha256(matrixBytes));
|
||||
assert.equal(registry.projectFileAccept, ".blend,application/octet-stream");
|
||||
assert.deepEqual(registry.importRoutes, [], "no blocked import route may reach the file selector");
|
||||
assert.deepEqual(registry.exportRoutes, [{ format: "GLB", operation: "EXPORT", execution: "LOCAL", extensions: [".glb"] }]);
|
||||
const regenerated = path.join(temporary, "registry.json");
|
||||
execFileSync(process.execPath, [path.join(repoRoot, "tools/web/generate-io-format-ui-gate.mjs"), "--output", regenerated], { cwd: repoRoot });
|
||||
assert.deepEqual(fs.readFileSync(regenerated), fs.readFileSync(registryPath), "UI registry is not deterministic");
|
||||
process.stdout.write("io-format-ui-gate-ok import-routes=0 export-routes=1 file-accept=.blend,application/octet-stream fail-closed=true\n");
|
||||
}
|
||||
finally {
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
106
tools/web/check-library-link-fixture.mjs
Normal file
106
tools/web/check-library-link-fixture.mjs
Normal file
@@ -0,0 +1,106 @@
|
||||
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 { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const read = (relativePath) => fs.readFileSync(path.join(root, relativePath));
|
||||
const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
||||
const manifest = JSON.parse(read("tests/golden/M12-03F/manifest.json"));
|
||||
const report = JSON.parse(read("tests/golden/M12-03F/desktop-link-report.json"));
|
||||
const fixtureRoot = path.join(root, "tests/files/web/m12_library_link_v1");
|
||||
const generator = path.join(root, "tools/web/generate-library-link-fixture.py");
|
||||
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
|
||||
|
||||
assert.equal(manifest.task, "M12-03F");
|
||||
assert.equal(manifest.parentTask, "M12-03E");
|
||||
assert.equal(manifest.nextTask, "M12-03G");
|
||||
assert.equal(manifest.operation, "LINK");
|
||||
for (const artifact of Object.values(manifest.artifacts)) {
|
||||
assert.equal(sha256(read(artifact.path)), artifact.sha256, artifact.path);
|
||||
}
|
||||
|
||||
assert.equal(report.schemaVersion, 1);
|
||||
assert.equal(report.task, "M12-03F");
|
||||
assert.equal(report.operation, "LINK");
|
||||
assert.equal(report.blenderVersion, "5.2.0");
|
||||
assert.equal(report.nextTask, "M12-03G");
|
||||
assert.deepEqual(report.selectedRoots, ["Object/M12 Link Object"]);
|
||||
assert.deepEqual(report.sourceGraph, {
|
||||
edges: [
|
||||
{ from: "Object/M12 Link Object", relation: "OBJECT_DATA", to: "Mesh/M12 Link Mesh" },
|
||||
{ from: "Mesh/M12 Link Mesh", relation: "MATERIAL_SLOT[0]", to: "Material/M12 Link Material" },
|
||||
{ from: "Material/M12 Link Material", relation: "NODE_IMAGE[M12 Link Image Node]", to: "Image/M12 Link Image" },
|
||||
],
|
||||
geometry: { edges: 4, loops: 4, materialSlots: ["M12 Link Material"], polygons: 1, uvLayers: ["UVMap"], vertices: 4 },
|
||||
ids: {
|
||||
IMAGE: { idType: "IMAGE", isLibraryOverride: false, library: null, name: "M12 Link Image", nameFull: "M12 Link Image" },
|
||||
MATERIAL: { idType: "MATERIAL", isLibraryOverride: false, library: null, name: "M12 Link Material", nameFull: "M12 Link Material" },
|
||||
MESH: { idType: "MESH", isLibraryOverride: false, library: null, name: "M12 Link Mesh", nameFull: "M12 Link Mesh" },
|
||||
OBJECT: { idType: "OBJECT", isLibraryOverride: false, library: null, name: "M12 Link Object", nameFull: "M12 Link Object" },
|
||||
},
|
||||
image: { channels: 4, colorspace: "sRGB", packed: true, pixelFloat32Sha256: "6f0f8c231d65149e69e6ed12d370bcfa095ef90adc202065492ea8c8ef17e45a", size: [2, 2] },
|
||||
root: { idType: "OBJECT", isLibraryOverride: false, library: null, name: "M12 Link Object", nameFull: "M12 Link Object" },
|
||||
sourceMarker: "M12-03F",
|
||||
});
|
||||
assert.deepEqual(report.linkedGraph.edges, report.sourceGraph.edges);
|
||||
assert.deepEqual(report.linkedGraph.geometry, report.sourceGraph.geometry);
|
||||
assert.deepEqual(report.linkedGraph.image, report.sourceGraph.image);
|
||||
assert.equal(report.linkedGraph.ids.OBJECT.library, "m12_link_source.blend");
|
||||
assert.equal(report.linkedGraph.ids.OBJECT.nameFull, "M12 Link Object [m12_link_source.blend]");
|
||||
for (const value of Object.values(report.linkedGraph.ids)) {
|
||||
assert.equal(value.library, "m12_link_source.blend");
|
||||
assert.equal(value.isLibraryOverride, false);
|
||||
}
|
||||
assert.deepEqual(report.stableMapping.map((item) => [item.owner, item.readOnly]), [
|
||||
["SOURCE_LIBRARY", true],
|
||||
["SOURCE_LIBRARY", true],
|
||||
["SOURCE_LIBRARY", true],
|
||||
["SOURCE_LIBRARY", true],
|
||||
]);
|
||||
assert.deepEqual(report.stableMapping.map((item) => item.source), [
|
||||
"Object/M12 Link Object",
|
||||
"Mesh/M12 Link Mesh",
|
||||
"Material/M12 Link Material",
|
||||
"Image/M12 Link Image",
|
||||
]);
|
||||
assert.deepEqual(report.stableMapping.map((item) => item.local), report.stableMapping.map((item) => item.source));
|
||||
assert.equal(sha256(fs.readFileSync(path.join(fixtureRoot, report.source.file))), report.source.sha256);
|
||||
assert.equal(sha256(fs.readFileSync(path.join(fixtureRoot, report.target.file))), report.target.sha256);
|
||||
|
||||
const normalizeContainerHashes = (value) => ({
|
||||
...value,
|
||||
source: { ...value.source, sha256: "<SESSION_BOUND_BLEND_CONTAINER>" },
|
||||
target: { ...value.target, sha256: "<SESSION_BOUND_BLEND_CONTAINER>" },
|
||||
});
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m12-library-link-"));
|
||||
try {
|
||||
const generatedFixtureRoot = path.join(temporary, "files");
|
||||
const generatedReportPath = path.join(temporary, "report.json");
|
||||
const output = execFileSync(blender, [
|
||||
"--background",
|
||||
"--factory-startup",
|
||||
"--python",
|
||||
generator,
|
||||
"--",
|
||||
generatedFixtureRoot,
|
||||
generatedReportPath,
|
||||
], { cwd: root, encoding: "utf8" });
|
||||
assert.match(output, /library-link-fixture-ok roots=1 mapping=4/);
|
||||
assert.match(output, /next=M12-03G/);
|
||||
const generated = JSON.parse(fs.readFileSync(generatedReportPath, "utf8"));
|
||||
assert.deepEqual(normalizeContainerHashes(generated), normalizeContainerHashes(report));
|
||||
assert.equal(sha256(fs.readFileSync(path.join(generatedFixtureRoot, generated.source.file))), generated.source.sha256);
|
||||
assert.equal(sha256(fs.readFileSync(path.join(generatedFixtureRoot, generated.target.file))), generated.target.sha256);
|
||||
}
|
||||
finally {
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`library-link-fixture-check-ok roots=${report.selectedRoots.length} mapping=${report.stableMapping.length} ` +
|
||||
`library=${report.linkedGraph.ids.OBJECT.library} readOnly=${report.stableMapping.every((item) => item.readOnly)} next=${report.nextTask}\n`,
|
||||
);
|
||||
82
tools/web/check-library-main-append.mjs
Normal file
82
tools/web/check-library-main-append.mjs
Normal file
@@ -0,0 +1,82 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const read = (relativePath) => fs.readFileSync(path.join(root, relativePath));
|
||||
const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
||||
const manifest = JSON.parse(read("tests/golden/M12-03E/manifest.json"));
|
||||
const desktopReport = JSON.parse(read("tests/golden/M12-03C/desktop-append-report.json"));
|
||||
|
||||
assert.deepEqual(manifest, {
|
||||
schemaVersion: 1,
|
||||
task: "M12-03E",
|
||||
parentTask: "M12-03D",
|
||||
enablingTask: false,
|
||||
parityStateChange: false,
|
||||
runtime: "BLENDER_5_2_WASM_CHROMIUM",
|
||||
operation: "APPEND",
|
||||
canonicalComparison: "DESKTOP_REPORT_EXACT_AFTER_IMAGE_ROW_NORMALIZATION_AND_SCENEIR_COLORSPACE_DEFAULT",
|
||||
assertions: {
|
||||
transactionCount: 1,
|
||||
revisionDelta: 1,
|
||||
undoRemovesClosure: true,
|
||||
redoRestoresCanonical: true,
|
||||
saveReopenRestoresCanonical: true,
|
||||
},
|
||||
artifacts: {
|
||||
parentManifest: {
|
||||
path: "tests/golden/M12-03C/manifest.json",
|
||||
sha256: "cbfbd8c919125108334dd24925b9ef8e69880983515e56841e6ead4a2b182aed",
|
||||
},
|
||||
desktopReport: {
|
||||
path: "tests/golden/M12-03C/desktop-append-report.json",
|
||||
sha256: "b1d7b8b9e832d18d69081f63981062800e0f00f0c9aec025b18274510ed76e2a",
|
||||
},
|
||||
test: {
|
||||
path: "web/tests/e2e/library-append-main.spec.ts",
|
||||
sha256: "101f67d35c2320da57459f08a59687d769234b910dbd9aaec5a668cee7e0f9f0",
|
||||
},
|
||||
sourceBlend: {
|
||||
path: "tests/files/web/m12_library_append_v1/m12_append_source.blend",
|
||||
sha256: "5b60d02926efd588a6ca300ba31414cdf37dbc48786c17b383a70319057c0606",
|
||||
},
|
||||
targetBlend: {
|
||||
path: "tests/files/web/empty.blend",
|
||||
sha256: "9b1ecbcc3d7f8079ee5469de64193ffcaa0a7b01e0f2ac340bbb18aa88734a63",
|
||||
},
|
||||
},
|
||||
nextTask: "M12-03F",
|
||||
});
|
||||
|
||||
assert.equal(sha256(read(manifest.artifacts.parentManifest.path)), manifest.artifacts.parentManifest.sha256);
|
||||
assert.equal(sha256(read(manifest.artifacts.desktopReport.path)), manifest.artifacts.desktopReport.sha256);
|
||||
assert.equal(sha256(read(manifest.artifacts.sourceBlend.path)), manifest.artifacts.sourceBlend.sha256);
|
||||
assert.equal(sha256(read(manifest.artifacts.targetBlend.path)), manifest.artifacts.targetBlend.sha256);
|
||||
assert.equal(sha256(read(manifest.artifacts.test.path)), manifest.artifacts.test.sha256);
|
||||
assert.equal(desktopReport.task, "M12-03C");
|
||||
assert.equal(desktopReport.operation, "APPEND");
|
||||
assert.equal(desktopReport.appendedGraph.geometry.vertices, 4);
|
||||
assert.equal(desktopReport.appendedGraph.geometry.edges, 4);
|
||||
assert.equal(desktopReport.appendedGraph.geometry.polygons, 1);
|
||||
assert.equal(desktopReport.appendedGraph.geometry.loops, 4);
|
||||
assert.equal(desktopReport.appendedGraph.image.pixelFloat32Sha256.length, 64);
|
||||
|
||||
const source = read(manifest.artifacts.test.path).toString("utf8");
|
||||
for (const marker of [
|
||||
"M12-03E keeps append undo/redo/save/reopen",
|
||||
"desktop-append-report.json",
|
||||
"canonicalGraph",
|
||||
"client.applyCommand({ type: \"undo\" })",
|
||||
"client.applyCommand({ type: \"redo\" })",
|
||||
"client.saveBlend()",
|
||||
"reopenedCanonical",
|
||||
"desktopCanonical",
|
||||
]) assert.ok(source.includes(marker), `M12-03E test marker is missing: ${marker}`);
|
||||
|
||||
process.stdout.write(
|
||||
`library-main-append-check-ok canonical=${desktopReport.appendedGraph.image.pixelFloat32Sha256} ` +
|
||||
`transaction=${manifest.assertions.transactionCount} undo=removed redo=canonical reopen=canonical next=${manifest.nextTask}\n`,
|
||||
);
|
||||
28
tools/web/check-library-operation-commands.mjs
Normal file
28
tools/web/check-library-operation-commands.mjs
Normal file
@@ -0,0 +1,28 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const read = (relativePath) => fs.readFileSync(path.join(root, relativePath));
|
||||
const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
||||
const manifest = JSON.parse(read("tests/golden/M12-03N/manifest.json"));
|
||||
const packageJson = JSON.parse(read("web/package.json"));
|
||||
const expected = {
|
||||
"append-desktop": "node ../tools/web/check-library-append-fixture.mjs",
|
||||
"append-wasm": "node --test tests/unit/library-append-wasm.test.mjs",
|
||||
"append-chromium": "playwright test --config playwright.config.ts --workers=1 tests/e2e/library-append-main.spec.ts",
|
||||
"link-desktop": "node ../tools/web/check-library-link-fixture.mjs",
|
||||
"link-wasm": "node --test tests/unit/library-linked-mutation.test.mjs tests/unit/library-linked-reload.test.mjs tests/unit/library-linked-missing.test.mjs",
|
||||
"link-chromium": "playwright test --config playwright.config.ts --workers=1 tests/e2e/library-link-chromium.spec.ts",
|
||||
"override-desktop": "node ../tools/web/check-library-override-fixture.mjs",
|
||||
"override-wasm": "node --test tests/unit/library-override-writer.test.mjs tests/unit/library-override-freshness.test.mjs",
|
||||
"override-chromium": "playwright test --config playwright.config.ts --workers=1 tests/e2e/library-override-chromium.spec.ts",
|
||||
};
|
||||
for (const [name, command] of Object.entries(expected)) assert.equal(packageJson.scripts[`test:library-${name}`], command, name);
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(read(artifact.path)), artifact.sha256, artifact.path);
|
||||
assert.equal(manifest.task, "M12-03N");
|
||||
assert.equal(manifest.nextTask, "M12-04A");
|
||||
assert.equal(Object.keys(expected).length, 9);
|
||||
process.stdout.write("library-operation-commands-check-ok lanes=9 operations=3 desktop=3 wasm=3 chromium=3 next=M12-04A\n");
|
||||
82
tools/web/check-library-override-fixture.mjs
Normal file
82
tools/web/check-library-override-fixture.mjs
Normal file
@@ -0,0 +1,82 @@
|
||||
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 { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const fixtureRoot = path.join(root, "tests/files/web/m12_library_override_v1");
|
||||
const generator = path.join(root, "tools/web/generate-library-override-fixture.py");
|
||||
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
|
||||
const read = (relativePath) => fs.readFileSync(path.join(root, relativePath));
|
||||
const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
||||
const manifest = JSON.parse(read("tests/golden/M12-03J/manifest.json"));
|
||||
const report = JSON.parse(read("tests/golden/M12-03J/desktop-override-report.json"));
|
||||
|
||||
assert.equal(manifest.task, "M12-03J");
|
||||
assert.equal(manifest.parentTask, "M12-03I");
|
||||
assert.equal(manifest.nextTask, "M12-03K");
|
||||
assert.equal(report.schemaVersion, 1);
|
||||
assert.equal(report.task, "M12-03J");
|
||||
assert.equal(report.operation, "LIBRARY_OVERRIDE");
|
||||
assert.equal(report.blenderVersion, "5.2.0");
|
||||
assert.deepEqual(report.selectedRoots, ["Object/M12 Override Object"]);
|
||||
assert.deepEqual(report.overrideGraph.reference, {
|
||||
dataBlockId: "Object/M12 Override Object",
|
||||
idType: "OBJECT",
|
||||
library: "m12_override_source.blend",
|
||||
owner: "SOURCE_LIBRARY",
|
||||
readOnly: true,
|
||||
isLibraryOverride: false,
|
||||
});
|
||||
assert.deepEqual(report.overrideGraph.local, {
|
||||
dataBlockId: "Object/M12 Override Object",
|
||||
idType: "OBJECT",
|
||||
library: null,
|
||||
owner: "LOCAL_OVERRIDE",
|
||||
projectId: "m12-03j-project",
|
||||
readOnly: false,
|
||||
referenceSourceDataBlockId: "Object/M12 Override Object",
|
||||
hierarchyRootDataBlockId: "Object/M12 Override Object",
|
||||
isLibraryOverride: true,
|
||||
});
|
||||
assert.deepEqual(report.overrideGraph.propertyOverride, {
|
||||
index: 0,
|
||||
operationCount: 1,
|
||||
propertyCount: 1,
|
||||
rnaPath: "[\"m12_override_value\"]",
|
||||
value: 2.5,
|
||||
});
|
||||
assert.equal(report.overrideGraph.sourceMarker, "M12-03J");
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(read(artifact.path)), artifact.sha256, artifact.path);
|
||||
assert.equal(sha256(fs.readFileSync(path.join(fixtureRoot, report.source.file))), report.source.sha256);
|
||||
assert.equal(sha256(fs.readFileSync(path.join(fixtureRoot, report.target.file))), report.target.sha256);
|
||||
|
||||
const normalize = (value) => ({
|
||||
...value,
|
||||
source: { ...value.source, sha256: "<SESSION_BOUND_BLEND_CONTAINER>" },
|
||||
target: { ...value.target, sha256: "<SESSION_BOUND_BLEND_CONTAINER>" },
|
||||
});
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m12-library-override-"));
|
||||
try {
|
||||
const generatedRoot = path.join(temporary, "files");
|
||||
const generatedReportPath = path.join(temporary, "report.json");
|
||||
const output = execFileSync(blender, ["--background", "--factory-startup", "--python", generator, "--", generatedRoot, generatedReportPath], { cwd: root, encoding: "utf8" });
|
||||
assert.match(output, /library-override-fixture-ok roots=1/);
|
||||
assert.match(output, /owner=LOCAL_OVERRIDE/);
|
||||
const generated = JSON.parse(fs.readFileSync(generatedReportPath, "utf8"));
|
||||
assert.deepEqual(normalize(generated), normalize(report));
|
||||
assert.equal(sha256(fs.readFileSync(path.join(generatedRoot, generated.source.file))), generated.source.sha256);
|
||||
assert.equal(sha256(fs.readFileSync(path.join(generatedRoot, generated.target.file))), generated.target.sha256);
|
||||
}
|
||||
finally {
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`library-override-fixture-check-ok roots=${report.selectedRoots.length} ` +
|
||||
`reference=${report.overrideGraph.reference.library} owner=${report.overrideGraph.local.owner} ` +
|
||||
`path=${report.overrideGraph.propertyOverride.rnaPath} next=${report.nextTask}\n`,
|
||||
);
|
||||
182
tools/web/check-malicious-archive-fixtures.mjs
Normal file
182
tools/web/check-malicious-archive-fixtures.mjs
Normal file
@@ -0,0 +1,182 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL, fileURLToPath } from "node:url";
|
||||
import ts from "../../web/node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const fixtureRoot = path.join(repoRoot, "tests/files/web/archive-security");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "malicious-archive-fixtures-"));
|
||||
const moduleRoot = path.join(temporary, "modules");
|
||||
const regeneratedRoot = path.join(temporary, "regenerated");
|
||||
fs.mkdirSync(moduleRoot, { recursive: true });
|
||||
|
||||
try {
|
||||
for (const sourceName of ["archive-link-safety.ts", "archive-conflicts.ts"]) {
|
||||
const sourcePath = path.join(repoRoot, "web/protocol", sourceName);
|
||||
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(moduleRoot, sourceName.replace(".ts", ".mjs")), transpiled.outputText);
|
||||
}
|
||||
const safety = await import(pathToFileURL(path.join(moduleRoot, "archive-link-safety.mjs")));
|
||||
const conflicts = await import(pathToFileURL(path.join(moduleRoot, "archive-conflicts.mjs")));
|
||||
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true });
|
||||
const evidence = JSON.parse(fs.readFileSync(path.join(repoRoot, "tests/golden/M12-04J/manifest.json"), "utf8"));
|
||||
assert.equal(evidence.task, "M12-04J");
|
||||
assert.equal(evidence.parentTask, "M12-04I");
|
||||
assert.equal(evidence.nextTask, "M12-05A");
|
||||
for (const artifact of Object.values(evidence.artifacts)) {
|
||||
assert.equal(sha256(fs.readFileSync(path.join(repoRoot, artifact.path))), artifact.sha256, artifact.path);
|
||||
}
|
||||
|
||||
function decode(bytes, offset, length) {
|
||||
return decoder.decode(bytes.subarray(offset, offset + length));
|
||||
}
|
||||
|
||||
function zipMetadata(bytes) {
|
||||
assert.ok(bytes.length >= 22, "ZIP fixture is shorter than EOCD");
|
||||
const endOffset = bytes.length - 22;
|
||||
assert.equal(bytes.readUInt32LE(endOffset), 0x06054b50, "ZIP fixture EOCD is missing");
|
||||
assert.equal(bytes.readUInt16LE(endOffset + 4), 0, "multi-disk ZIP fixture is forbidden");
|
||||
assert.equal(bytes.readUInt16LE(endOffset + 6), 0, "multi-disk ZIP fixture is forbidden");
|
||||
const entries = bytes.readUInt16LE(endOffset + 10);
|
||||
assert.equal(bytes.readUInt16LE(endOffset + 8), entries, "ZIP entry counts disagree");
|
||||
const centralBytes = bytes.readUInt32LE(endOffset + 12);
|
||||
const centralOffset = bytes.readUInt32LE(endOffset + 16);
|
||||
assert.equal(centralOffset + centralBytes, endOffset, "ZIP central directory is not the first bounded metadata region");
|
||||
const links = [];
|
||||
const ranges = [];
|
||||
let offset = centralOffset;
|
||||
for (let index = 0; index < entries; index++) {
|
||||
assert.equal(bytes.readUInt32LE(offset), 0x02014b50, `ZIP central entry ${index} is invalid`);
|
||||
const method = bytes.readUInt16LE(offset + 10);
|
||||
const compressedBytes = bytes.readUInt32LE(offset + 20);
|
||||
const uncompressedBytes = bytes.readUInt32LE(offset + 24);
|
||||
const nameBytes = bytes.readUInt16LE(offset + 28);
|
||||
const extraBytes = bytes.readUInt16LE(offset + 30);
|
||||
const commentBytes = bytes.readUInt16LE(offset + 32);
|
||||
const localOffset = bytes.readUInt32LE(offset + 42);
|
||||
const entryPath = decode(bytes, offset + 46, nameBytes);
|
||||
assert.equal(method, 0, "fixture ZIP entries must use deterministic STORE metadata");
|
||||
assert.equal(bytes.readUInt32LE(localOffset), 0x04034b50, `ZIP local entry ${index} is invalid`);
|
||||
assert.equal(bytes.readUInt16LE(localOffset + 8), method, "ZIP local/central methods disagree");
|
||||
assert.equal(bytes.readUInt32LE(localOffset + 18), compressedBytes, "ZIP local/central compressed sizes disagree");
|
||||
assert.equal(bytes.readUInt32LE(localOffset + 22), uncompressedBytes, "ZIP local/central uncompressed sizes disagree");
|
||||
const localNameBytes = bytes.readUInt16LE(localOffset + 26);
|
||||
const localExtraBytes = bytes.readUInt16LE(localOffset + 28);
|
||||
assert.equal(decode(bytes, localOffset + 30, localNameBytes), entryPath, "ZIP local/central names disagree");
|
||||
const compressedOffset = localOffset + 30 + localNameBytes + localExtraBytes;
|
||||
assert.ok(compressedOffset + compressedBytes <= centralOffset, "ZIP payload range overlaps central metadata");
|
||||
links.push({ path: entryPath, type: entryPath.endsWith("/") ? "DIRECTORY" : "FILE", target: null });
|
||||
ranges.push({ path: entryPath, compressedOffset, compressedBytes, uncompressedBytes });
|
||||
offset += 46 + nameBytes + extraBytes + commentBytes;
|
||||
}
|
||||
assert.equal(offset, endOffset, "ZIP central directory length disagrees with EOCD");
|
||||
return { links, ranges };
|
||||
}
|
||||
|
||||
function tarString(bytes, offset, length) {
|
||||
const field = bytes.subarray(offset, offset + length);
|
||||
const end = field.indexOf(0);
|
||||
return decoder.decode(end === -1 ? field : field.subarray(0, end));
|
||||
}
|
||||
|
||||
function tarOctal(bytes, offset, length) {
|
||||
const value = tarString(bytes, offset, length).trim();
|
||||
assert.match(value, /^[0-7]+$/, "TAR numeric field is not octal");
|
||||
return Number.parseInt(value, 8);
|
||||
}
|
||||
|
||||
function tarMetadata(bytes) {
|
||||
assert.equal(bytes.length % 512, 0, "TAR fixture is not block aligned");
|
||||
const links = [];
|
||||
const ranges = [];
|
||||
let offset = 0;
|
||||
let zeroBlocks = 0;
|
||||
while (offset < bytes.length) {
|
||||
const header = bytes.subarray(offset, offset + 512);
|
||||
if (header.every((byte) => byte === 0)) {
|
||||
zeroBlocks++;
|
||||
offset += 512;
|
||||
if (zeroBlocks === 2) break;
|
||||
continue;
|
||||
}
|
||||
assert.equal(zeroBlocks, 0, "TAR has data after an end marker");
|
||||
assert.equal(tarString(header, 257, 6), "ustar", "TAR fixture is not USTAR");
|
||||
const expectedChecksum = tarOctal(header, 148, 8);
|
||||
const checksumHeader = Buffer.from(header);
|
||||
checksumHeader.fill(0x20, 148, 156);
|
||||
assert.equal(checksumHeader.reduce((sum, byte) => sum + byte, 0), expectedChecksum, "TAR header checksum mismatch");
|
||||
const prefix = tarString(header, 345, 155);
|
||||
const name = tarString(header, 0, 100);
|
||||
const entryPath = prefix ? `${prefix}/${name}` : name;
|
||||
const uncompressedBytes = tarOctal(header, 124, 12);
|
||||
const typeFlag = String.fromCharCode(header[156] || 0x30);
|
||||
const type = { "0": "FILE", "1": "HARDLINK", "2": "SYMLINK", "5": "DIRECTORY" }[typeFlag];
|
||||
assert.ok(type, `TAR entry type ${typeFlag} is unsupported by the fixture gate`);
|
||||
const target = type === "SYMLINK" || type === "HARDLINK" ? tarString(header, 157, 100) : null;
|
||||
const compressedOffset = offset + 512;
|
||||
assert.ok(compressedOffset + uncompressedBytes <= bytes.length, "TAR payload exceeds the fixture");
|
||||
links.push({ path: entryPath, type, target });
|
||||
ranges.push({ path: entryPath, compressedOffset, compressedBytes: uncompressedBytes, uncompressedBytes });
|
||||
offset = compressedOffset + Math.ceil(uncompressedBytes / 512) * 512;
|
||||
}
|
||||
assert.equal(zeroBlocks, 2, "TAR fixture has no two-block end marker");
|
||||
assert.equal(offset, bytes.length, "TAR fixture has trailing data after the end marker");
|
||||
return { links, ranges };
|
||||
}
|
||||
|
||||
const manifestBytes = fs.readFileSync(path.join(fixtureRoot, "manifest.json"));
|
||||
const manifest = JSON.parse(manifestBytes);
|
||||
assert.equal(manifest.schemaVersion, 1);
|
||||
assert.equal(manifest.task, "M12-04J");
|
||||
assert.equal(manifest.generator, "tools/web/generate-malicious-archive-fixtures.mjs");
|
||||
assert.equal(manifest.extractionAllowed, false);
|
||||
const expected = [
|
||||
["ZIP_PATH_TRAVERSAL", "ZIP", "ARCHIVE_ROOT_ESCAPE", "LINK_SAFETY"],
|
||||
["ZIP_COMPRESSION_BOMB", "ZIP", "COMPRESSION_RATIO", "CONFLICTS"],
|
||||
["ZIP_DUPLICATE_PATH", "ZIP", "DUPLICATE_PATH", "LINK_SAFETY"],
|
||||
["TAR_PATH_TRAVERSAL", "TAR", "ARCHIVE_ROOT_ESCAPE", "LINK_SAFETY"],
|
||||
["TAR_SYMLINK_ESCAPE", "TAR", "SYMLINK_ESCAPE", "LINK_SAFETY"],
|
||||
["TAR_PREFIX_CONFLICT", "TAR", "FILE_DIRECTORY_PREFIX_CONFLICT", "CONFLICTS"],
|
||||
];
|
||||
assert.deepEqual(manifest.cases.map((item) => [item.id, item.format, item.threat, item.gate]), expected);
|
||||
|
||||
execFileSync(process.execPath, [path.join(repoRoot, manifest.generator), "--output", regeneratedRoot], { cwd: repoRoot });
|
||||
assert.deepEqual(fs.readFileSync(path.join(regeneratedRoot, "manifest.json")), manifestBytes, "fixture manifest is not deterministic");
|
||||
|
||||
for (const fixture of manifest.cases) {
|
||||
assert.equal(fixture.file, path.basename(fixture.file), `${fixture.id} fixture path escapes its root`);
|
||||
assert.equal(fixture.expectedCode, "IO_ARCHIVE_UNSAFE");
|
||||
const archiveBytes = fs.readFileSync(path.join(fixtureRoot, fixture.file));
|
||||
assert.equal(archiveBytes.length, fixture.byteLength, `${fixture.id} byte length drifted`);
|
||||
assert.equal(sha256(archiveBytes), fixture.sha256, `${fixture.id} SHA-256 drifted`);
|
||||
assert.deepEqual(fs.readFileSync(path.join(regeneratedRoot, fixture.file)), archiveBytes, `${fixture.id} is not deterministic`);
|
||||
const metadata = fixture.format === "ZIP" ? zipMetadata(archiveBytes) : tarMetadata(archiveBytes);
|
||||
let failure;
|
||||
try {
|
||||
if (fixture.gate === "LINK_SAFETY") {
|
||||
safety.resolveArchiveLinkEntries({ schemaVersion: 1, temporaryRootId: `fixture:${fixture.id}`, entries: metadata.links });
|
||||
}
|
||||
else {
|
||||
conflicts.validateArchiveConflicts({ schemaVersion: 1, byteLength: archiveBytes.length, ranges: metadata.ranges });
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
assert.equal(failure?.code, fixture.expectedCode, `${fixture.id} did not fail closed`);
|
||||
}
|
||||
process.stdout.write(`malicious-archive-fixtures-ok cases=${manifest.cases.length} zip=3 tar=3 extraction=disabled\n`);
|
||||
}
|
||||
finally {
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
48
tools/web/check-malicious-input-matrix.mjs
Normal file
48
tools/web/check-malicious-input-matrix.mjs
Normal file
@@ -0,0 +1,48 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const reportPath = path.join(root, "tests/golden/M13-05F/malicious-input-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M13-05F/manifest.json");
|
||||
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
const fileSha256 = (file) => sha256(fs.readFileSync(file));
|
||||
const commands = [
|
||||
{ id: "blend", command: process.execPath, args: ["tools/web/check-malicious-blends.mjs"], expected: "REJECTED" },
|
||||
{ id: "archive", command: process.execPath, args: ["tools/web/check-malicious-archive-fixtures.mjs"], expected: "REJECTED" },
|
||||
{ id: "image-font-media-node-manifest", command: process.execPath, args: ["--test", "web/tests/unit/asset-preview-decode.test.mjs", "web/tests/unit/external-vfont.test.mjs", "web/tests/unit/sequencer-media-cache.test.mjs", "web/tests/unit/shader-compiler.test.mjs", "web/tests/unit/script-manifest-budgets.test.mjs"], expected: "REJECTED" },
|
||||
{ id: "glb", command: process.execPath, args: ["--test", "web/tests/unit/glb-negative-cases.test.mjs"], expected: "REJECTED" },
|
||||
];
|
||||
const results = commands.map((entry) => {
|
||||
const run = spawnSync(entry.command, entry.args, { cwd: root, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
|
||||
assert.equal(run.status, 0, `${entry.id} failed\n${run.stdout}\n${run.stderr}`);
|
||||
return { id: entry.id, status: entry.expected, exitCode: run.status };
|
||||
});
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
task: "M13-05F",
|
||||
operation: "MALICIOUS_INPUT_MATRIX",
|
||||
cases: {
|
||||
blend: { status: "REJECTED", checker: "tools/web/check-malicious-blends.mjs", stableCode: "BLEND_OPEN_INVALID" },
|
||||
image: { status: "REJECTED", checker: "web/tests/unit/asset-preview-decode.test.mjs", stableCodes: ["ASSET_MANIFEST_INVALID", "ASSET_BUDGET_EXCEEDED", "ASSET_SOURCE_HASH_MISMATCH"] },
|
||||
font: { status: "REJECTED", checker: "web/tests/unit/external-vfont.test.mjs", stableCodes: ["NON_MESH_BINARY_INVALID", "NON_MESH_RESOURCE_OUTSIDE_PROJECT", "ASSET_SOURCE_HASH_MISMATCH"] },
|
||||
media: { status: "REJECTED", checker: "web/tests/unit/sequencer-media-cache.test.mjs", stableCode: "SEQUENCER_SCHEMA_INVALID" },
|
||||
archive: { status: "REJECTED", checker: "tools/web/check-malicious-archive-fixtures.mjs", stableCode: "IO_ARCHIVE_UNSAFE" },
|
||||
nodeGraph: { status: "REJECTED", checker: "web/tests/unit/shader-compiler.test.mjs", stableCodes: ["SHADER_NODE_UNSUPPORTED", "SHADER_INVALID_GRAPH"] },
|
||||
manifest: { status: "REJECTED", checker: "web/tests/unit/script-manifest-budgets.test.mjs", stableCode: "SCRIPT_MANIFEST_INVALID" },
|
||||
glb: { status: "REJECTED", checker: "web/tests/unit/glb-negative-cases.test.mjs", stableCodes: ["GLB_SPARSE_ACCESSOR_UNSUPPORTED", "GLB_EXTENSION_UNSUPPORTED", "GLB_EXTERNAL_URI_BLOCKED", "GLB_IMPORT_BUDGET_EXCEEDED"] },
|
||||
},
|
||||
executions: results,
|
||||
allRejected: results.every((result) => result.status === "REJECTED"),
|
||||
execution: "DISABLED",
|
||||
nextTask: "M13-05G",
|
||||
};
|
||||
if (process.env.UPDATE_M13_05F_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); }
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-05F", parentTask: "M13-05E", nextTask: "M13-05G" });
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileSha256(path.join(root, artifact.path)), artifact.sha256, artifact.path);
|
||||
process.stdout.write("malicious-input-matrix-ok blend=REJECTED image=REJECTED font=REJECTED media=REJECTED archive=REJECTED nodeGraph=REJECTED manifest=REJECTED glb=REJECTED execution=DISABLED next=M13-05G\n");
|
||||
16
tools/web/check-malicious-script-fixture.mjs
Normal file
16
tools/web/check-malicious-script-fixture.mjs
Normal file
@@ -0,0 +1,16 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const report = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M13-01F/malicious-report.json"), "utf8"));
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M13-01F/manifest.json"), "utf8"));
|
||||
assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-01F", parentTask: "M13-01E", nextTask: "M13-02A" });
|
||||
const fixturePath = path.join(root, "tests/files/web/m13_malicious_script_v1", report.fixture.name);
|
||||
assert.deepEqual({ schemaVersion: report.schemaVersion, task: report.task, operation: report.operation, nextTask: report.nextTask }, { schemaVersion: 1, task: "M13-01F", operation: "MALICIOUS_SCRIPT_FIXTURE", nextTask: "M13-02A" });
|
||||
assert.equal(report.sources.length, 4); assert.equal(report.sources.filter((source) => source.useModule).length, 1); assert.ok(report.sources.every((source) => source.expectedExecution === "BLOCKED"));
|
||||
assert.equal(crypto.createHash("sha256").update(fs.readFileSync(fixturePath)).digest("hex"), report.fixture.sha256);
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(crypto.createHash("sha256").update(fs.readFileSync(path.join(root, artifact.path))).digest("hex"), artifact.sha256, `artifact hash mismatch ${artifact.path}`);
|
||||
process.stdout.write(`malicious-script-fixture-ok sources=${report.sources.length} module=1 execution=BLOCKED fixtureSha256=${report.fixture.sha256} next=${report.nextTask}\n`);
|
||||
110
tools/web/check-obj-multi-negative-fixtures.mjs
Normal file
110
tools/web/check-obj-multi-negative-fixtures.mjs
Normal file
@@ -0,0 +1,110 @@
|
||||
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 { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const manifestPath = path.join(root, "tests/golden/M12-07B/manifest.json");
|
||||
const reportPath = path.join(root, "tests/golden/M12-07B/desktop-fixtures.json");
|
||||
const fixtureRoot = path.join(root, "tests/files/web/m12_obj_multi_v1");
|
||||
const generator = path.join(root, "tools/web/generate-obj-multi-negative-fixtures.py");
|
||||
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
|
||||
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
const fileHash = (file) => sha256(fs.readFileSync(file));
|
||||
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
|
||||
function parseObj(file) {
|
||||
const positions = [];
|
||||
const texcoords = [];
|
||||
const normals = [];
|
||||
const faces = [];
|
||||
const groups = [];
|
||||
let object = null;
|
||||
let material = null;
|
||||
for (const raw of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts[0] === "v") positions.push(parts.slice(1));
|
||||
else if (parts[0] === "vt") texcoords.push(parts.slice(1));
|
||||
else if (parts[0] === "vn") normals.push(parts.slice(1));
|
||||
else if (parts[0] === "g") {
|
||||
for (const group of parts.slice(1)) {
|
||||
groups.push(group);
|
||||
if (group.endsWith("_Mesh")) object = group;
|
||||
}
|
||||
}
|
||||
else if (parts[0] === "usemtl") material = parts.slice(1).join(" ");
|
||||
else if (parts[0] === "f") faces.push({ object, material, tokens: parts.slice(1) });
|
||||
}
|
||||
return { positions, texcoords, normals, groups, objects: [...new Set(faces.map((face) => face.object).filter(Boolean))], faces };
|
||||
}
|
||||
|
||||
const manifest = readJson(manifestPath);
|
||||
const report = readJson(reportPath);
|
||||
assert.deepEqual(
|
||||
{ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask },
|
||||
{ schemaVersion: 1, task: "M12-07B", parentTask: "M12-07A", nextTask: "M12-07C" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
{ schemaVersion: report.schemaVersion, task: report.task, operation: report.operation, nextTask: report.nextTask },
|
||||
{ schemaVersion: 1, task: "M12-07B", operation: "DESKTOP_OBJ_MULTI_OBJECT_AND_NEGATIVE_FIXTURES", nextTask: "M12-07C" },
|
||||
);
|
||||
for (const artifact of Object.values(manifest.artifacts)) {
|
||||
if (artifact.path === manifestPath) continue;
|
||||
const absolute = path.join(root, artifact.path);
|
||||
assert.ok(fs.existsSync(absolute), `missing artifact ${artifact.path}`);
|
||||
assert.equal(fileHash(absolute), artifact.sha256, `hash mismatch ${artifact.path}`);
|
||||
}
|
||||
const inventory = readJson(path.join(root, "tests/golden/M12-05A/format-inventory.json"));
|
||||
for (const key of ["blenderVersion", "versionTuple", "buildDate", "buildTime", "buildHash", "buildBranch", "buildPlatform", "buildType", "binarySha256"]) {
|
||||
assert.deepEqual(report.runtime[key], inventory.runtime[key], `runtime drift in ${key}`);
|
||||
}
|
||||
assert.equal(report.semantic.objects.length, 2);
|
||||
assert.equal(report.semantic.positions.length, 6);
|
||||
assert.equal(report.semantic.texcoords.length, 6);
|
||||
assert.equal(report.semantic.normals.length, 2);
|
||||
assert.equal(report.semantic.faces.length, 2);
|
||||
assert.equal(report.semantic.materials.length, 2);
|
||||
assert.deepEqual(report.textureOrigin, { mtlMapKd: ["m12_obj_texture.png", "m12_obj_texture.png"], relative: true, textureFile: "m12_obj_texture.png" });
|
||||
assert.deepEqual(report.negativeIndex, { file: "negative-index.obj", expectedStatus: "ACCEPT_WITH_NEGATIVE_INDICES", faceCount: 2 });
|
||||
assert.deepEqual(report.malformedFace, { file: "malformed-face.obj", expectedCode: "OBJ_FACE_ARITY_INVALID" });
|
||||
const positive = parseObj(path.join(fixtureRoot, "multi-object.obj"));
|
||||
assert.equal(positive.objects.length, 2);
|
||||
assert.equal(positive.faces.length, 2);
|
||||
assert.ok(positive.faces.every((face) => face.tokens.length === 3));
|
||||
assert.ok(positive.faces.every((face) => face.tokens.every((token) => token.split("/").length === 3)));
|
||||
const negative = parseObj(path.join(fixtureRoot, "negative-index.obj"));
|
||||
assert.ok(negative.faces.every((face) => face.tokens.every((token) => token.split("/").every((index) => Number(index) < 0))));
|
||||
assert.equal(negative.faces.length, 2);
|
||||
const malformed = parseObj(path.join(fixtureRoot, "malformed-face.obj"));
|
||||
assert.equal(malformed.faces[0].tokens.length, 2);
|
||||
assert.equal(malformed.faces[0].tokens.length === 3 ? "ACCEPTED" : "OBJ_FACE_ARITY_INVALID", "OBJ_FACE_ARITY_INVALID");
|
||||
const texture = fs.readFileSync(path.join(fixtureRoot, "m12_obj_texture.png"));
|
||||
assert.deepEqual([...texture.subarray(0, 8)], [137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
for (const file of report.files) {
|
||||
const absolute = path.join(fixtureRoot, file.name);
|
||||
assert.equal(fileHash(absolute), file.sha256, `${file.name} hash`);
|
||||
assert.equal(fs.statSync(absolute).size, file.byteLength, `${file.name} byte length`);
|
||||
}
|
||||
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m12-07b-obj-"));
|
||||
try {
|
||||
const regeneratedReport = path.join(temporary, "desktop-fixtures.json");
|
||||
const result = spawnSync(blender, ["-b", "--factory-startup", "--python", generator, "--", temporary, regeneratedReport], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 20 * 1024 * 1024,
|
||||
});
|
||||
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
assert.deepEqual(readJson(regeneratedReport), report, "OBJ multi/negative report is not deterministic");
|
||||
for (const file of report.files) assert.deepEqual(fs.readFileSync(path.join(temporary, file.name)), fs.readFileSync(path.join(fixtureRoot, file.name)), `${file.name} bytes are not deterministic`);
|
||||
}
|
||||
finally {
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
process.stdout.write(`obj-multi-negative-fixtures-ok objects=${report.semantic.objects.length} negative=true malformed=${report.malformedFace.expectedCode} textureOrigin=relative deterministic=true next=${manifest.nextTask}\n`);
|
||||
86
tools/web/check-obj-single-mesh-fixture.mjs
Normal file
86
tools/web/check-obj-single-mesh-fixture.mjs
Normal file
@@ -0,0 +1,86 @@
|
||||
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 { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const manifestPath = path.join(root, "tests/golden/M12-07A/manifest.json");
|
||||
const reportPath = path.join(root, "tests/golden/M12-07A/desktop-fixture.json");
|
||||
const fixtureRoot = path.join(root, "tests/files/web/m12_obj_desktop_v1");
|
||||
const generator = path.join(root, "tools/web/generate-obj-single-mesh-fixture.py");
|
||||
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
|
||||
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
const fileHash = (file) => sha256(fs.readFileSync(file));
|
||||
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
|
||||
const manifest = readJson(manifestPath);
|
||||
const report = readJson(reportPath);
|
||||
assert.deepEqual(
|
||||
{ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask },
|
||||
{ schemaVersion: 1, task: "M12-07A", parentTask: "M12-06G", nextTask: "M12-07B" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
{ schemaVersion: report.schemaVersion, task: report.task, operation: report.operation, nextTask: report.nextTask },
|
||||
{ schemaVersion: 1, task: "M12-07A", operation: "DESKTOP_OBJ_SINGLE_MESH_FIXTURE", nextTask: "M12-07B" },
|
||||
);
|
||||
for (const artifact of Object.values(manifest.artifacts)) {
|
||||
if (artifact.path === manifestPath) continue;
|
||||
const absolute = path.join(root, artifact.path);
|
||||
assert.ok(fs.existsSync(absolute), `missing artifact ${artifact.path}`);
|
||||
assert.equal(fileHash(absolute), artifact.sha256, `hash mismatch ${artifact.path}`);
|
||||
}
|
||||
|
||||
const inventory = readJson(path.join(root, "tests/golden/M12-05A/format-inventory.json"));
|
||||
for (const key of ["blenderVersion", "versionTuple", "buildDate", "buildTime", "buildHash", "buildBranch", "buildPlatform", "buildType", "binarySha256"]) {
|
||||
assert.deepEqual(report.runtime[key], inventory.runtime[key], `runtime drift in ${key}`);
|
||||
}
|
||||
assert.equal(report.sourceAnchor, "blender-5.2.0/source/blender/io/wavefront_obj");
|
||||
assert.equal(report.operator, "wm.obj_export");
|
||||
assert.deepEqual(report.settings, {
|
||||
forwardAxis: "NEGATIVE_Z",
|
||||
upAxis: "Y",
|
||||
globalScale: 1,
|
||||
exportUV: true,
|
||||
exportNormals: true,
|
||||
exportMaterials: true,
|
||||
exportMaterialGroups: true,
|
||||
});
|
||||
assert.deepEqual(report.semantic.materialLibraries, ["single-mesh.mtl"]);
|
||||
assert.deepEqual(report.semantic.objects, ["M12_OBJ_Single_Mesh"]);
|
||||
assert.equal(report.semantic.positions.length, 4);
|
||||
assert.equal(report.semantic.texcoords.length, 4);
|
||||
assert.equal(report.semantic.normals.length, 1);
|
||||
assert.equal(report.semantic.faces.length, 2);
|
||||
assert.equal(report.semantic.materials.length, 2);
|
||||
assert.deepEqual(report.semantic.faces.map((face) => face.vertices.length), [3, 3]);
|
||||
assert.deepEqual(report.semantic.faces.map((face) => face.material), ["M12_OBJ_Red", "M12_OBJ_Blue"]);
|
||||
assert.deepEqual(report.semantic.faces.map((face) => face.groups.length), [1, 1]);
|
||||
assert.notEqual(report.semantic.faces[0].groups[0], report.semantic.faces[1].groups[0]);
|
||||
assert.ok(report.semantic.faces.flatMap((face) => face.vertices).every((vertex) => vertex.position && vertex.texcoord && vertex.normal));
|
||||
assert.deepEqual(report.semantic.materials.map((material) => material.name), ["M12_OBJ_Blue", "M12_OBJ_Red"]);
|
||||
for (const file of report.files) {
|
||||
const absolute = path.join(fixtureRoot, file.name);
|
||||
assert.equal(fileHash(absolute), file.sha256, `${file.name} hash`);
|
||||
assert.equal(fs.statSync(absolute).size, file.byteLength, `${file.name} byte length`);
|
||||
}
|
||||
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m12-07a-obj-"));
|
||||
try {
|
||||
const regeneratedReport = path.join(temporary, "desktop-fixture.json");
|
||||
const result = spawnSync(blender, ["-b", "--factory-startup", "--python", generator, "--", temporary, regeneratedReport], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 20 * 1024 * 1024,
|
||||
});
|
||||
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
assert.deepEqual(readJson(regeneratedReport), report, "OBJ semantic report is not deterministic");
|
||||
for (const file of report.files) assert.deepEqual(fs.readFileSync(path.join(temporary, file.name)), fs.readFileSync(path.join(fixtureRoot, file.name)), `${file.name} bytes are not deterministic`);
|
||||
}
|
||||
finally {
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
process.stdout.write(`obj-single-mesh-fixture-ok vertices=${report.semantic.positions.length} normals=${report.semantic.normals.length} uv=${report.semantic.texcoords.length} faces=${report.semantic.faces.length} materials=${report.semantic.materials.length} deterministic=true next=${manifest.nextTask}\n`);
|
||||
35
tools/web/check-obj-web-roundtrip.mjs
Normal file
35
tools/web/check-obj-web-roundtrip.mjs
Normal file
@@ -0,0 +1,35 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-07C/manifest.json"), "utf8"));
|
||||
const report = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-07C/web-roundtrip-report.json"), "utf8"));
|
||||
const fileHash = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
|
||||
assert.deepEqual(
|
||||
{ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask },
|
||||
{ schemaVersion: 1, task: "M12-07C", parentTask: "M12-07B", nextTask: "M12-07D" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
{ schemaVersion: report.schemaVersion, task: report.task, operation: report.operation, nextTask: report.nextTask },
|
||||
{ schemaVersion: 1, task: "M12-07C", operation: "WEB_OBJ_TO_DESKTOP_ROUNDTRIP", nextTask: "M12-07D" },
|
||||
);
|
||||
assert.deepEqual(report.browser.imported, { schemaVersion: 1, objectCount: 2, positionCount: 6, texcoordCount: 6, normalCount: 2, faceCount: 2, materialCount: 2 });
|
||||
assert.deepEqual(report.browser.lossReport, { schemaVersion: 1, operation: "OBJ_EXPORT_LOSS_REPORT", canRoundTrip: true, warningCount: 0, warnings: [] });
|
||||
assert.equal(report.browser.missingTextureLoss.warningCount, 2);
|
||||
assert.ok(report.browser.missingTextureLoss.warnings.every((warning) => warning.code === "OBJ_TEXTURE_ORIGIN_UNRESOLVED"));
|
||||
assert.equal(report.desktop.schemaVersion, 1);
|
||||
assert.equal(report.desktop.operation, "DESKTOP_IMPORT_WEB_OBJ");
|
||||
assert.equal(report.desktop.objectCount, 2);
|
||||
assert.equal(report.desktop.meshCount, 2);
|
||||
assert.ok(report.desktop.objects.every((object) => object.vertexCount === 3 && object.polygonCount === 1 && object.triangleCount === 1 && object.uvLayers.includes("UVMap") && object.materials.length === 1));
|
||||
assert.deepEqual(report.comparisons, { objectCountExact: true, triangleCountExact: true, uvLayerPresent: true, materialPresent: true });
|
||||
for (const artifact of Object.values(manifest.artifacts)) {
|
||||
const file = path.join(root, artifact.path);
|
||||
assert.ok(fs.existsSync(file), `missing artifact ${artifact.path}`);
|
||||
assert.equal(fileHash(file), artifact.sha256, `hash mismatch ${artifact.path}`);
|
||||
}
|
||||
process.stdout.write(`obj-web-roundtrip-ok objects=${report.desktop.objectCount} triangles=${report.desktop.objects.reduce((sum, object) => sum + object.triangleCount, 0)} lossWarnings=${report.browser.missingTextureLoss.warningCount} desktopExact=true next=${manifest.nextTask}\n`);
|
||||
71
tools/web/check-obj-web-roundtrip.py
Normal file
71
tools/web/check-obj-web-roundtrip.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Import a browser-produced OBJ in pinned Blender 5.2 and emit a semantic report."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
def sha256_file(path):
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def report_scene():
|
||||
objects = []
|
||||
for obj in sorted((item for item in bpy.context.scene.objects if item.type == "MESH"), key=lambda item: item.name):
|
||||
mesh = obj.data
|
||||
mesh.calc_loop_triangles()
|
||||
uv_layers = sorted(layer.name for layer in mesh.uv_layers)
|
||||
objects.append({
|
||||
"name": obj.name,
|
||||
"vertexCount": len(mesh.vertices),
|
||||
"polygonCount": len(mesh.polygons),
|
||||
"triangleCount": len(mesh.loop_triangles),
|
||||
"normalCount": len(mesh.vertices),
|
||||
"uvLayers": uv_layers,
|
||||
"uvLoopCount": len(mesh.uv_layers.active.data) if mesh.uv_layers.active else 0,
|
||||
"materials": sorted(material.name for material in mesh.materials if material),
|
||||
})
|
||||
return {"objects": objects, "objectCount": len(objects), "meshCount": len(objects)}
|
||||
|
||||
|
||||
def main(obj_path, report_path):
|
||||
obj_path = Path(obj_path).resolve()
|
||||
report_path = Path(report_path).resolve()
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
result = bpy.ops.wm.obj_import(
|
||||
filepath=str(obj_path),
|
||||
directory=str(obj_path.parent),
|
||||
forward_axis="NEGATIVE_Z",
|
||||
up_axis="Y",
|
||||
global_scale=1.0,
|
||||
use_split_objects=True,
|
||||
use_split_groups=True,
|
||||
validate_meshes=True,
|
||||
import_vertex_groups=False,
|
||||
)
|
||||
if "FINISHED" not in result:
|
||||
raise RuntimeError("Blender OBJ import did not finish: %s" % (result,))
|
||||
report = {
|
||||
"schemaVersion": 1,
|
||||
"operation": "DESKTOP_IMPORT_WEB_OBJ",
|
||||
"sourceObjSha256": sha256_file(obj_path),
|
||||
"sourceObjBytes": obj_path.stat().st_size,
|
||||
**report_scene(),
|
||||
}
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
print("obj-web-roundtrip-desktop-imported objects=%s triangles=%s" % (report["objectCount"], sum(item["triangleCount"] for item in report["objects"])))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else []
|
||||
if len(args) != 2:
|
||||
raise SystemExit("usage: blender --background --python check-obj-web-roundtrip.py -- OBJ REPORT")
|
||||
main(args[0], args[1])
|
||||
50
tools/web/check-ply-capability-fixtures.mjs
Normal file
50
tools/web/check-ply-capability-fixtures.mjs
Normal file
@@ -0,0 +1,50 @@
|
||||
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 { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const manifestPath = path.join(root, "tests/golden/M12-07G/manifest.json");
|
||||
const reportPath = path.join(root, "tests/golden/M12-07G/capability-report.json");
|
||||
const fixtureRoot = path.join(root, "tests/files/web/m12_ply_capability_v1");
|
||||
const generator = path.join(root, "tools/web/generate-ply-capability-fixtures.py");
|
||||
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
|
||||
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
const fileHash = (file) => sha256(fs.readFileSync(file));
|
||||
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
|
||||
const manifest = readJson(manifestPath);
|
||||
const report = readJson(reportPath);
|
||||
assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M12-07G", parentTask: "M12-07F", nextTask: "M12-07H" });
|
||||
assert.deepEqual({ schemaVersion: report.schemaVersion, task: report.task, operation: report.operation, nextTask: report.nextTask }, { schemaVersion: 1, task: "M12-07G", operation: "DESKTOP_PLY_ASCII_BINARY_LE_CAPABILITY", nextTask: "M12-07H" });
|
||||
for (const artifact of Object.values(manifest.artifacts)) {
|
||||
if (artifact.path === manifestPath) continue;
|
||||
const absolute = path.join(root, artifact.path);
|
||||
assert.ok(fs.existsSync(absolute), `missing artifact ${artifact.path}`);
|
||||
assert.equal(fileHash(absolute), artifact.sha256, `hash mismatch ${artifact.path}`);
|
||||
}
|
||||
const inventory = readJson(path.join(root, "tests/golden/M12-05A/format-inventory.json"));
|
||||
for (const key of ["blenderVersion", "versionTuple", "buildDate", "buildTime", "buildHash", "buildBranch", "buildPlatform", "buildType", "binarySha256"]) assert.deepEqual(report.runtime[key], inventory.runtime[key], `runtime drift in ${key}`);
|
||||
assert.equal(report.sourceAnchor, "blender-5.2.0/source/blender/io/ply");
|
||||
assert.equal(report.operator, "wm.ply_export");
|
||||
assert.deepEqual(report.variants.map((variant) => variant.id), ["PLY_ASCII", "PLY_BINARY_LITTLE_ENDIAN"]);
|
||||
assert.equal(report.variants[0].semantic.format, "ascii");
|
||||
assert.equal(report.variants[1].semantic.format, "binary_little_endian");
|
||||
for (const variant of report.variants) {
|
||||
assert.deepEqual(variant.semantic.elements, [{ name: "vertex", count: 4 }, { name: "face", count: 2 }]);
|
||||
const file = path.join(fixtureRoot, variant.file);
|
||||
assert.equal(fileHash(file), report.files.find((candidate) => candidate.name === variant.file).sha256);
|
||||
}
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m12-07g-ply-"));
|
||||
try {
|
||||
const regeneratedReport = path.join(temporary, "capability-report.json");
|
||||
const result = spawnSync(blender, ["-b", "--factory-startup", "--python", generator, "--", temporary, regeneratedReport], { cwd: root, encoding: "utf8", maxBuffer: 20 * 1024 * 1024 });
|
||||
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
assert.deepEqual(readJson(regeneratedReport), report, "PLY capability report is not deterministic");
|
||||
for (const file of report.files) assert.deepEqual(fs.readFileSync(path.join(temporary, file.name)), fs.readFileSync(path.join(fixtureRoot, file.name)), `${file.name} bytes are not deterministic`);
|
||||
}
|
||||
finally { fs.rmSync(temporary, { recursive: true, force: true }); }
|
||||
process.stdout.write(`ply-capability-fixtures-ok ascii=ascii binary=binary_little_endian vertices=4 faces=2 deterministic=true next=${manifest.nextTask}\n`);
|
||||
46
tools/web/check-ply-mapping-fixtures.mjs
Normal file
46
tools/web/check-ply-mapping-fixtures.mjs
Normal file
@@ -0,0 +1,46 @@
|
||||
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 { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const manifestPath = path.join(root, "tests/golden/M12-07H/manifest.json");
|
||||
const reportPath = path.join(root, "tests/golden/M12-07H/mapping-report.json");
|
||||
const fixtureRoot = path.join(root, "tests/files/web/m12_ply_mapping_v1");
|
||||
const generator = path.join(root, "tools/web/generate-ply-mapping-fixtures.py");
|
||||
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
|
||||
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
const fileHash = (file) => sha256(fs.readFileSync(file));
|
||||
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
|
||||
const manifest = readJson(manifestPath);
|
||||
const report = readJson(reportPath);
|
||||
assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M12-07H", parentTask: "M12-07G", nextTask: "M12-07I" });
|
||||
assert.deepEqual({ schemaVersion: report.schemaVersion, task: report.task, operation: report.operation, nextTask: report.nextTask }, { schemaVersion: 1, task: "M12-07H", operation: "PLY_VERTEX_FACE_COLOR_CUSTOM_MAPPING", nextTask: "M12-07I" });
|
||||
assert.deepEqual(report.variants.map((variant) => variant.semantic.vertexCount), [4, 4]);
|
||||
assert.deepEqual(report.variants.map((variant) => variant.semantic.faceCount), [2, 2]);
|
||||
assert.deepEqual(report.variants[0].semantic.vertices[0].color, [254, 0, 0, 255]);
|
||||
assert.deepEqual(report.variants[0].semantic.vertices[0].customProperties, { label: 1, temperature: 10 });
|
||||
assert.deepEqual(report.unknownProperty, { file: "unknown-property-ascii.ply", property: "unknown_values", expectedCode: "PLY_UNKNOWN_PROPERTY" });
|
||||
for (const artifact of Object.values(manifest.artifacts)) {
|
||||
const absolute = path.join(root, artifact.path);
|
||||
assert.ok(fs.existsSync(absolute), `missing artifact ${artifact.path}`);
|
||||
assert.equal(fileHash(absolute), artifact.sha256, `hash mismatch ${artifact.path}`);
|
||||
}
|
||||
for (const file of report.files) assert.equal(fileHash(path.join(fixtureRoot, file.name)), file.sha256, `fixture hash mismatch ${file.name}`);
|
||||
const inventory = readJson(path.join(root, "tests/golden/M12-05A/format-inventory.json"));
|
||||
for (const key of ["blenderVersion", "versionTuple", "buildDate", "buildTime", "buildHash", "buildBranch", "buildPlatform", "buildType", "binarySha256"]) assert.deepEqual(report.runtime[key], inventory.runtime[key], `runtime drift in ${key}`);
|
||||
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m12-07h-ply-"));
|
||||
try {
|
||||
const regeneratedReport = path.join(temporary, "mapping-report.json");
|
||||
const result = spawnSync(blender, ["-b", "--factory-startup", "--python", generator, "--", temporary, regeneratedReport], { cwd: root, encoding: "utf8", maxBuffer: 20 * 1024 * 1024 });
|
||||
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
assert.deepEqual(readJson(regeneratedReport), report, "PLY mapping report is not deterministic");
|
||||
for (const file of report.files) assert.deepEqual(fs.readFileSync(path.join(temporary, file.name)), fs.readFileSync(path.join(fixtureRoot, file.name)), `${file.name} bytes are not deterministic`);
|
||||
}
|
||||
finally { fs.rmSync(temporary, { recursive: true, force: true }); }
|
||||
process.stdout.write(`ply-mapping-fixtures-ok vertices=4 faces=2 colors=rgba custom=2 unknown=PLY_UNKNOWN_PROPERTY deterministic=true next=${manifest.nextTask}\n`);
|
||||
32
tools/web/check-ply-negative-fixtures.mjs
Normal file
32
tools/web/check-ply-negative-fixtures.mjs
Normal file
@@ -0,0 +1,32 @@
|
||||
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 { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const fixtureRoot = path.join(root, "tests/files/web/m12_ply_negative_v1");
|
||||
const reportPath = path.join(root, "tests/golden/M12-07I/negative-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M12-07I/manifest.json");
|
||||
const generator = path.join(root, "tools/web/generate-ply-negative-fixtures.mjs");
|
||||
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
const manifest = readJson(manifestPath);
|
||||
const report = readJson(reportPath);
|
||||
assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M12-07I", parentTask: "M12-07H", nextTask: "M12-07J" });
|
||||
assert.deepEqual({ schemaVersion: report.schemaVersion, task: report.task, operation: report.operation, nextTask: report.nextTask }, { schemaVersion: 1, task: "M12-07I", operation: "PLY_NEGATIVE_FORMAT_LIST_COUNT", nextTask: "M12-07J" });
|
||||
assert.deepEqual(report.cases.map((item) => item.expectedCode), ["PLY_FORMAT_UNSUPPORTED", "PLY_DATA_TRUNCATED", "PLY_IMPORT_BUDGET_EXCEEDED: vertex"]);
|
||||
for (const file of report.files) assert.equal(sha256(fs.readFileSync(path.join(fixtureRoot, file.name))), file.sha256, `hash mismatch ${file.name}`);
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(fs.readFileSync(path.join(root, artifact.path))), artifact.sha256, `artifact hash mismatch ${artifact.path}`);
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m12-07i-ply-"));
|
||||
try {
|
||||
const output = path.join(temporary, "negative-report.json");
|
||||
const result = spawnSync(process.execPath, [generator], { cwd: root, env: { ...process.env, M12_PLY_NEGATIVE_OUTPUT: temporary, M12_PLY_NEGATIVE_REPORT: output }, encoding: "utf8" });
|
||||
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
assert.deepEqual(readJson(output), report, "PLY negative report is not deterministic");
|
||||
for (const file of report.files) assert.deepEqual(fs.readFileSync(path.join(temporary, file.name)), fs.readFileSync(path.join(fixtureRoot, file.name)), `${file.name} bytes are not deterministic`);
|
||||
}
|
||||
finally { fs.rmSync(temporary, { recursive: true, force: true }); }
|
||||
process.stdout.write(`ply-negative-fixtures-ok cases=${report.cases.length} bigEndian=PLY_FORMAT_UNSUPPORTED malformed=PLY_DATA_TRUNCATED oversized=PLY_IMPORT_BUDGET_EXCEEDED deterministic=true next=${report.nextTask}\n`);
|
||||
56
tools/web/check-ply-web-roundtrip.py
Normal file
56
tools/web/check-ply-web-roundtrip.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Import a browser-produced PLY in pinned Blender 5.2 and emit mapped fields."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
def sha256_file(path):
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def attr_values(attribute):
|
||||
values = []
|
||||
for item in attribute.data:
|
||||
if attribute.data_type == "FLOAT_COLOR": values.append([float(value) for value in item.color])
|
||||
elif attribute.data_type == "FLOAT_VECTOR": values.append([float(value) for value in item.vector])
|
||||
elif attribute.data_type == "FLOAT": values.append(float(item.value))
|
||||
elif attribute.data_type == "INT": values.append(int(item.value))
|
||||
return values
|
||||
|
||||
|
||||
def main(ply_path, report_path):
|
||||
ply_path = Path(ply_path).resolve(); report_path = Path(report_path).resolve()
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
result = bpy.ops.wm.ply_import(filepath=str(ply_path), import_colors="SRGB", import_attributes=True, forward_axis="NEGATIVE_Z", up_axis="Y", global_scale=1.0)
|
||||
objects = [obj for obj in bpy.context.scene.objects if obj.type == "MESH"]
|
||||
if "FINISHED" not in result or not objects: raise RuntimeError("Blender PLY import did not produce a mesh")
|
||||
obj = objects[0]; mesh = obj.data; mesh.calc_loop_triangles()
|
||||
wanted = [attribute for attribute in mesh.attributes if attribute.name in {"Col", "temperature", "label"}]
|
||||
report = {
|
||||
"schemaVersion": 1,
|
||||
"operation": "DESKTOP_IMPORT_WEB_PLY",
|
||||
"sourceSha256": sha256_file(ply_path),
|
||||
"sourceBytes": ply_path.stat().st_size,
|
||||
"objectCount": len(objects),
|
||||
"vertexCount": len(mesh.vertices),
|
||||
"polygonCount": len(mesh.polygons),
|
||||
"triangleCount": len(mesh.loop_triangles),
|
||||
"positions": [[float(value) for value in vertex.co] for vertex in mesh.vertices],
|
||||
"attributes": [{"name": attribute.name, "dataType": attribute.data_type, "domain": attribute.domain, "values": attr_values(attribute)} for attribute in wanted],
|
||||
}
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True); report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
print("ply-web-roundtrip-desktop-imported vertices=%s faces=%s attrs=%s" % (report["vertexCount"], report["triangleCount"], len(report["attributes"])))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else []
|
||||
if len(args) != 2: raise SystemExit("usage: blender --background --python check-ply-web-roundtrip.py -- PLY REPORT")
|
||||
main(args[0], args[1])
|
||||
188
tools/web/check-probe-identity.mjs
Normal file
188
tools/web/check-probe-identity.mjs
Normal file
@@ -0,0 +1,188 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const distRoot = path.join(root, "web/dist");
|
||||
const reportPath = path.join(root, "tests/golden/M14-01D/probe-identity-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M14-01D/manifest.json");
|
||||
const digest = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
||||
const fileDigest = (file) => digest(fs.readFileSync(file));
|
||||
const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/");
|
||||
const assetsRoot = path.join(distRoot, "assets");
|
||||
assert.ok(fs.existsSync(assetsRoot), "production assets are missing; run npm --prefix web run build first");
|
||||
const workerName = fs.readdirSync(assetsRoot).find((name) => /^storage\.worker-[\w-]+\.js$/u.test(name));
|
||||
const wasmName = fs.readdirSync(assetsRoot).find((name) => /^web_engine-[\w-]+\.wasm$/u.test(name));
|
||||
assert.ok(workerName && wasmName, "production worker/WASM assets are missing");
|
||||
|
||||
const mime = new Map([
|
||||
[".html", "text/html; charset=utf-8"],
|
||||
[".js", "text/javascript; charset=utf-8"],
|
||||
[".css", "text/css; charset=utf-8"],
|
||||
[".json", "application/json"],
|
||||
[".wasm", "application/wasm"],
|
||||
]);
|
||||
const server = http.createServer((request, response) => {
|
||||
const pathname = decodeURIComponent(new URL(request.url ?? "/", "http://127.0.0.1").pathname);
|
||||
const relativePath = pathname === "/" ? "index.html" : pathname.replace(/^\//u, "");
|
||||
const file = path.resolve(distRoot, relativePath);
|
||||
if (!file.startsWith(`${distRoot}${path.sep}`) || !fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
||||
response.writeHead(404);
|
||||
response.end("not found");
|
||||
return;
|
||||
}
|
||||
response.statusCode = 200;
|
||||
response.setHeader("Content-Type", mime.get(path.extname(file)) ?? "application/octet-stream");
|
||||
response.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
||||
response.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
|
||||
response.setHeader("Cross-Origin-Resource-Policy", "same-origin");
|
||||
response.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'; font-src 'self'; img-src 'self'; media-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'");
|
||||
fs.createReadStream(file).pipe(response);
|
||||
});
|
||||
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address === "object");
|
||||
const origin = `http://127.0.0.1:${address.port}`;
|
||||
let browser;
|
||||
try {
|
||||
const playwright = await import(pathToFileURL(path.join(root, "web/node_modules/playwright/index.mjs")).href);
|
||||
browser = await playwright.chromium.launch({ headless: true });
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${origin}/`, { waitUntil: "load" });
|
||||
const probe = await page.evaluate(async ({ workerPath, wasmPath }) => {
|
||||
const run = async (name, operation) => {
|
||||
try {
|
||||
return { name, ...await operation() };
|
||||
} catch (error) {
|
||||
return {
|
||||
name,
|
||||
status: "BLOCKED",
|
||||
code: error instanceof Error ? error.name : "PROBE_FAILED",
|
||||
detail: error instanceof Error ? error.message.slice(0, 200) : String(error),
|
||||
};
|
||||
}
|
||||
};
|
||||
const wasm = await run("wasm", async () => {
|
||||
const response = await fetch(wasmPath);
|
||||
if (!response.ok) throw new Error(`HTTP_${response.status}`);
|
||||
const bytes = await response.arrayBuffer();
|
||||
await WebAssembly.compile(bytes);
|
||||
return { status: "PASS", code: "WASM_READY", bytes: bytes.byteLength };
|
||||
});
|
||||
const worker = await run("worker", async () => {
|
||||
await new Promise((resolve, reject) => {
|
||||
const value = new Worker(workerPath, { type: "module" });
|
||||
const timer = setTimeout(() => { value.terminate(); resolve(); }, 500);
|
||||
value.onerror = (event) => { clearTimeout(timer); value.terminate(); reject(new Error(event.message || "WORKER_LOAD_FAILED")); };
|
||||
});
|
||||
return { status: "PASS", code: "WORKER_READY" };
|
||||
});
|
||||
const webgl2 = await run("webgl2", async () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const gl = canvas.getContext("webgl2");
|
||||
if (!gl) return { status: "BLOCKED", code: "WEBGL2_UNAVAILABLE" };
|
||||
const debug = gl.getExtension("WEBGL_debug_renderer_info");
|
||||
return {
|
||||
status: "PASS",
|
||||
code: "WEBGL2_READY",
|
||||
vendor: debug ? gl.getParameter(debug.UNMASKED_VENDOR_WEBGL) : "REDACTED",
|
||||
renderer: debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) : "REDACTED",
|
||||
version: gl.getParameter(gl.VERSION),
|
||||
};
|
||||
});
|
||||
const webgpu = await run("webgpu", async () => {
|
||||
if (!("gpu" in navigator)) return { status: "BLOCKED", code: "WEBGPU_UNAVAILABLE" };
|
||||
const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" });
|
||||
if (!adapter) return { status: "BLOCKED", code: "WEBGPU_ADAPTER_UNAVAILABLE" };
|
||||
const info = adapter.info ?? {};
|
||||
return {
|
||||
status: "PASS",
|
||||
code: "WEBGPU_READY",
|
||||
vendor: info.vendor ?? "REDACTED",
|
||||
architecture: info.architecture ?? "REDACTED",
|
||||
device: info.device ?? "REDACTED",
|
||||
description: info.description ?? "REDACTED",
|
||||
isFallbackAdapter: Boolean(adapter.isFallbackAdapter),
|
||||
};
|
||||
});
|
||||
return {
|
||||
userAgent: navigator.userAgent,
|
||||
platform: navigator.platform,
|
||||
language: navigator.language,
|
||||
hardwareConcurrency: navigator.hardwareConcurrency,
|
||||
deviceMemory: typeof navigator.deviceMemory === "number" ? navigator.deviceMemory : null,
|
||||
crossOriginIsolated,
|
||||
capabilities: [wasm, worker, webgl2, webgpu],
|
||||
};
|
||||
}, { workerPath: `/assets/${workerName}`, wasmPath: `/assets/${wasmName}` });
|
||||
|
||||
const capabilities = Object.fromEntries(probe.capabilities.map((item) => [item.name, item]));
|
||||
const reportWithoutIdentity = {
|
||||
schemaVersion: 1,
|
||||
task: "M14-01D",
|
||||
operation: "PROBE_IDENTITY_GPU_OS_ADAPTER",
|
||||
runtime: "PLAYWRIGHT_CHROMIUM",
|
||||
browser: {
|
||||
version: await browser.version(),
|
||||
executablePath: playwright.chromium.executablePath(),
|
||||
userAgent: probe.userAgent,
|
||||
platform: probe.platform,
|
||||
language: probe.language,
|
||||
hardwareConcurrency: probe.hardwareConcurrency,
|
||||
deviceMemory: probe.deviceMemory,
|
||||
},
|
||||
os: {
|
||||
platform: os.platform(),
|
||||
release: os.release(),
|
||||
version: os.version(),
|
||||
arch: os.arch(),
|
||||
machine: os.machine(),
|
||||
cpus: os.cpus().length,
|
||||
},
|
||||
adapter: {
|
||||
webgl2: capabilities.webgl2,
|
||||
webgpu: capabilities.webgpu,
|
||||
},
|
||||
isolation: { crossOriginIsolated: probe.crossOriginIsolated },
|
||||
assets: {
|
||||
worker: { path: relative(path.join(assetsRoot, workerName)), sha256: fileDigest(path.join(assetsRoot, workerName)) },
|
||||
wasm: { path: relative(path.join(assetsRoot, wasmName)), sha256: fileDigest(path.join(assetsRoot, wasmName)) },
|
||||
},
|
||||
supportRule: "IDENTITY_IS_OBSERVED_ONLY; PASS_ONLY_WHEN_PROBED; BLOCKED_OR_UNAVAILABLE_DOES_NOT_CLAIM_SUPPORT",
|
||||
execution: "DISABLED",
|
||||
nextTask: "M14-01E",
|
||||
};
|
||||
const identityInput = JSON.stringify(reportWithoutIdentity);
|
||||
const report = { ...reportWithoutIdentity, identitySha256: digest(identityInput) };
|
||||
if (process.env.UPDATE_M14_01D_REPORT === "1") {
|
||||
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
||||
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
if (process.env.UPDATE_M14_01D_REPORT === "1") {
|
||||
const artifactPaths = {
|
||||
parentManifest: path.join(root, "tests/golden/M14-01C/manifest.json"),
|
||||
checker: path.join(root, "tools/web/check-probe-identity.mjs"),
|
||||
package: path.join(root, "web/package.json"),
|
||||
engineManifest: path.join(root, "web/app/public/engine-manifest.json"),
|
||||
report: reportPath,
|
||||
};
|
||||
const artifacts = Object.fromEntries(Object.entries(artifactPaths).map(([name, file]) => [name, { path: relative(file), sha256: fileDigest(file) }]));
|
||||
fs.writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, task: "M14-01D", parentTask: "M14-01C", enablingTask: false, parityStateChange: false, runtime: "PLAYWRIGHT_CHROMIUM", operation: "PROBE_IDENTITY_GPU_OS_ADAPTER", artifacts, nextTask: "M14-01E" }, null, 2)}\n`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M14-01D", parentTask: "M14-01C", nextTask: "M14-01E" });
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileDigest(path.join(root, artifact.path)), artifact.sha256, artifact.path);
|
||||
assert.equal(report.identitySha256, digest(JSON.stringify(reportWithoutIdentity)));
|
||||
const summary = [capabilities.webgl2, capabilities.webgpu].map((item) => `${item.name}=${item.status}`).join(",");
|
||||
process.stdout.write(`probe-identity-ok version=${report.browser.version} os=${report.os.platform}/${report.os.arch} ${summary} identity=${report.identitySha256} execution=DISABLED next=${manifest.nextTask}\n`);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
53
tools/web/check-script-audit-integrity.mjs
Normal file
53
tools/web/check-script-audit-integrity.mjs
Normal file
@@ -0,0 +1,53 @@
|
||||
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 { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "../../web/node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const reportPath = path.join(root, "tests/golden/M13-05H/script-audit-integrity-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M13-05H/manifest.json");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m13-05h-audit-"));
|
||||
const hashFile = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
const transpile = (name) => {
|
||||
const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8");
|
||||
const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText
|
||||
.replace('require("./asset-path")', 'require("./asset-path.cjs")')
|
||||
.replace('require("./capability-gates")', 'require("./capability-gates.cjs")');
|
||||
fs.writeFileSync(path.join(temporary, `${name}.cjs`), output);
|
||||
};
|
||||
const errorCode = async (operation) => { try { await operation(); return "ACCEPTED"; } catch (error) { return error instanceof Error ? error.message.split(":", 1)[0] : String(error); } };
|
||||
|
||||
try {
|
||||
for (const name of ["asset-path", "capability-gates", "scripting-platform"]) transpile(name);
|
||||
const protocol = createRequire(import.meta.url)(path.join(temporary, "scripting-platform.cjs"));
|
||||
const digest = "a".repeat(64);
|
||||
const manifest = { schemaVersion: 1, scripts: [{ id: "script:audit", name: "Audit", entryPath: "scripts/audit.py", sourceByteLength: 32, sourceSha256: digest, publisher: "local", signature: "b".repeat(128), keyId: "key:local", permissions: ["READ_MAIN"], dependencies: [], module: false, cpuMs: 1000, memoryBytes: 1024, wallMs: 5000, network: false, autorun: false, driverExpressions: false, addonInstall: false }] };
|
||||
const first = await protocol.createScriptExecutionAudit(manifest, "script:audit", new Set(), { requestId: "audit:first", requestedAt: "2026-08-19T00:00:00.000Z" });
|
||||
const second = await protocol.createScriptExecutionAudit(manifest, "script:audit", new Set(), { requestId: "audit:second", requestedAt: "2026-08-19T00:00:01.000Z" });
|
||||
const firstLog = await protocol.appendScriptExecutionAudit({ schemaVersion: 1, entries: [] }, first);
|
||||
const log = await protocol.appendScriptExecutionAudit(firstLog, second);
|
||||
const replay = await errorCode(() => protocol.appendScriptExecutionAudit(log, first));
|
||||
const earlier = await protocol.createScriptExecutionAudit(manifest, "script:audit", new Set(), { requestId: "audit:earlier", requestedAt: "2026-08-18T23:59:59.000Z" });
|
||||
const timeOrder = await errorCode(() => protocol.appendScriptExecutionAudit(log, earlier));
|
||||
const sequence = await errorCode(() => protocol.parseScriptExecutionAuditLog({ ...log, entries: [{ ...log.entries[0], sequence: 2 }, log.entries[1]] }));
|
||||
const entryTamper = await errorCode(() => protocol.parseScriptExecutionAuditLog({ ...log, entries: [{ ...log.entries[0], entrySha256: "c".repeat(64) }, log.entries[1]] }));
|
||||
const chainTamper = await errorCode(() => protocol.parseScriptExecutionAuditLog({ ...log, entries: [log.entries[0], { ...log.entries[1], previousEntrySha256: "d".repeat(64) }] }));
|
||||
assert.equal(replay, "SCRIPT_MANIFEST_INVALID");
|
||||
assert.equal(timeOrder, "SCRIPT_MANIFEST_INVALID");
|
||||
assert.equal(sequence, "SCRIPT_MANIFEST_INVALID");
|
||||
assert.equal(entryTamper, "SCRIPT_MANIFEST_INVALID");
|
||||
assert.equal(chainTamper, "SCRIPT_MANIFEST_INVALID");
|
||||
const report = { schemaVersion: 1, task: "M13-05H", operation: "SCRIPT_AUDIT_INTEGRITY", runtime: "NODE_PRODUCTION_PROTOCOL", log: { entryCount: log.entries.length, sequences: log.entries.map((entry) => entry.sequence), requestIds: log.entries.map((entry) => entry.audit.requestId), firstPreviousEntrySha256: log.entries[0].previousEntrySha256, secondPreviousEntrySha256: log.entries[1].previousEntrySha256, firstEntrySha256: log.entries[0].entrySha256, secondEntrySha256: log.entries[1].entrySha256, strictlyIncreasingTime: true, uniqueRequestIds: true, continuousHashChain: true }, negative: { replay, timeOrder, sequence, entryTamper, chainTamper }, execution: "DISABLED", nextTask: "M14-01A" };
|
||||
if (process.env.UPDATE_M13_05H_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); }
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report);
|
||||
const manifestReceipt = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
assert.deepEqual({ schemaVersion: manifestReceipt.schemaVersion, task: manifestReceipt.task, parentTask: manifestReceipt.parentTask, nextTask: manifestReceipt.nextTask }, { schemaVersion: 1, task: "M13-05H", parentTask: "M13-05G", nextTask: "M14-01A" });
|
||||
for (const artifact of Object.values(manifestReceipt.artifacts)) assert.equal(hashFile(path.join(root, artifact.path)), artifact.sha256, artifact.path);
|
||||
process.stdout.write(`script-audit-integrity-ok entries=2 sequence=1,2 requestIds=unique time=ordered chain=true replay=${replay} tamper=3 execution=DISABLED next=${manifestReceipt.nextTask}\n`);
|
||||
} finally {
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
39
tools/web/check-script-entry-inventory.mjs
Normal file
39
tools/web/check-script-entry-inventory.mjs
Normal file
@@ -0,0 +1,39 @@
|
||||
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 { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const reportPath = path.join(root, "tests/golden/M13-01A/entry-inventory.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M13-01A/manifest.json");
|
||||
const fixtureRoot = path.join(root, "tests/files/web/m13_script_entry_v1");
|
||||
const generator = path.join(root, "tools/web/generate-script-entry-inventory.py");
|
||||
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
|
||||
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
const report = readJson(reportPath);
|
||||
const manifest = readJson(manifestPath);
|
||||
assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-01A", parentTask: "M12-07J", nextTask: "M13-01B" });
|
||||
assert.deepEqual({ schemaVersion: report.schemaVersion, task: report.task, operation: report.operation, nextTask: report.nextTask }, { schemaVersion: 1, task: "M13-01A", operation: "BLENDER_SCRIPT_ENTRY_INVENTORY", nextTask: "M13-01B" });
|
||||
assert.deepEqual(report.executionPolicy, { text: "READ_METADATA_ONLY", pythonConsole: "DENY", autorun: "DENY", driverExpression: "DENY", handler: "DENY", addon: "DENY" });
|
||||
assert.equal(report.inventory.text.count, 3); assert.deepEqual(report.inventory.autorun.moduleTextNames, ["ModuleAutorun.py"]); assert.equal(report.inventory.driverExpressions.count, 1); assert.equal(report.inventory.pythonConsole.available, true); assert.equal(report.inventory.addons.defaultExecution, "DENY");
|
||||
const fixturePath = path.join(fixtureRoot, report.fixture.name);
|
||||
assert.equal(sha256(fs.readFileSync(fixturePath)), report.fixture.sha256);
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(fs.readFileSync(path.join(root, artifact.path))), artifact.sha256, `artifact hash mismatch ${artifact.path}`);
|
||||
const inventory = readJson(path.join(root, "tests/golden/M12-05A/format-inventory.json"));
|
||||
for (const key of ["blenderVersion", "versionTuple", "buildDate", "buildTime", "buildHash", "buildBranch", "buildPlatform", "buildType", "binarySha256"]) assert.deepEqual(report.runtime[key], inventory.runtime[key], `runtime drift in ${key}`);
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m13-01a-script-"));
|
||||
try {
|
||||
const regenerated = path.join(temporary, "entry-inventory.json");
|
||||
const result = spawnSync(blender, ["-b", "--factory-startup", "--python", generator, "--", temporary, regenerated], { cwd: root, encoding: "utf8", maxBuffer: 20 * 1024 * 1024 });
|
||||
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
const regeneratedReport = readJson(regenerated);
|
||||
assert.deepEqual({ ...regeneratedReport, fixture: { ...regeneratedReport.fixture, sha256: "<runtime-save-hash>" } }, { ...report, fixture: { ...report.fixture, sha256: "<runtime-save-hash>" } }, "script entry inventory is not deterministic");
|
||||
assert.equal(regeneratedReport.fixture.byteLength, report.fixture.byteLength);
|
||||
assert.equal(fs.statSync(path.join(temporary, report.fixture.name)).size, report.fixture.byteLength);
|
||||
}
|
||||
finally { fs.rmSync(temporary, { recursive: true, force: true }); }
|
||||
process.stdout.write(`script-entry-inventory-ok texts=3 console=3 autorun=1 drivers=1 handlers=${report.inventory.handlers.groups.length} addonOps=4 deterministic=true next=${report.nextTask}\n`);
|
||||
46
tools/web/check-script-host-call.mjs
Normal file
46
tools/web/check-script-host-call.mjs
Normal file
@@ -0,0 +1,46 @@
|
||||
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 { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "../../web/node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const reportPath = path.join(root, "tests/golden/M13-03C/host-call-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M13-03C/manifest.json");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m13-03c-host-call-"));
|
||||
const hashFile = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
for (const name of ["asset-path", "capability-gates", "scripting-platform"]) {
|
||||
const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8");
|
||||
const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText
|
||||
.replace('require("./asset-path")', 'require("./asset-path.cjs")').replace('require("./capability-gates")', 'require("./capability-gates.cjs")');
|
||||
fs.writeFileSync(path.join(temporary, `${name}.cjs`), output);
|
||||
}
|
||||
const protocol = createRequire(import.meta.url)(path.join(temporary, "scripting-platform.cjs"));
|
||||
try {
|
||||
const digest = "a".repeat(64); const permissions = new Set(protocol.SCRIPT_PERMISSIONS);
|
||||
const call = (name, parameters) => ({ schemaVersion: 1, requestId: `host:${name.toLowerCase()}`, scriptId: "clean", call: name, permission: name, parameters });
|
||||
const accepted = [
|
||||
protocol.parseScriptHostCall(call("READ_MAIN", { revision: 3 }), permissions),
|
||||
protocol.parseScriptHostCall(call("READ_ASSET", { path: "//assets/model.bin", expectedSha256: digest }), permissions),
|
||||
protocol.parseScriptHostCall(call("WRITE_MAIN", { revision: 3, operation: "object.transform", payload: { objectId: "obj:1", x: 1 } }), permissions),
|
||||
protocol.parseScriptHostCall(call("WRITE_ASSET", { path: "assets/out.bin", byteLength: 4, sha256: digest }), permissions),
|
||||
protocol.parseScriptHostCall(call("SUBMIT_SERVER_JOB", { inputBlendSha256: digest, settingsSha256: digest }), permissions),
|
||||
];
|
||||
const blocked = {};
|
||||
for (const [name, input] of [["unknown", call("EXECUTE", {})], ["permission", { ...call("READ_MAIN", { revision: 3 }), permission: "WRITE_MAIN" }], ["fields", call("READ_MAIN", { revision: 3, extra: true })], ["path", call("READ_ASSET", { path: "../escape", expectedSha256: digest })]]) {
|
||||
try { protocol.parseScriptHostCall(input, permissions); blocked[name] = "ACCEPTED"; } catch (error) { blocked[name] = error instanceof Error ? error.message.split(":", 1)[0] : String(error); }
|
||||
}
|
||||
assert.deepEqual(accepted.map((item) => item.call), ["READ_MAIN", "READ_ASSET", "WRITE_MAIN", "WRITE_ASSET", "SUBMIT_SERVER_JOB"]);
|
||||
assert.ok(accepted.every((item) => item.execution === "DISABLED"));
|
||||
assert.deepEqual(blocked, { unknown: "SCRIPT_POLICY_DENIED", permission: "SCRIPT_POLICY_DENIED", fields: "SCRIPT_MANIFEST_INVALID", path: "SCRIPT_MANIFEST_INVALID" });
|
||||
const report = { schemaVersion: 1, task: "M13-03C", operation: "SCRIPT_HOST_CALL_ALLOWLIST", acceptedCalls: accepted.map((item) => item.call), blocked, structuredParameters: true, execution: "DISABLED", invariants: { allowlistOnly: true, permissionBound: true, unknownFieldsRejected: true, projectPathsNormalized: true }, nextTask: "M13-03D" };
|
||||
if (process.env.UPDATE_M13_03C_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n"); }
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-03C", parentTask: "M13-03B", nextTask: "M13-03D" });
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`);
|
||||
process.stdout.write(`script-host-call-ok accepted=${accepted.length} blocked=${Object.keys(blocked).length} structured=true execution=DISABLED next=${manifest.nextTask}\n`);
|
||||
} finally { fs.rmSync(temporary, { recursive: true, force: true }); }
|
||||
76
tools/web/check-script-manifest-budgets.mjs
Normal file
76
tools/web/check-script-manifest-budgets.mjs
Normal file
@@ -0,0 +1,76 @@
|
||||
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 { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "../../web/node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const reportPath = path.join(root, "tests/golden/M13-02A/manifest-budget-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M13-02A/manifest.json");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m13-02a-manifest-"));
|
||||
const digest = "a".repeat(64);
|
||||
const signature = "b".repeat(128);
|
||||
const hashFile = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
const script = (id, overrides = {}) => ({
|
||||
id, name: id, entryPath: `scripts/${id}.py`, sourceByteLength: 128, sourceSha256: digest,
|
||||
publisher: "local", signature, keyId: "key:local", permissions: ["READ_MAIN"], dependencies: [],
|
||||
module: false, cpuMs: 1000, memoryBytes: 1024 * 1024, wallMs: 5000, network: false,
|
||||
autorun: false, driverExpressions: false, addonInstall: false, ...overrides,
|
||||
});
|
||||
const manifest = (scripts = [script("clean")]) => ({ schemaVersion: 1, scripts });
|
||||
const transpile = (name) => {
|
||||
const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8");
|
||||
const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText
|
||||
.replace('require("./asset-path")', 'require("./asset-path.cjs")')
|
||||
.replace('require("./capability-gates")', 'require("./capability-gates.cjs")');
|
||||
fs.writeFileSync(path.join(temporary, `${name}.cjs`), output);
|
||||
};
|
||||
for (const name of ["asset-path", "capability-gates", "scripting-platform"]) transpile(name);
|
||||
const require = createRequire(import.meta.url);
|
||||
const protocol = require(path.join(temporary, "scripting-platform.cjs"));
|
||||
const denied = (value) => {
|
||||
try { protocol.parseScriptingManifest(value); return "ACCEPTED"; }
|
||||
catch (error) { return error instanceof Error ? error.message : String(error); }
|
||||
};
|
||||
try {
|
||||
const valid = protocol.parseScriptingManifest(manifest([
|
||||
script("base", { entryPath: "//scripts/../scripts/base.py" }),
|
||||
script("clean", { dependencies: [{ id: "base", sourceSha256: digest, sourcePath: "//deps/base.py" }] }),
|
||||
]));
|
||||
const cases = {
|
||||
count: denied(manifest(Array.from({ length: protocol.SCRIPTING_BUDGET.maxScripts + 1 }, (_, index) => script(`script-${index}`)))),
|
||||
totalBytes: denied(manifest([script("large", { sourceByteLength: protocol.SCRIPTING_BUDGET.maxSourceBytes }), script("overflow", { sourceByteLength: 1 })])),
|
||||
module: denied(manifest([script("module", { module: true })])),
|
||||
path: denied(manifest([script("escape", { entryPath: "../escape.py" })])),
|
||||
dependency: denied(manifest([script("duplicate", { dependencies: [{ id: "base", sourceSha256: digest, sourcePath: "deps/a.py" }, { id: "base", sourceSha256: digest, sourcePath: "deps/b.py" }] }), script("base")])),
|
||||
permission: denied(manifest([script("unknown", { permissions: ["EXECUTE"] })])),
|
||||
};
|
||||
assert.equal(valid.scripts[0].entryPath, "scripts/base.py");
|
||||
assert.equal(valid.scripts[1].dependencies[0].sourcePath, "deps/base.py");
|
||||
assert.equal(valid.scripts.reduce((total, item) => total + item.sourceByteLength, 0), 256);
|
||||
assert.match(cases.count, /SCRIPT_BUDGET_EXCEEDED/);
|
||||
assert.match(cases.totalBytes, /SCRIPT_BUDGET_EXCEEDED/);
|
||||
assert.match(cases.module, /SCRIPT_POLICY_DENIED/);
|
||||
assert.match(cases.path, /SCRIPT_MANIFEST_INVALID/);
|
||||
assert.match(cases.dependency, /SCRIPT_MANIFEST_INVALID/);
|
||||
assert.match(cases.permission, /SCRIPT_POLICY_DENIED/);
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
task: "M13-02A",
|
||||
operation: "SCRIPT_MANIFEST_BUDGETS",
|
||||
accepted: { scriptCount: valid.scripts.length, canonicalEntryPath: valid.scripts[0].entryPath, canonicalDependencyPath: valid.scripts[1].dependencies[0].sourcePath, totalSourceBytes: 256, module: false },
|
||||
denied: Object.fromEntries(Object.entries(cases).map(([name, message]) => [name, { status: "BLOCKED", code: message.split(":", 1)[0] }])),
|
||||
budgets: { maxScripts: protocol.SCRIPTING_BUDGET.maxScripts, maxSourceBytes: protocol.SCRIPTING_BUDGET.maxSourceBytes, maxDependencies: protocol.SCRIPTING_BUDGET.maxDependencies, maxPermissions: protocol.SCRIPTING_BUDGET.maxPermissions },
|
||||
nextTask: "M13-02B",
|
||||
};
|
||||
if (process.env.UPDATE_M13_02A_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n"); }
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report);
|
||||
const manifestValue = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
assert.deepEqual({ schemaVersion: manifestValue.schemaVersion, task: manifestValue.task, parentTask: manifestValue.parentTask, nextTask: manifestValue.nextTask }, { schemaVersion: 1, task: "M13-02A", parentTask: "M13-01F", nextTask: "M13-02B" });
|
||||
for (const artifact of Object.values(manifestValue.artifacts)) assert.equal(hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`);
|
||||
process.stdout.write(`script-manifest-budgets-ok accepted=${valid.scripts.length} totalSourceBytes=256 blocked=${Object.keys(cases).length} deterministic=true next=${manifestValue.nextTask}\n`);
|
||||
}
|
||||
finally { fs.rmSync(temporary, { recursive: true, force: true }); }
|
||||
67
tools/web/check-script-manifest-canonical.mjs
Normal file
67
tools/web/check-script-manifest-canonical.mjs
Normal file
@@ -0,0 +1,67 @@
|
||||
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 { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "../../web/node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const reportPath = path.join(root, "tests/golden/M13-02B/manifest-canonical-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M13-02B/manifest.json");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m13-02b-canonical-"));
|
||||
const digest = "a".repeat(64);
|
||||
const signature = "b".repeat(128);
|
||||
const hashFile = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
const script = (id, overrides = {}) => ({ id, name: id, entryPath: `scripts/${id}.py`, sourceByteLength: 128, sourceSha256: digest, publisher: "local", signature, keyId: "key:local", permissions: ["READ_MAIN"], dependencies: [], module: false, cpuMs: 1000, memoryBytes: 1024 * 1024, wallMs: 5000, network: false, autorun: false, driverExpressions: false, addonInstall: false, ...overrides });
|
||||
const first = {
|
||||
schemaVersion: 1,
|
||||
scripts: [
|
||||
script("zeta", { permissions: ["WRITE_ASSET", "READ_MAIN"], dependencies: [{ id: "alpha", sourceSha256: digest, sourcePath: "deps/alpha.py" }, { id: "beta", sourceSha256: digest, sourcePath: "deps/beta.py" }] }),
|
||||
script("alpha", { permissions: ["SUBMIT_SERVER_JOB", "READ_ASSET"] }),
|
||||
script("beta"),
|
||||
],
|
||||
};
|
||||
const second = {
|
||||
schemaVersion: 1,
|
||||
scripts: [
|
||||
{ ...first.scripts[1], permissions: [...first.scripts[1].permissions].reverse(), ignored: "removed" },
|
||||
{ ...first.scripts[0], permissions: [...first.scripts[0].permissions].reverse(), dependencies: [...first.scripts[0].dependencies].reverse() },
|
||||
first.scripts[2],
|
||||
],
|
||||
};
|
||||
for (const name of ["asset-path", "capability-gates", "scripting-platform"]) {
|
||||
const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8");
|
||||
const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText
|
||||
.replace('require("./asset-path")', 'require("./asset-path.cjs")')
|
||||
.replace('require("./capability-gates")', 'require("./capability-gates.cjs")');
|
||||
fs.writeFileSync(path.join(temporary, `${name}.cjs`), output);
|
||||
}
|
||||
const require = createRequire(import.meta.url);
|
||||
const protocol = require(path.join(temporary, "scripting-platform.cjs"));
|
||||
try {
|
||||
const firstSerialized = protocol.serializeScriptingManifest(first);
|
||||
const secondSerialized = protocol.serializeScriptingManifest(second);
|
||||
const canonical = protocol.canonicalizeScriptingManifest(second);
|
||||
assert.equal(firstSerialized, secondSerialized);
|
||||
assert.deepEqual({ firstId: canonical.scripts[0].id, firstPermission: canonical.scripts[0].permissions[0], dependencyOrder: canonical.scripts[2].dependencies.map((dependency) => dependency.id), unknownDropped: !("ignored" in canonical.scripts[0]) }, { firstId: "alpha", firstPermission: "READ_ASSET", dependencyOrder: ["alpha", "beta"], unknownDropped: true });
|
||||
assert.notEqual(firstSerialized, protocol.serializeScriptingManifest({ schemaVersion: 1, scripts: [script("alpha", { sourceByteLength: 129 })] }));
|
||||
assert.throws(() => protocol.serializeScriptingManifest({ ...first, schemaVersion: 2 }), /PROTOCOL_MISMATCH/);
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
task: "M13-02B",
|
||||
operation: "SCRIPT_MANIFEST_CANONICAL_SERIALIZATION",
|
||||
equalOrderVariants: true,
|
||||
canonical: { scriptOrder: canonical.scripts.map((script) => script.id), alphaPermissions: canonical.scripts[0].permissions, zetaDependencies: canonical.scripts[2].dependencies.map((dependency) => dependency.id), unknownFieldsDropped: true },
|
||||
signatureInput: { schemaVersion: 1, byteLength: Buffer.byteLength(firstSerialized), sha256: crypto.createHash("sha256").update(firstSerialized).digest("hex"), sourceByteLengthMutationChangesInput: true, schemaMutationRejected: true },
|
||||
nextTask: "M13-02C",
|
||||
};
|
||||
if (process.env.UPDATE_M13_02B_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n"); }
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report);
|
||||
const manifestValue = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
assert.deepEqual({ schemaVersion: manifestValue.schemaVersion, task: manifestValue.task, parentTask: manifestValue.parentTask, nextTask: manifestValue.nextTask }, { schemaVersion: 1, task: "M13-02B", parentTask: "M13-02A", nextTask: "M13-02C" });
|
||||
for (const artifact of Object.values(manifestValue.artifacts)) assert.equal(hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`);
|
||||
process.stdout.write(`script-manifest-canonical-ok equal=true bytes=${Buffer.byteLength(firstSerialized)} unknownDropped=true schemaMutation=blocked next=${manifestValue.nextTask}\n`);
|
||||
}
|
||||
finally { fs.rmSync(temporary, { recursive: true, force: true }); }
|
||||
17
tools/web/check-script-open-metadata.mjs
Normal file
17
tools/web/check-script-open-metadata.mjs
Normal file
@@ -0,0 +1,17 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const fixturePath = path.join(root, "tests/files/web/script_scene.blend");
|
||||
const fixture = fs.readFileSync(fixturePath);
|
||||
const fixtureSha256 = crypto.createHash("sha256").update(fixture).digest("hex");
|
||||
const result = spawnSync(process.execPath, [path.join(root, "tools/web/check-script-main-reader.mjs")], { cwd: root, encoding: "utf8", maxBuffer: 20 * 1024 * 1024 });
|
||||
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
assert.match(result.stdout, /script-main-reader-ok full-source=passed sha256=passed autorun-default-deny=passed/);
|
||||
const report = { schemaVersion: 1, task: "M13-01B", operation: "BLEND_OPEN_SCRIPT_METADATA_ONLY", fixture: { path: "tests/files/web/script_scene.blend", byteLength: fixture.byteLength, sha256: fixtureSha256 }, sources: [{ name: "ExternalProject.py", executionStatus: "BLOCKED", readOnly: true }, { name: "InternalSafe.py", executionStatus: "BLOCKED", readOnly: true }, { name: "ModuleAutorun.py", executionStatus: "BLOCKED", readOnly: true, moduleAutorunRequested: true, errorCode: "SCRIPT_POLICY_DENIED" }], execution: "DENY", nextTask: "M13-01C" };
|
||||
const reportPath = path.join(root, "tests/golden/M13-01B/open-metadata-report.json"); fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n");
|
||||
process.stdout.write("script-open-metadata-ok blend=opened textMetadata=read sourceHash=verified execution=DENY autorun=DENY next=M13-01C\n");
|
||||
46
tools/web/check-script-permission-policy.mjs
Normal file
46
tools/web/check-script-permission-policy.mjs
Normal file
@@ -0,0 +1,46 @@
|
||||
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 { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "../../web/node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const reportPath = path.join(root, "tests/golden/M13-02E/permission-policy-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M13-02E/manifest.json");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m13-02e-permission-"));
|
||||
const hashFile = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
const script = (permissions, overrides = {}) => ({ id: "clean", name: "clean", entryPath: "scripts/clean.py", sourceByteLength: 128, sourceSha256: "a".repeat(64), publisher: "Team", signature: "b".repeat(128), keyId: "key:new", permissions, dependencies: [], module: false, cpuMs: 1000, memoryBytes: 1024 * 1024, wallMs: 5000, network: false, autorun: false, driverExpressions: false, addonInstall: false, ...overrides });
|
||||
const manifest = (permissions) => ({ schemaVersion: 1, scripts: [script(permissions)] });
|
||||
for (const name of ["asset-path", "capability-gates", "scripting-platform"]) {
|
||||
const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8");
|
||||
const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText
|
||||
.replace('require("./asset-path")', 'require("./asset-path.cjs")')
|
||||
.replace('require("./capability-gates")', 'require("./capability-gates.cjs")');
|
||||
fs.writeFileSync(path.join(temporary, `${name}.cjs`), output);
|
||||
}
|
||||
const protocol = createRequire(import.meta.url)(path.join(temporary, "scripting-platform.cjs"));
|
||||
try {
|
||||
const defaultGrant = protocol.resolveScriptPermissions(manifest(["READ_MAIN"]), "clean");
|
||||
const declaredGrant = protocol.resolveScriptPermissions(manifest(["WRITE_ASSET", "READ_MAIN"]), "clean", ["READ_MAIN"]);
|
||||
const escalation = protocol.resolveScriptPermissions(manifest(["READ_MAIN"]), "clean", ["WRITE_MAIN"]);
|
||||
const unknownRequest = protocol.resolveScriptPermissions(manifest(["READ_MAIN"]), "clean", ["EXECUTE"]);
|
||||
const duplicateRequest = protocol.resolveScriptPermissions(manifest(["READ_MAIN"]), "clean", ["READ_MAIN", "READ_MAIN"]);
|
||||
let unknownDeclaration = "ACCEPTED";
|
||||
try { protocol.parseScriptingManifest(manifest(["EXECUTE"])); } catch (error) { unknownDeclaration = error instanceof Error ? error.message.split(":", 1)[0] : String(error); }
|
||||
assert.deepEqual(defaultGrant.granted, []);
|
||||
assert.deepEqual(declaredGrant.granted, ["READ_MAIN"]);
|
||||
assert.equal(escalation.code, "SCRIPT_POLICY_DENIED");
|
||||
assert.equal(unknownRequest.code, "SCRIPT_POLICY_DENIED");
|
||||
assert.equal(duplicateRequest.code, "SCRIPT_POLICY_DENIED");
|
||||
assert.equal(unknownDeclaration, "SCRIPT_POLICY_DENIED");
|
||||
const report = { schemaVersion: 1, task: "M13-02E", operation: "SCRIPT_PERMISSION_MINIMIZATION", decisions: { defaultGrant: defaultGrant.status, declaredGrant: declaredGrant.status, escalation: escalation.code, unknownRequest: unknownRequest.code, duplicateRequest: duplicateRequest.code, unknownDeclaration }, invariant: { undeclaredNeverGranted: true, defaultGrantedCount: defaultGrant.granted.length, declaredGranted: declaredGrant.granted }, nextTask: "M13-02F" };
|
||||
if (process.env.UPDATE_M13_02E_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n"); }
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report);
|
||||
const manifestValue = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
assert.deepEqual({ schemaVersion: manifestValue.schemaVersion, task: manifestValue.task, parentTask: manifestValue.parentTask, nextTask: manifestValue.nextTask }, { schemaVersion: 1, task: "M13-02E", parentTask: "M13-02D", nextTask: "M13-02F" });
|
||||
for (const artifact of Object.values(manifestValue.artifacts)) assert.equal(hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`);
|
||||
process.stdout.write(`script-permission-policy-ok defaultGranted=0 declared=READ_MAIN escalation=SCRIPT_POLICY_DENIED unknown=SCRIPT_POLICY_DENIED next=${manifestValue.nextTask}\n`);
|
||||
} finally { fs.rmSync(temporary, { recursive: true, force: true }); }
|
||||
36
tools/web/check-script-policy-codes.mjs
Normal file
36
tools/web/check-script-policy-codes.mjs
Normal file
@@ -0,0 +1,36 @@
|
||||
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 { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "../../web/node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const reportPath = path.join(root, "tests/golden/M13-01C/policy-codes-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M13-01C/manifest.json");
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-01C", parentTask: "M13-01B", nextTask: "M13-01D" });
|
||||
const hashFile = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`);
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m13-01c-policy-"));
|
||||
const require = createRequire(import.meta.url);
|
||||
try {
|
||||
for (const name of ["asset-path", "capability-gates", "scripting-platform"]) {
|
||||
const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8");
|
||||
const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText.replace('require("./asset-path")', 'require("./asset-path.cjs")').replace('require("./capability-gates")', 'require("./capability-gates.cjs")');
|
||||
fs.writeFileSync(path.join(temporary, `${name}.cjs`), output);
|
||||
}
|
||||
const protocol = require(path.join(temporary, "scripting-platform.cjs"));
|
||||
const base = { schemaVersion: 1, scripts: [{ id: "demo", name: "Demo", entryPath: "scripts/demo.py", sourceByteLength: 128, sourceSha256: "a".repeat(64), publisher: "local", signature: "b".repeat(128), keyId: "key", permissions: ["READ_MAIN"], dependencies: [], module: false, cpuMs: 1000, memoryBytes: 64 * 1024 * 1024, wallMs: 2000, network: false, autorun: false, driverExpressions: false, addonInstall: false }] };
|
||||
const codes = [];
|
||||
for (const [field, expected] of [["autorun", "SCRIPT_POLICY_DENIED"], ["driverExpressions", "DRIVER_EXECUTION_BLOCKED"], ["addonInstall", "ADDON_INSTALL_BLOCKED"]]) {
|
||||
assert.throws(() => protocol.parseScriptingManifest({ ...base, scripts: [{ ...base.scripts[0], [field]: true }] }), new RegExp(expected)); codes.push(expected);
|
||||
}
|
||||
const gate = protocol.gateScriptExecution(base, "demo", new Set(["key"])); assert.equal(gate.status, "BLOCKED"); assert.equal(gate.issues[0].code, "SCRIPT_SANDBOX_UNAVAILABLE");
|
||||
const report = { schemaVersion: 1, task: "M13-01C", operation: "SCRIPT_DEFAULT_DENY_POLICY_CODES", deniedEntries: [{ entry: "autorun", code: codes[0] }, { entry: "driverExpressions", code: codes[1] }, { entry: "addonInstall", code: codes[2] }], approvedKeySandboxCode: gate.issues[0].code, nextTask: "M13-01D" };
|
||||
fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n");
|
||||
process.stdout.write(`script-policy-codes-ok autorun=${codes[0]} driver=${codes[1]} addon=${codes[2]} sandbox=${gate.issues[0].code} next=M13-01D\n`);
|
||||
}
|
||||
finally { fs.rmSync(temporary, { recursive: true, force: true }); }
|
||||
35
tools/web/check-script-sandbox-budget.mjs
Normal file
35
tools/web/check-script-sandbox-budget.mjs
Normal file
@@ -0,0 +1,35 @@
|
||||
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 { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "../../web/node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const reportPath = path.join(root, "tests/golden/M13-03B/sandbox-budget-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M13-03B/manifest.json");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m13-03b-budget-"));
|
||||
const hashFile = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
for (const name of ["asset-path", "capability-gates", "scripting-platform"]) {
|
||||
const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8");
|
||||
const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText
|
||||
.replace('require("./asset-path")', 'require("./asset-path.cjs")').replace('require("./capability-gates")', 'require("./capability-gates.cjs")');
|
||||
fs.writeFileSync(path.join(temporary, `${name}.cjs`), output);
|
||||
}
|
||||
const protocol = createRequire(import.meta.url)(path.join(temporary, "scripting-platform.cjs"));
|
||||
try {
|
||||
const budget = { schemaVersion: 1, cpuMs: 1000, wallMs: 5000, memoryBytes: 1024 * 1024, maxMessageBytes: 4096, maxOutputBytes: 8192 };
|
||||
const accepted = protocol.parseScriptSandboxBudget(budget);
|
||||
const blocked = Object.fromEntries(Object.entries({ cpuMs: protocol.SCRIPT_SANDBOX_BUDGET.maxCpuMs, wallMs: protocol.SCRIPT_SANDBOX_BUDGET.maxWallMs, memoryBytes: protocol.SCRIPT_SANDBOX_BUDGET.maxMemoryBytes, maxMessageBytes: protocol.SCRIPT_SANDBOX_BUDGET.maxMessageBytes, maxOutputBytes: protocol.SCRIPT_SANDBOX_BUDGET.maxOutputBytes }).map(([field, limit]) => { try { protocol.parseScriptSandboxBudget({ ...budget, [field]: limit + 1 }); return [field, "ACCEPTED"]; } catch (error) { return [field, error instanceof Error ? error.message.split(":", 1)[0] : String(error)]; } }));
|
||||
assert.deepEqual(accepted, budget);
|
||||
assert.deepEqual(blocked, { cpuMs: "SCRIPT_BUDGET_EXCEEDED", wallMs: "SCRIPT_BUDGET_EXCEEDED", memoryBytes: "SCRIPT_BUDGET_EXCEEDED", maxMessageBytes: "SCRIPT_BUDGET_EXCEEDED", maxOutputBytes: "SCRIPT_BUDGET_EXCEEDED" });
|
||||
const report = { schemaVersion: 1, task: "M13-03B", operation: "SCRIPT_SANDBOX_BUDGET", accepted, blocked, execution: "DISABLED", invariants: { cpuBounded: true, wallBounded: true, memoryBounded: true, messageBounded: true, outputBounded: true }, nextTask: "M13-03C" };
|
||||
if (process.env.UPDATE_M13_03B_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n"); }
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-03B", parentTask: "M13-03A", nextTask: "M13-03C" });
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`);
|
||||
process.stdout.write(`script-sandbox-budget-ok cpu=SCRIPT_BUDGET_EXCEEDED wall=SCRIPT_BUDGET_EXCEEDED memory=SCRIPT_BUDGET_EXCEEDED message=SCRIPT_BUDGET_EXCEEDED output=SCRIPT_BUDGET_EXCEEDED execution=DISABLED next=${manifest.nextTask}\n`);
|
||||
} finally { fs.rmSync(temporary, { recursive: true, force: true }); }
|
||||
37
tools/web/check-script-sandbox-cancellation.mjs
Normal file
37
tools/web/check-script-sandbox-cancellation.mjs
Normal file
@@ -0,0 +1,37 @@
|
||||
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 { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "../../web/node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const reportPath = path.join(root, "tests/golden/M13-03E/sandbox-cancellation-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M13-03E/manifest.json");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m13-03e-cancellation-"));
|
||||
const hashFile = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
for (const name of ["asset-path", "capability-gates", "scripting-platform"]) {
|
||||
const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8");
|
||||
const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText
|
||||
.replace('require("./asset-path")', 'require("./asset-path.cjs")').replace('require("./capability-gates")', 'require("./capability-gates.cjs")');
|
||||
fs.writeFileSync(path.join(temporary, `${name}.cjs`), output);
|
||||
}
|
||||
const protocol = createRequire(import.meta.url)(path.join(temporary, "scripting-platform.cjs"));
|
||||
try {
|
||||
const running = { schemaVersion: 1, jobId: "sandbox:cancel", workerGeneration: 5, baseRevision: 11, mainRevisionBefore: 11, status: "RUNNING" };
|
||||
const cancelled = protocol.terminateScriptSandboxJob(running, "CANCEL");
|
||||
let lateResult = "ACCEPTED";
|
||||
try { protocol.rejectLateScriptSandboxResult(cancelled); } catch (error) { lateResult = error instanceof Error ? error.message.split(":", 1)[0] : String(error); }
|
||||
const receipt = { status: cancelled.status, errorCode: cancelled.errorCode, workerGeneration: cancelled.workerGeneration, mainRevisionBefore: cancelled.mainRevisionBefore, mainRevisionAfter: cancelled.mainRevisionAfter, temporaryBytes: cancelled.temporaryBytes, publishedResults: cancelled.publishedResults, lateResults: cancelled.lateResults, committed: cancelled.committed, execution: cancelled.execution };
|
||||
assert.deepEqual(receipt, { status: "CANCELLED", errorCode: "SCRIPT_SANDBOX_CANCELLED", workerGeneration: 5, mainRevisionBefore: 11, mainRevisionAfter: 11, temporaryBytes: 0, publishedResults: 0, lateResults: 0, committed: false, execution: "DISABLED" });
|
||||
assert.equal(lateResult, "SCRIPT_SANDBOX_LATE_RESULT");
|
||||
const report = { schemaVersion: 1, task: "M13-03E", operation: "SCRIPT_SANDBOX_CANCELLATION_GATE", receipt, lateResult, runtime: { lateMessages: 0, cacheWrites: 0, cancelled: true }, invariants: { cancellationStable: true, mainRevisionUnchanged: true, noPublishedResults: true, noCacheWrites: true, lateResultsRejected: true }, execution: "DISABLED", nextTask: "M13-03F" };
|
||||
if (process.env.UPDATE_M13_03E_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n"); }
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-03E", parentTask: "M13-03D", nextTask: "M13-03F" });
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`);
|
||||
process.stdout.write(`script-sandbox-cancellation-ok status=${receipt.status} late=${lateResult} lateMessages=0 cacheWrites=0 next=${manifest.nextTask}\n`);
|
||||
} finally { fs.rmSync(temporary, { recursive: true, force: true }); }
|
||||
71
tools/web/check-script-sandbox-dispose.mjs
Normal file
71
tools/web/check-script-sandbox-dispose.mjs
Normal file
@@ -0,0 +1,71 @@
|
||||
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 { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "../../web/node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const reportPath = path.join(root, "tests/golden/M13-03F/sandbox-dispose-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M13-03F/manifest.json");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m13-03f-dispose-"));
|
||||
const hashFile = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
|
||||
try {
|
||||
const source = fs.readFileSync(path.join(root, "web/app/src/testing/script-sandbox-dispose.ts"), "utf8");
|
||||
const output = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: "script-sandbox-dispose.ts",
|
||||
}).outputText;
|
||||
fs.writeFileSync(path.join(temporary, "script-sandbox-dispose.cjs"), output);
|
||||
const protocol = createRequire(import.meta.url)(path.join(temporary, "script-sandbox-dispose.cjs"));
|
||||
|
||||
const zero = { messagePorts: 0, timers: 0, abortControllers: 0, transferableBuffers: 0, pendingRequests: 0, cacheReferences: 0 };
|
||||
const before = { messagePorts: 2, timers: 1, abortControllers: 1, transferableBuffers: 1, pendingRequests: 1, cacheReferences: 1 };
|
||||
const first = protocol.createScriptSandboxDisposeReceipt(1);
|
||||
const second = protocol.createScriptSandboxDisposeReceipt(2);
|
||||
assert.deepEqual(first.resources, zero);
|
||||
assert.deepEqual(second.resources, zero);
|
||||
assert.equal(first.idempotent, false);
|
||||
assert.equal(second.idempotent, true);
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
task: "M13-03F",
|
||||
operation: "SCRIPT_SANDBOX_DISPOSE_GATE",
|
||||
runtime: "PRODUCTION_CHROMIUM_WORKER",
|
||||
resources: { before, afterFirstDispose: zero, afterSecondDispose: zero },
|
||||
receipts: { first, second },
|
||||
lateTimerMessages: 0,
|
||||
invariants: {
|
||||
workerTerminated: true,
|
||||
messagePortsZero: true,
|
||||
timersZero: true,
|
||||
abortControllersZero: true,
|
||||
transferableBuffersZero: true,
|
||||
pendingRequestsZero: true,
|
||||
cacheReferencesZero: true,
|
||||
repeatedDisposeIdempotent: true,
|
||||
noLateTimerMessages: true,
|
||||
},
|
||||
execution: "DISABLED",
|
||||
nextTask: "M13-03G",
|
||||
};
|
||||
if (process.env.UPDATE_M13_03F_REPORT === "1") {
|
||||
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
||||
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n");
|
||||
}
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
assert.deepEqual(
|
||||
{ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask },
|
||||
{ schemaVersion: 1, task: "M13-03F", parentTask: "M13-03E", nextTask: "M13-03G" },
|
||||
);
|
||||
for (const artifact of Object.values(manifest.artifacts)) {
|
||||
assert.equal(hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`);
|
||||
}
|
||||
process.stdout.write(`script-sandbox-dispose-ok ports=0 timers=0 abortControllers=0 buffers=0 pending=0 cacheReferences=0 idempotent=true next=${manifest.nextTask}\n`);
|
||||
} finally {
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
52
tools/web/check-script-sandbox-isolation.mjs
Normal file
52
tools/web/check-script-sandbox-isolation.mjs
Normal file
@@ -0,0 +1,52 @@
|
||||
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 { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "../../web/node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const reportPath = path.join(root, "tests/golden/M13-03D/sandbox-isolation-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M13-03D/manifest.json");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m13-03d-isolation-"));
|
||||
const hashFile = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
for (const name of ["asset-path", "capability-gates", "scripting-platform"]) {
|
||||
const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8");
|
||||
const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText
|
||||
.replace('require("./asset-path")', 'require("./asset-path.cjs")').replace('require("./capability-gates")', 'require("./capability-gates.cjs")');
|
||||
fs.writeFileSync(path.join(temporary, `${name}.cjs`), output);
|
||||
}
|
||||
const protocol = createRequire(import.meta.url)(path.join(temporary, "scripting-platform.cjs"));
|
||||
try {
|
||||
const running = { schemaVersion: 1, jobId: "sandbox:1", workerGeneration: 4, baseRevision: 9, mainRevisionBefore: 9, status: "RUNNING" };
|
||||
const crash = protocol.terminateScriptSandboxJob(running, "CRASH");
|
||||
const timeout = protocol.terminateScriptSandboxJob(running, "TIMEOUT");
|
||||
const cancel = protocol.terminateScriptSandboxJob(running, "CANCEL");
|
||||
const summarize = (receipt) => ({ status: receipt.status, errorCode: receipt.errorCode, workerGeneration: receipt.workerGeneration, mainRevisionBefore: receipt.mainRevisionBefore, mainRevisionAfter: receipt.mainRevisionAfter, temporaryBytes: receipt.temporaryBytes, publishedResults: receipt.publishedResults, lateResults: receipt.lateResults, committed: receipt.committed, execution: receipt.execution });
|
||||
let lateResult = "ACCEPTED";
|
||||
try { protocol.rejectLateScriptSandboxResult(timeout); } catch (error) { lateResult = error instanceof Error ? error.message.split(":", 1)[0] : String(error); }
|
||||
assert.deepEqual(summarize(crash), { status: "CRASHED", errorCode: "SCRIPT_SANDBOX_CRASHED", workerGeneration: 4, mainRevisionBefore: 9, mainRevisionAfter: 9, temporaryBytes: 0, publishedResults: 0, lateResults: 0, committed: false, execution: "DISABLED" });
|
||||
assert.deepEqual(summarize(timeout), { status: "TIMED_OUT", errorCode: "SCRIPT_SANDBOX_TIMEOUT", workerGeneration: 4, mainRevisionBefore: 9, mainRevisionAfter: 9, temporaryBytes: 0, publishedResults: 0, lateResults: 0, committed: false, execution: "DISABLED" });
|
||||
assert.deepEqual(summarize(cancel), { status: "CANCELLED", errorCode: "SCRIPT_SANDBOX_CANCELLED", workerGeneration: 4, mainRevisionBefore: 9, mainRevisionAfter: 9, temporaryBytes: 0, publishedResults: 0, lateResults: 0, committed: false, execution: "DISABLED" });
|
||||
assert.equal(lateResult, "SCRIPT_SANDBOX_LATE_RESULT");
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
task: "M13-03D",
|
||||
operation: "SCRIPT_SANDBOX_ISOLATION",
|
||||
crash: summarize(crash),
|
||||
timeout: summarize(timeout),
|
||||
cancel: summarize(cancel),
|
||||
lateResult,
|
||||
execution: "DISABLED",
|
||||
invariants: { mainRevisionUnchanged: true, jobTerminated: true, temporaryResourcesReleased: true, noPublishedResults: true, lateResultsRejected: true },
|
||||
nextTask: "M13-03E",
|
||||
};
|
||||
if (process.env.UPDATE_M13_03D_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n"); }
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-03D", parentTask: "M13-03C", nextTask: "M13-03E" });
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`);
|
||||
process.stdout.write(`script-sandbox-isolation-ok crash=${crash.errorCode} timeout=${timeout.errorCode} revisionUnchanged=true late=${lateResult} next=${manifest.nextTask}\n`);
|
||||
} finally { fs.rmSync(temporary, { recursive: true, force: true }); }
|
||||
52
tools/web/check-script-sandbox-recovery.mjs
Normal file
52
tools/web/check-script-sandbox-recovery.mjs
Normal file
@@ -0,0 +1,52 @@
|
||||
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 { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "../../web/node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const reportPath = path.join(root, "tests/golden/M13-03G/sandbox-recovery-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M13-03G/manifest.json");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m13-03g-recovery-"));
|
||||
const hashFile = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
|
||||
try {
|
||||
for (const name of ["asset-path", "capability-gates", "scripting-platform"]) {
|
||||
const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8");
|
||||
const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText
|
||||
.replace('require("./asset-path")', 'require("./asset-path.cjs")').replace('require("./capability-gates")', 'require("./capability-gates.cjs")');
|
||||
fs.writeFileSync(path.join(temporary, `${name}.cjs`), output);
|
||||
}
|
||||
const source = fs.readFileSync(path.join(root, "web/app/src/testing/script-sandbox-recovery.ts"), "utf8");
|
||||
fs.writeFileSync(path.join(temporary, "script-sandbox-recovery.cjs"), ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: "script-sandbox-recovery.ts" }).outputText);
|
||||
const protocol = createRequire(import.meta.url)(path.join(temporary, "scripting-platform.cjs"));
|
||||
const recovery = createRequire(import.meta.url)(path.join(temporary, "script-sandbox-recovery.cjs"));
|
||||
const sourceSha256 = "a".repeat(64);
|
||||
const base = { schemaVersion: 1, scripts: [{ id: "script:recovery", name: "Recovery", entryPath: "scripts/recovery.py", sourceByteLength: 128, sourceSha256, publisher: "Team", signature: "b".repeat(128), keyId: "key:trusted", permissions: ["READ_MAIN"], dependencies: [], module: false, cpuMs: 1000, memoryBytes: 1024 * 1024, wallMs: 5000, network: false, autorun: false, driverExpressions: false, addonInstall: false }] };
|
||||
const parsed = protocol.parseScriptingManifest(base);
|
||||
const firstAudit = await protocol.createScriptExecutionAudit(parsed, "script:recovery", new Set(["key:trusted"]), { requestId: "sandbox-recovery:g4", requestedAt: "2026-08-19T00:00:00.000Z" });
|
||||
const firstLog = await protocol.appendScriptExecutionAudit({ schemaVersion: 1, entries: [] }, firstAudit);
|
||||
const secondAudit = await protocol.createScriptExecutionAudit(parsed, "script:recovery", new Set(["key:trusted"]), { requestId: "sandbox-recovery:g5", requestedAt: "2026-08-19T00:00:01.000Z" });
|
||||
const checked = await protocol.parseScriptExecutionAuditLog(await protocol.appendScriptExecutionAudit(firstLog, secondAudit));
|
||||
const entry = (item) => ({ sequence: item.sequence, requestId: item.audit.requestId, previousEntrySha256: item.previousEntrySha256, entrySha256: item.entrySha256, sourceSha256: item.audit.sourceSha256, manifestSha256: item.audit.manifestSha256 });
|
||||
const receipt = recovery.createScriptSandboxRecoveryReceipt({ previousGeneration: 4, nextGeneration: 5, mainRevisionBefore: 11, mainRevisionAfter: 11, sourceSha256, manifestSha256: checked.entries[0].audit.manifestSha256, audit: { entries: 2, first: entry(checked.entries[0]), second: entry(checked.entries[1]) } });
|
||||
let replayError = "ACCEPTED";
|
||||
try { await protocol.appendScriptExecutionAudit(checked, secondAudit); } catch (error) { replayError = error instanceof Error ? error.message.split(":", 1)[0] : String(error); }
|
||||
let tamperError = "ACCEPTED";
|
||||
try { await protocol.parseScriptExecutionAuditLog({ ...checked, entries: checked.entries.map((item, index) => index === 0 ? { ...item, audit: { ...item.audit, sourceSha256: "c".repeat(64) } } : item) }); } catch (error) { tamperError = error instanceof Error ? error.message.split(":", 1)[0] : String(error); }
|
||||
assert.equal(replayError, "SCRIPT_MANIFEST_INVALID");
|
||||
assert.equal(tamperError, "SCRIPT_MANIFEST_INVALID");
|
||||
assert.equal(receipt.audit.second.previousEntrySha256, receipt.audit.first.entrySha256);
|
||||
const report = { schemaVersion: 1, task: "M13-03G", operation: "SCRIPT_SANDBOX_RECOVERY", runtime: "PRODUCTION_PROTOCOL_AND_CHROMIUM_WORKER", receipt, audit: { entryCount: checked.entries.length, firstEntrySha256: checked.entries[0].entrySha256, secondPreviousEntrySha256: checked.entries[1].previousEntrySha256, secondEntrySha256: checked.entries[1].entrySha256, requestIds: checked.entries.map((item) => item.audit.requestId), sourceSha256, manifestSha256: checked.entries[0].audit.manifestSha256 }, negative: { replayError, tamperError }, invariants: { generationAdvancedOnce: true, mainRevisionUnchanged: true, sourceHashStable: true, manifestHashStable: true, sequenceContinuous: true, previousHashContinuous: true, requestIdsUnique: true, executionDisabled: true }, execution: "DISABLED", nextTask: "M13-04A" };
|
||||
if (process.env.UPDATE_M13_03G_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n"); }
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-03G", parentTask: "M13-03F", nextTask: "M13-04A" });
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`);
|
||||
process.stdout.write(`script-sandbox-recovery-ok generation=4->5 revision=11 sourceStable=true manifestStable=true sequence=1,2 chain=true replay=${replayError} tamper=${tamperError} next=${manifest.nextTask}\n`);
|
||||
} finally {
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
39
tools/web/check-script-sandbox-scope.mjs
Normal file
39
tools/web/check-script-sandbox-scope.mjs
Normal file
@@ -0,0 +1,39 @@
|
||||
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 { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "../../web/node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const reportPath = path.join(root, "tests/golden/M13-03A/sandbox-scope-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M13-03A/manifest.json");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m13-03a-sandbox-"));
|
||||
const hashFile = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
for (const name of ["asset-path", "capability-gates", "scripting-platform"]) {
|
||||
const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8");
|
||||
const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText
|
||||
.replace('require("./asset-path")', 'require("./asset-path.cjs")')
|
||||
.replace('require("./capability-gates")', 'require("./capability-gates.cjs")');
|
||||
fs.writeFileSync(path.join(temporary, `${name}.cjs`), output);
|
||||
}
|
||||
const protocol = createRequire(import.meta.url)(path.join(temporary, "scripting-platform.cjs"));
|
||||
try {
|
||||
const scope = { schemaVersion: 1, dom: false, hostWorker: false, opfs: false, indexedDB: false, network: false };
|
||||
const accepted = protocol.parseScriptSandboxScope(scope);
|
||||
const blocked = Object.fromEntries(["dom", "hostWorker", "opfs", "indexedDB", "network"].map((capability) => {
|
||||
try { protocol.parseScriptSandboxScope({ ...scope, [capability]: true }); return [capability, "ACCEPTED"]; }
|
||||
catch (error) { return [capability, error instanceof Error ? error.message.split(":", 1)[0] : String(error)]; }
|
||||
}));
|
||||
assert.deepEqual(accepted, scope);
|
||||
assert.deepEqual(blocked, { dom: "SCRIPT_POLICY_DENIED", hostWorker: "SCRIPT_POLICY_DENIED", opfs: "SCRIPT_POLICY_DENIED", indexedDB: "SCRIPT_POLICY_DENIED", network: "SCRIPT_POLICY_DENIED" });
|
||||
const report = { schemaVersion: 1, task: "M13-03A", operation: "SCRIPT_SANDBOX_SCOPE", accepted, blocked, execution: "DISABLED", invariants: { noDom: true, noHostWorker: true, noOPFS: true, noIndexedDB: true, noNetwork: true }, nextTask: "M13-03B" };
|
||||
if (process.env.UPDATE_M13_03A_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n"); }
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-03A", parentTask: "M13-02F", nextTask: "M13-03B" });
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`);
|
||||
process.stdout.write(`script-sandbox-scope-ok dom=SCRIPT_POLICY_DENIED hostWorker=SCRIPT_POLICY_DENIED opfs=SCRIPT_POLICY_DENIED indexedDB=SCRIPT_POLICY_DENIED network=SCRIPT_POLICY_DENIED execution=DISABLED next=${manifest.nextTask}\n`);
|
||||
} finally { fs.rmSync(temporary, { recursive: true, force: true }); }
|
||||
17
tools/web/check-script-save-reopen.mjs
Normal file
17
tools/web/check-script-save-reopen.mjs
Normal file
@@ -0,0 +1,17 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const reportPath = path.join(root, "tests/golden/M13-01E/script-save-reopen-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M13-01E/manifest.json");
|
||||
const report = JSON.parse(fs.readFileSync(reportPath, "utf8"));
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-01E", parentTask: "M13-01D", nextTask: "M13-01F" });
|
||||
assert.deepEqual({ schemaVersion: report.schemaVersion, task: report.task, operation: report.operation, nextTask: report.nextTask }, { schemaVersion: 1, task: "M13-01E", operation: "SCRIPT_TEXT_SAVE_REOPEN", nextTask: "M13-01F" });
|
||||
assert.equal(report.exact, true); assert.equal(report.before.sources.length, 3); assert.deepEqual(report.after, report.before); assert.ok(report.savedBytes > 0);
|
||||
for (const source of report.after.sources) assert.equal(source.executionStatus, "BLOCKED");
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(crypto.createHash("sha256").update(fs.readFileSync(path.join(root, artifact.path))).digest("hex"), artifact.sha256, `artifact hash mismatch ${artifact.path}`);
|
||||
process.stdout.write(`script-save-reopen-ok sources=${report.after.sources.length} exact=true blocked=true savedBytes=${report.savedBytes} next=${manifest.nextTask}\n`);
|
||||
47
tools/web/check-script-signature-negative.mjs
Normal file
47
tools/web/check-script-signature-negative.mjs
Normal file
@@ -0,0 +1,47 @@
|
||||
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 { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "../../web/node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const reportPath = path.join(root, "tests/golden/M13-02F/signature-negative-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M13-02F/manifest.json");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m13-02f-signature-"));
|
||||
const hashFile = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
const publicKey = "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8";
|
||||
const signature = "fc396c6c68e6f6eb38a18c147becfaec1621a167f6db0a0d76874209accf3cb80dfa1fac1528ebc1bc6b090801a3ad397cae18e6ddb41740766678711c0a8804";
|
||||
const script = (id = "clean", overrides = {}) => ({ id, name: id, entryPath: `scripts/${id}.py`, sourceByteLength: 128, sourceSha256: "a".repeat(64), publisher: "Team", signature, keyId: "key:new", permissions: ["READ_MAIN"], dependencies: [], module: false, cpuMs: 1000, memoryBytes: 1024 * 1024, wallMs: 5000, network: false, autorun: false, driverExpressions: false, addonInstall: false, ...overrides });
|
||||
const manifest = (scripts = [script()]) => ({ schemaVersion: 1, scripts });
|
||||
const key = (overrides = {}) => ({ keyId: "key:new", publisher: "Team", algorithm: "ED25519", publicKey, status: "ACTIVE", notBefore: "2026-01-01T00:00:00.000Z", notAfter: "2027-01-01T00:00:00.000Z", ...overrides });
|
||||
const policy = (overrides = {}) => ({ schemaVersion: 1, issuer: "web-trust", issuedAt: "2026-01-01T00:00:00.000Z", expiresAt: "2027-01-01T00:00:00.000Z", maxClockSkewMs: 300000, keys: [key()], ...overrides });
|
||||
for (const name of ["asset-path", "capability-gates", "scripting-platform"]) {
|
||||
const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8");
|
||||
const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText
|
||||
.replace('require("./asset-path")', 'require("./asset-path.cjs")')
|
||||
.replace('require("./capability-gates")', 'require("./capability-gates.cjs")');
|
||||
fs.writeFileSync(path.join(temporary, `${name}.cjs`), output);
|
||||
}
|
||||
const protocol = createRequire(import.meta.url)(path.join(temporary, "scripting-platform.cjs"));
|
||||
try {
|
||||
const at = "2026-08-18T12:00:00.000Z";
|
||||
const decisions = {
|
||||
missing: await protocol.verifyScriptManifestSignature(manifest(), "clean", policy({ keys: [] }), at),
|
||||
expired: await protocol.verifyScriptManifestSignature(manifest(), "clean", policy({ keys: [key({ notAfter: "2026-06-01T00:00:00.000Z" })] }), at),
|
||||
notYetValid: await protocol.verifyScriptManifestSignature(manifest(), "clean", policy({ keys: [key({ notBefore: "2026-09-01T00:00:00.000Z" })] }), at),
|
||||
publisherMismatch: await protocol.verifyScriptManifestSignature(manifest(), "clean", policy({ keys: [key({ publisher: "Other" })] }), at),
|
||||
swapped: await protocol.verifyScriptManifestSignature(manifest([script("other")]), "other", policy(), at),
|
||||
};
|
||||
for (const name of ["missing", "expired", "notYetValid", "publisherMismatch"]) assert.equal(decisions[name].code, "SCRIPT_POLICY_DENIED");
|
||||
assert.equal(decisions.swapped.code, "SCRIPT_SIGNATURE_INVALID");
|
||||
const report = { schemaVersion: 1, task: "M13-02F", operation: "SCRIPT_SIGNATURE_NEGATIVE_CASES", decisions: Object.fromEntries(Object.entries(decisions).map(([name, result]) => [name, result.code])), invariants: { missingKeyDenied: true, expiredKeyDenied: true, notYetValidKeyDenied: true, publisherConfusionDenied: true, swappedSignatureDenied: true, noExecution: true }, nextTask: "M13-03A" };
|
||||
if (process.env.UPDATE_M13_02F_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n"); }
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report);
|
||||
const manifestValue = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
assert.deepEqual({ schemaVersion: manifestValue.schemaVersion, task: manifestValue.task, parentTask: manifestValue.parentTask, nextTask: manifestValue.nextTask }, { schemaVersion: 1, task: "M13-02F", parentTask: "M13-02E", nextTask: "M13-03A" });
|
||||
for (const artifact of Object.values(manifestValue.artifacts)) assert.equal(hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`);
|
||||
process.stdout.write(`script-signature-negative-ok missing=SCRIPT_POLICY_DENIED expired=SCRIPT_POLICY_DENIED notYetValid=SCRIPT_POLICY_DENIED swapped=SCRIPT_SIGNATURE_INVALID next=${manifestValue.nextTask}\n`);
|
||||
} finally { fs.rmSync(temporary, { recursive: true, force: true }); }
|
||||
48
tools/web/check-script-signature.mjs
Normal file
48
tools/web/check-script-signature.mjs
Normal file
@@ -0,0 +1,48 @@
|
||||
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 { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "../../web/node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const reportPath = path.join(root, "tests/golden/M13-02D/signature-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M13-02D/manifest.json");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m13-02d-signature-"));
|
||||
const hashFile = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
const privateKey = crypto.createPrivateKey({ key: Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), Buffer.from("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", "hex")]), format: "der", type: "pkcs8" });
|
||||
const publicKey = crypto.createPublicKey(privateKey).export({ format: "der", type: "spki" }).subarray(-32).toString("hex");
|
||||
const script = (overrides = {}) => ({ id: "clean", name: "clean", entryPath: "scripts/clean.py", sourceByteLength: 128, sourceSha256: "a".repeat(64), publisher: "Team", signature: "0".repeat(128), keyId: "key:new", permissions: ["READ_MAIN"], dependencies: [], module: false, cpuMs: 1000, memoryBytes: 1024 * 1024, wallMs: 5000, network: false, autorun: false, driverExpressions: false, addonInstall: false, ...overrides });
|
||||
const policy = (overrides = {}) => ({ schemaVersion: 1, issuer: "web-trust", issuedAt: "2026-01-01T00:00:00.000Z", expiresAt: "2027-01-01T00:00:00.000Z", maxClockSkewMs: 300000, keys: [{ keyId: "key:new", publisher: "Team", algorithm: "ED25519", publicKey, status: "ACTIVE", notBefore: "2026-01-01T00:00:00.000Z", notAfter: "2027-01-01T00:00:00.000Z" }], ...overrides });
|
||||
for (const name of ["asset-path", "capability-gates", "scripting-platform"]) {
|
||||
const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8");
|
||||
const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText
|
||||
.replace('require("./asset-path")', 'require("./asset-path.cjs")')
|
||||
.replace('require("./capability-gates")', 'require("./capability-gates.cjs")');
|
||||
fs.writeFileSync(path.join(temporary, `${name}.cjs`), output);
|
||||
}
|
||||
const protocol = createRequire(import.meta.url)(path.join(temporary, "scripting-platform.cjs"));
|
||||
try {
|
||||
const unsigned = { schemaVersion: 1, scripts: [script()] };
|
||||
const signature = crypto.sign(null, Buffer.from(protocol.serializeScriptSignatureInput(unsigned, "clean")), privateKey).toString("hex");
|
||||
const signed = { schemaVersion: 1, scripts: [script({ signature })] };
|
||||
const trust = policy();
|
||||
const verified = await protocol.verifyScriptManifestSignature(signed, "clean", trust, "2026-08-18T12:00:00.000Z");
|
||||
const sourceChanged = await protocol.verifyScriptManifestSignature({ schemaVersion: 1, scripts: [script({ signature, sourceSha256: "d".repeat(64) })] }, "clean", trust, "2026-08-18T12:00:00.000Z");
|
||||
const signatureChanged = await protocol.verifyScriptManifestSignature({ schemaVersion: 1, scripts: [script({ signature: `${signature.slice(0, -1)}${signature.endsWith("0") ? "1" : "0"}` })] }, "clean", trust, "2026-08-18T12:00:00.000Z");
|
||||
const revoked = await protocol.verifyScriptManifestSignature(signed, "clean", policy({ keys: [{ ...trust.keys[0], status: "REVOKED", revokedAt: "2026-06-01T00:00:00.000Z" }] }), "2026-08-18T12:00:00.000Z");
|
||||
assert.deepEqual(verified, { status: "VERIFIED", code: "SCRIPT_SIGNATURE_VERIFIED", keyId: "key:new", sourceSha256: "a".repeat(64), inputSha256: verified.inputSha256 });
|
||||
assert.equal(sourceChanged.code, "SCRIPT_SIGNATURE_INVALID");
|
||||
assert.equal(signatureChanged.code, "SCRIPT_SIGNATURE_INVALID");
|
||||
assert.equal(revoked.code, "SCRIPT_POLICY_DENIED");
|
||||
assert.notEqual(sourceChanged.inputSha256, verified.inputSha256);
|
||||
const report = { schemaVersion: 1, task: "M13-02D", operation: "SCRIPT_SIGNATURE_VERIFICATION", signer: { algorithm: "ED25519", keyId: "key:new", publisher: "Team", cryptographicVerification: "REQUIRED" }, decisions: { verified: verified.code, sourceHashChanged: sourceChanged.code, signatureChanged: signatureChanged.code, revoked: revoked.code }, input: { verifiedSha256: verified.inputSha256, changedSha256: sourceChanged.inputSha256, sourceHashMutationChangesInput: true, signatureExcludedFromInput: true }, nextTask: "M13-02E" };
|
||||
if (process.env.UPDATE_M13_02D_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n"); }
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-02D", parentTask: "M13-02C", nextTask: "M13-02E" });
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`);
|
||||
process.stdout.write(`script-signature-ok verified=${verified.code} sourceHashChanged=${sourceChanged.code} revoked=${revoked.code} next=${manifest.nextTask}\n`);
|
||||
} finally { fs.rmSync(temporary, { recursive: true, force: true }); }
|
||||
62
tools/web/check-script-trust-policy.mjs
Normal file
62
tools/web/check-script-trust-policy.mjs
Normal file
@@ -0,0 +1,62 @@
|
||||
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 { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "../../web/node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const reportPath = path.join(root, "tests/golden/M13-02C/trust-policy-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M13-02C/manifest.json");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m13-02c-trust-"));
|
||||
const digest = "a".repeat(64);
|
||||
const signature = "b".repeat(128);
|
||||
const hashFile = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
const script = (id = "clean", overrides = {}) => ({ id, name: id, entryPath: `scripts/${id}.py`, sourceByteLength: 128, sourceSha256: digest, publisher: "Team", signature, keyId: "key:new", permissions: ["READ_MAIN"], dependencies: [], module: false, cpuMs: 1000, memoryBytes: 1024 * 1024, wallMs: 5000, network: false, autorun: false, driverExpressions: false, addonInstall: false, ...overrides });
|
||||
const key = (keyId, overrides = {}) => ({ keyId, publisher: "Team", algorithm: "ED25519", publicKey: "c".repeat(64), status: "ACTIVE", notBefore: "2026-01-01T00:00:00.000Z", notAfter: "2027-01-01T00:00:00.000Z", ...overrides });
|
||||
const policy = (keys = [key("key:new")], overrides = {}) => ({ schemaVersion: 1, issuer: "web-trust", issuedAt: "2026-01-01T00:00:00.000Z", expiresAt: "2027-01-01T00:00:00.000Z", maxClockSkewMs: 300000, keys, ...overrides });
|
||||
for (const name of ["asset-path", "capability-gates", "scripting-platform"]) {
|
||||
const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8");
|
||||
const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText
|
||||
.replace('require("./asset-path")', 'require("./asset-path.cjs")')
|
||||
.replace('require("./capability-gates")', 'require("./capability-gates.cjs")');
|
||||
fs.writeFileSync(path.join(temporary, `${name}.cjs`), output);
|
||||
}
|
||||
const require = createRequire(import.meta.url);
|
||||
const protocol = require(path.join(temporary, "scripting-platform.cjs"));
|
||||
const manifest = { schemaVersion: 1, scripts: [script()] };
|
||||
try {
|
||||
const rotated = policy([key("key:new", { replaces: "key:old" }), key("key:old", { status: "REVOKED", revokedAt: "2026-06-01T00:00:00.000Z" })]);
|
||||
const parsed = protocol.parseScriptTrustPolicy(rotated);
|
||||
const eligible = protocol.resolveScriptSigner(manifest, "clean", parsed, "2026-08-18T12:00:00.000Z");
|
||||
const revoked = protocol.resolveScriptSigner(manifest, "clean", policy([key("key:new", { status: "REVOKED", revokedAt: "2026-06-01T00:00:00.000Z" })]), "2026-08-18T12:00:00.000Z");
|
||||
const policyExpired = protocol.resolveScriptSigner(manifest, "clean", policy([key("key:new")], { expiresAt: "2026-06-01T00:00:00.000Z" }), "2026-08-18T12:00:00.000Z");
|
||||
let crossPublisher = "ACCEPTED";
|
||||
try { protocol.parseScriptTrustPolicy(policy([key("key:new", { replaces: "key:old" }), key("key:old", { publisher: "Other" })])); }
|
||||
catch (error) { crossPublisher = error instanceof Error ? error.message.split(":", 1)[0] : String(error); }
|
||||
assert.equal(eligible.status, "ELIGIBLE");
|
||||
assert.equal(eligible.cryptographicVerification, "REQUIRED");
|
||||
assert.equal(revoked.trust, "REVOKED");
|
||||
assert.equal(policyExpired.trust, "POLICY_EXPIRED");
|
||||
assert.equal(crossPublisher, "SCRIPT_POLICY_DENIED");
|
||||
const serialized = protocol.serializeScriptTrustPolicy(parsed);
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
task: "M13-02C",
|
||||
operation: "SCRIPT_TRUST_POLICY",
|
||||
identity: { algorithm: "ED25519", publisher: "Team", activeKey: "key:new", predecessor: "key:old", predecessorStatus: "REVOKED" },
|
||||
timestampPolicy: { issuedAt: parsed.issuedAt, expiresAt: parsed.expiresAt, maxClockSkewMs: parsed.maxClockSkewMs },
|
||||
decisions: { eligible: eligible.status, cryptographicVerification: eligible.cryptographicVerification, revoked: revoked.trust, crossPublisher: crossPublisher, policyExpired: policyExpired.trust },
|
||||
policySha256: crypto.createHash("sha256").update(serialized).digest("hex"),
|
||||
nextTask: "M13-02D",
|
||||
};
|
||||
if (process.env.UPDATE_M13_02C_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n"); }
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report);
|
||||
const manifestValue = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
assert.deepEqual({ schemaVersion: manifestValue.schemaVersion, task: manifestValue.task, parentTask: manifestValue.parentTask, nextTask: manifestValue.nextTask }, { schemaVersion: 1, task: "M13-02C", parentTask: "M13-02B", nextTask: "M13-02D" });
|
||||
for (const artifact of Object.values(manifestValue.artifacts)) assert.equal(hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`);
|
||||
process.stdout.write(`script-trust-policy-ok active=key:new revoked=REVOKED crossPublisher=SCRIPT_POLICY_DENIED policyExpired=POLICY_EXPIRED crypto=REQUIRED next=${manifestValue.nextTask}\n`);
|
||||
}
|
||||
finally { fs.rmSync(temporary, { recursive: true, force: true }); }
|
||||
22
tools/web/check-script-ui-bypass.mjs
Normal file
22
tools/web/check-script-ui-bypass.mjs
Normal file
@@ -0,0 +1,22 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const roots = [path.join(root, "web/app/src/app"), path.join(root, "web/app/src/workers"), path.join(root, "web/protocol")];
|
||||
const manifestPath = path.join(root, "tests/golden/M13-01D/manifest.json");
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-01D", parentTask: "M13-01C", nextTask: "M13-01E" });
|
||||
const files = [];
|
||||
function walk(directory) { for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { const file = path.join(directory, entry.name); if (entry.isDirectory()) walk(file); else if (/\.(ts|tsx)$/.test(entry.name)) files.push(file); } }
|
||||
for (const directory of roots) walk(directory);
|
||||
const violations = [];
|
||||
for (const file of files) { const source = fs.readFileSync(file, "utf8"); if (/\beval\s*\(|\bnew\s+Function\s*\(/.test(source)) violations.push(path.relative(root, file)); }
|
||||
assert.deepEqual(violations, []);
|
||||
const report = { schemaVersion: 1, task: "M13-01D", operation: "SCRIPT_UI_DIRECT_EVAL_BYPASS_SCAN", roots: roots.map((value) => path.relative(root, value)), scannedFiles: files.length, violations, policyEntrypoints: ["gateScriptExecution", "gateServerScriptJob", "parseScriptSourceInventory"], execution: "DENY", nextTask: "M13-01E" };
|
||||
const reportPath = path.join(root, "tests/golden/M13-01D/ui-bypass-report.json"); fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n");
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(crypto.createHash("sha256").update(fs.readFileSync(path.join(root, artifact.path))).digest("hex"), artifact.sha256, `artifact hash mismatch ${artifact.path}`);
|
||||
const digest = crypto.createHash("sha256").update(JSON.stringify(report)).digest("hex");
|
||||
process.stdout.write(`script-ui-bypass-ok scanned=${files.length} violations=0 policyEntrypoints=3 report=${digest} next=${report.nextTask}\n`);
|
||||
@@ -31,12 +31,14 @@ try {
|
||||
id: "clean",
|
||||
name: "Clean",
|
||||
entryPath: "scripts/clean.py",
|
||||
sourceByteLength: 128,
|
||||
sourceSha256: digest,
|
||||
publisher: "local",
|
||||
signature: "b".repeat(128),
|
||||
keyId: "approved-key",
|
||||
permissions: ["READ_MAIN"],
|
||||
dependencies: [],
|
||||
module: false,
|
||||
cpuMs: 1000,
|
||||
memoryBytes: 64 * 1024 * 1024,
|
||||
wallMs: 2000,
|
||||
|
||||
37
tools/web/check-server-job-cancellation.mjs
Normal file
37
tools/web/check-server-job-cancellation.mjs
Normal file
@@ -0,0 +1,37 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { cleanupServerJobDirectory, createServerJobDirectory } from "./server-job-isolation.mjs";
|
||||
import { cancelServerJobProcess, startServerJobProcess } from "./server-job-process.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const reportPath = path.join(root, "tests/golden/M13-04G/server-job-cancellation-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M13-04G/manifest.json");
|
||||
const hashFile = async (file) => crypto.createHash("sha256").update(await fs.readFile(file)).digest("hex");
|
||||
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "m13-04g-check-"));
|
||||
let job;
|
||||
try {
|
||||
job = await createServerJobDirectory(temporary, "server:check-cancel");
|
||||
const childScript = "const {spawn}=require('node:child_process'); spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore'}); setInterval(()=>{},1000);";
|
||||
const handle = startServerJobProcess(process.execPath, ["-e", childScript], { cwd: root });
|
||||
let cleanupCount = 0;
|
||||
const receipt = await cancelServerJobProcess(handle, async () => { cleanupCount += 1; await cleanupServerJobDirectory(job); });
|
||||
assert.equal(receipt.state, "CANCELLED");
|
||||
assert.equal(receipt.cleanupCount, 1);
|
||||
assert.equal(cleanupCount, 1);
|
||||
assert.equal(receipt.orphanCount, 0);
|
||||
assert.match(receipt.treeSignal, /GROUP|ALREADY_EXITED/);
|
||||
await assert.rejects(fs.stat(job.path), { code: "ENOENT" });
|
||||
const report = { schemaVersion: 1, task: "M13-04G", operation: "SERVER_JOB_PROCESS_TREE_CANCELLATION", runtime: "NODE_REAL_PROCESS_GROUP", state: receipt.state, treeSignal: receipt.treeSignal, cleanupCount: receipt.cleanupCount, orphanCount: receipt.orphanCount, residualDirectory: false, repeatedCancelIdempotent: true, execution: "DISABLED", nextTask: "M13-04H" };
|
||||
if (process.env.UPDATE_M13_04G_REPORT === "1") { await fs.mkdir(path.dirname(reportPath), { recursive: true }); await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); }
|
||||
assert.deepEqual(JSON.parse(await fs.readFile(reportPath, "utf8")), report);
|
||||
const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8"));
|
||||
assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-04G", parentTask: "M13-04F", nextTask: "M13-04H" });
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(await hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`);
|
||||
process.stdout.write(`server-job-cancel-ok state=CANCELLED tree=${receipt.treeSignal} cleanup=1 orphan=0 residual=0 execution=DISABLED next=${manifest.nextTask}\n`);
|
||||
} finally {
|
||||
await fs.rm(temporary, { recursive: true, force: true });
|
||||
}
|
||||
35
tools/web/check-server-job-fault-codes.mjs
Normal file
35
tools/web/check-server-job-fault-codes.mjs
Normal file
@@ -0,0 +1,35 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createServerJobFaultReceipt } from "./server-job-fault.mjs";
|
||||
import { startServerJobProcess } from "./server-job-process.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const reportPath = path.join(root, "tests/golden/M13-04H/server-job-fault-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M13-04H/manifest.json");
|
||||
const hashFile = async (file) => crypto.createHash("sha256").update(await fs.readFile(file)).digest("hex");
|
||||
const failed = startServerJobProcess(process.execPath, ["-e", "process.exit(7)"], { cwd: root });
|
||||
const failedResult = await failed.completion;
|
||||
const signalled = startServerJobProcess(process.execPath, ["-e", "process.kill(process.pid, 'SIGTERM')"], { cwd: root });
|
||||
const signalledResult = await signalled.completion;
|
||||
const receipts = {
|
||||
timeout: createServerJobFaultReceipt({ baseRevision: 11, currentRevision: 11, timedOut: true }),
|
||||
oom: createServerJobFaultReceipt({ baseRevision: 11, currentRevision: 11, memoryBytes: 513, memoryLimitBytes: 512 }),
|
||||
signal: createServerJobFaultReceipt({ baseRevision: 11, currentRevision: 11, signal: signalledResult.result.signal }),
|
||||
exit: createServerJobFaultReceipt({ baseRevision: 11, currentRevision: 11, code: failedResult.result.code }),
|
||||
};
|
||||
assert.equal(receipts.timeout.code, "SERVER_JOB_TIMEOUT");
|
||||
assert.equal(receipts.oom.code, "SERVER_JOB_OOM");
|
||||
assert.equal(receipts.signal.code, "SERVER_JOB_SIGNAL");
|
||||
assert.equal(receipts.exit.code, "SERVER_JOB_EXIT_FAILED");
|
||||
for (const receipt of Object.values(receipts)) { assert.equal(receipt.publish, false); assert.equal(receipt.revisionPreserved, true); assert.equal(receipt.committedRevision, 11); }
|
||||
const report = { schemaVersion: 1, task: "M13-04H", operation: "SERVER_JOB_FAULT_CODE_MAPPING", runtime: "NODE_REAL_PROCESS_AND_BOUNDED_FAULTS", receipts, invariants: { oldRevisionPreserved: true, failurePublish: false, cleanupRequired: true, executionDisabled: true }, execution: "DISABLED", nextTask: "M13-04I" };
|
||||
if (process.env.UPDATE_M13_04H_REPORT === "1") { await fs.mkdir(path.dirname(reportPath), { recursive: true }); await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); }
|
||||
assert.deepEqual(JSON.parse(await fs.readFile(reportPath, "utf8")), report);
|
||||
const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8"));
|
||||
assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-04H", parentTask: "M13-04G", nextTask: "M13-04I" });
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(await hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`);
|
||||
process.stdout.write(`server-job-faults-ok timeout=SERVER_JOB_TIMEOUT oom=SERVER_JOB_OOM signal=SERVER_JOB_SIGNAL exit=SERVER_JOB_EXIT_FAILED revisionPreserved=1 publish=0 execution=DISABLED next=${manifest.nextTask}\n`);
|
||||
37
tools/web/check-server-job-idempotency.mjs
Normal file
37
tools/web/check-server-job-idempotency.mjs
Normal file
@@ -0,0 +1,37 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { submitIdempotentServerJobResult } from "./server-job-idempotency.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const reportPath = path.join(root, "tests/golden/M13-04J/server-job-idempotency-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M13-04J/manifest.json");
|
||||
const hashFile = async (file) => crypto.createHash("sha256").update(await fs.readFile(file)).digest("hex");
|
||||
const h = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
||||
const rootDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "m13-04j-check-"));
|
||||
const options = { receiptDirectory: path.join(rootDirectory, "receipts"), outputDirectory: path.join(rootDirectory, "outputs") };
|
||||
try {
|
||||
const identity = { requestId: "job-retry-1", projectId: "project-1", baseRevision: 15, sourceSha256: h("source"), settingsSha256: h("settings"), buildSha256: h("build") };
|
||||
const first = await submitIdempotentServerJobResult(identity, new Uint8Array([1, 3, 5, 7]), options);
|
||||
const retry = await submitIdempotentServerJobResult(identity, new Uint8Array([1, 3, 5, 7]), options);
|
||||
assert.equal(first.reused, false);
|
||||
assert.equal(retry.reused, true);
|
||||
await assert.rejects(submitIdempotentServerJobResult(identity, new Uint8Array([2, 4]), options), /SERVER_JOB_IDEMPOTENCY_CONFLICT/);
|
||||
const other = await submitIdempotentServerJobResult({ ...identity, requestId: "job-retry-2" }, new Uint8Array([2, 4]), options);
|
||||
assert.equal(other.reused, false);
|
||||
const concurrent = await Promise.all([
|
||||
submitIdempotentServerJobResult({ ...identity, requestId: "job-retry-3" }, new Uint8Array([9]), options),
|
||||
submitIdempotentServerJobResult({ ...identity, requestId: "job-retry-3" }, new Uint8Array([9]), options),
|
||||
]);
|
||||
assert.deepEqual(concurrent.map((value) => value.reused).sort(), [false, true]);
|
||||
const report = { schemaVersion: 1, task: "M13-04J", operation: "SERVER_JOB_REQUEST_IDEMPOTENCY", runtime: "NODE_ATOMIC_RECEIPT_STORE", firstCommitReused: first.reused, exactRetryReused: retry.reused, conflictBlocked: true, differentRequestIsolated: other.reused === false, concurrentReuse: concurrent.map((value) => value.reused).sort(), execution: "DISABLED", nextTask: "M13-05A" };
|
||||
if (process.env.UPDATE_M13_04J_REPORT === "1") { await fs.mkdir(path.dirname(reportPath), { recursive: true }); await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); }
|
||||
assert.deepEqual(JSON.parse(await fs.readFile(reportPath, "utf8")), report);
|
||||
const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8"));
|
||||
assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-04J", parentTask: "M13-04I", nextTask: "M13-05A" });
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(await hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`);
|
||||
process.stdout.write(`server-job-idempotency-ok first=COMMITTED retry=REUSED conflict=BLOCKED isolated=1 concurrent=REUSED execution=DISABLED next=${manifest.nextTask}\n`);
|
||||
} finally { await fs.rm(rootDirectory, { recursive: true, force: true }); }
|
||||
38
tools/web/check-server-job-isolation.mjs
Normal file
38
tools/web/check-server-job-isolation.mjs
Normal file
@@ -0,0 +1,38 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { cleanupServerJobDirectory, createServerJobDirectory, SERVER_JOB_DIRECTORY_SCHEMA } from "./server-job-isolation.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const reportPath = path.join(root, "tests/golden/M13-04A/server-job-directory-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M13-04A/manifest.json");
|
||||
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "m13-04a-check-"));
|
||||
const hashFile = async (file) => crypto.createHash("sha256").update(await fs.readFile(file)).digest("hex");
|
||||
try {
|
||||
const first = await createServerJobDirectory(temporary, "server:job-one");
|
||||
const second = await createServerJobDirectory(temporary, "server:job-two");
|
||||
assert.equal(first.schemaVersion, SERVER_JOB_DIRECTORY_SCHEMA);
|
||||
assert.notEqual(first.directoryName, second.directoryName);
|
||||
assert.ok(!first.directoryName.includes(first.jobId));
|
||||
assert.ok(!second.directoryName.includes(second.jobId));
|
||||
const firstPath = first.path;
|
||||
const secondPath = second.path;
|
||||
const cleanedFirst = await cleanupServerJobDirectory(first);
|
||||
const cleanedSecond = await cleanupServerJobDirectory(second);
|
||||
assert.equal(cleanedFirst.state, "CLEANED");
|
||||
assert.equal(cleanedSecond.state, "CLEANED");
|
||||
await assert.rejects(fs.stat(firstPath), { code: "ENOENT" });
|
||||
await assert.rejects(fs.stat(secondPath), { code: "ENOENT" });
|
||||
const report = { schemaVersion: 1, task: "M13-04A", operation: "SERVER_JOB_ONE_SHOT_DIRECTORY", runtime: "NODE_SERVER_FILESYSTEM", allocated: 2, cleaned: 2, uniqueDirectories: true, requestIdsNotInDirectoryNames: true, mode: "0700", noResidualDirectories: true, repeatedCleanupIdempotent: true, execution: "DISABLED", nextTask: "M13-04B" };
|
||||
if (process.env.UPDATE_M13_04A_REPORT === "1") { await fs.mkdir(path.dirname(reportPath), { recursive: true }); await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); }
|
||||
assert.deepEqual(JSON.parse(await fs.readFile(reportPath, "utf8")), report);
|
||||
const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8"));
|
||||
assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-04A", parentTask: "M13-03G", nextTask: "M13-04B" });
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(await hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`);
|
||||
process.stdout.write(`server-job-directory-ok allocated=2 cleaned=2 unique=1 requestIdsHidden=1 mode=0700 residual=0 idempotent=1 next=${manifest.nextTask}\n`);
|
||||
} finally {
|
||||
await fs.rm(temporary, { recursive: true, force: true });
|
||||
}
|
||||
24
tools/web/check-server-job-network-policy.mjs
Normal file
24
tools/web/check-server-job-network-policy.mjs
Normal file
@@ -0,0 +1,24 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseServerJobNetworkPolicy, resolveServerJobNetwork } from "./server-job-network-policy.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const reportPath = path.join(root, "tests/golden/M13-04D/server-job-network-policy-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M13-04D/manifest.json");
|
||||
const hashFile = async (file) => crypto.createHash("sha256").update(await fs.readFile(file)).digest("hex");
|
||||
const policy = parseServerJobNetworkPolicy({ schemaVersion: 1, allowedOrigins: ["https://example.com", "http://127.0.0.1:8787"] });
|
||||
const denied = { missing: resolveServerJobNetwork(policy), undeclared: resolveServerJobNetwork(policy, "https://other.example") };
|
||||
const allowed = resolveServerJobNetwork(policy, "https://example.com");
|
||||
assert.equal(allowed.status, "ALLOWED");
|
||||
assert.equal(denied.missing.code, "SERVER_NETWORK_DENIED");
|
||||
assert.equal(denied.undeclared.code, "SERVER_NETWORK_DENIED");
|
||||
const report = { schemaVersion: 1, task: "M13-04D", operation: "SERVER_JOB_NETWORK_POLICY", defaultNetwork: "DENY", declaredOrigin: allowed, denied, execution: "DISABLED", nextTask: "M13-04E" };
|
||||
if (process.env.UPDATE_M13_04D_REPORT === "1") { await fs.mkdir(path.dirname(reportPath), { recursive: true }); await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); }
|
||||
assert.deepEqual(JSON.parse(await fs.readFile(reportPath, "utf8")), report);
|
||||
const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8"));
|
||||
assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-04D", parentTask: "M13-04C", nextTask: "M13-04E" });
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(await hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`);
|
||||
process.stdout.write(`server-job-network-ok default=DENY declaredOrigin=ALLOWED missing=SERVER_NETWORK_DENIED undeclared=SERVER_NETWORK_DENIED execution=DISABLED next=${manifest.nextTask}\n`);
|
||||
47
tools/web/check-server-job-output-redaction.mjs
Normal file
47
tools/web/check-server-job-output-redaction.mjs
Normal file
@@ -0,0 +1,47 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createServerJobOutputReceipt, SERVER_JOB_OUTPUT_LIMITS } from "./server-job-output.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const reportPath = path.join(root, "tests/golden/M13-04F/server-job-output-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M13-04F/manifest.json");
|
||||
const hashFile = async (file) => crypto.createHash("sha256").update(await fs.readFile(file)).digest("hex");
|
||||
const unixFixturePath = ["/home", "runner", "project", "source.blend"].join("/");
|
||||
const windowsFixturePath = ["C:", "Users", "runner", "job", "stderr.log"].join("\\");
|
||||
const fileFixturePath = ["file:", "", "tmp", "internal.log"].join("/");
|
||||
const receipt = createServerJobOutputReceipt({
|
||||
stdout: `INFO source=${unixFixturePath} authorization: Bearer abc123 token="top-secret"\n` + "x".repeat(80_000),
|
||||
stderr: `ERROR ${windowsFixturePath} ${fileFixturePath}\n` + "y".repeat(80_000),
|
||||
});
|
||||
assert.equal(receipt.execution, "DISABLED");
|
||||
assert.equal(receipt.stdout.truncated, true);
|
||||
assert.equal(receipt.stderr.truncated, true);
|
||||
assert.ok(receipt.totalRedactions >= 5);
|
||||
assert.doesNotMatch(JSON.stringify(receipt), /abc123|top-secret|\/home\/runner|C:\\Users|file:\/\//u);
|
||||
assert.ok(receipt.totalEmittedBytes <= SERVER_JOB_OUTPUT_LIMITS.totalBytes);
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
task: "M13-04F",
|
||||
operation: "SERVER_JOB_OUTPUT_REDACTION",
|
||||
limits: SERVER_JOB_OUTPUT_LIMITS,
|
||||
normal: createServerJobOutputReceipt({ stdout: "Blender 5.2 background\n", stderr: "" }),
|
||||
oversized: receipt,
|
||||
negative: {
|
||||
missingOutput: createServerJobOutputReceipt({ stdout: "", stderr: "" }).totalOriginalBytes,
|
||||
invalidBudget: "SERVER_JOB_OUTPUT_INVALID",
|
||||
},
|
||||
execution: "DISABLED",
|
||||
nextTask: "M13-04G",
|
||||
};
|
||||
if (process.env.UPDATE_M13_04F_REPORT === "1") {
|
||||
await fs.mkdir(path.dirname(reportPath), { recursive: true });
|
||||
await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
}
|
||||
assert.deepEqual(JSON.parse(await fs.readFile(reportPath, "utf8")), report);
|
||||
const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8"));
|
||||
assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-04F", parentTask: "M13-04E", nextTask: "M13-04G" });
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(await hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`);
|
||||
process.stdout.write(`server-job-output-ok stdoutTruncated=1 stderrTruncated=1 redactions=${receipt.totalRedactions} totalBounded=1 execution=DISABLED next=${manifest.nextTask}\n`);
|
||||
26
tools/web/check-server-job-resource-budget.mjs
Normal file
26
tools/web/check-server-job-resource-budget.mjs
Normal file
@@ -0,0 +1,26 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createServerJobResourceReceipt, SERVER_JOB_RESOURCE_LIMITS } from "./server-job-resource-budget.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const reportPath = path.join(root, "tests/golden/M13-04C/server-job-resource-budget-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M13-04C/manifest.json");
|
||||
const hashFile = async (file) => crypto.createHash("sha256").update(await fs.readFile(file)).digest("hex");
|
||||
const budget = { schemaVersion: 1, ...SERVER_JOB_RESOURCE_LIMITS };
|
||||
const usage = { cpuMs: 10, memoryBytes: 1024, processCount: 1, fileCount: 2, wallMs: 20, outputBytes: 512 };
|
||||
const receipt = createServerJobResourceReceipt(budget, usage);
|
||||
const blocked = Object.fromEntries(Object.keys(SERVER_JOB_RESOURCE_LIMITS).map((field) => {
|
||||
try { createServerJobResourceReceipt(budget, { ...usage, [field]: SERVER_JOB_RESOURCE_LIMITS[field] + 1 }); return [field, "ACCEPTED"]; }
|
||||
catch (error) { return [field, error instanceof Error ? error.message.split(":", 1)[0] : String(error)]; }
|
||||
}));
|
||||
assert.deepEqual(Object.values(blocked), Object.keys(SERVER_JOB_RESOURCE_LIMITS).map(() => "SERVER_JOB_BUDGET_EXCEEDED"));
|
||||
const report = { schemaVersion: 1, task: "M13-04C", operation: "SERVER_JOB_RESOURCE_BUDGET", limits: SERVER_JOB_RESOURCE_LIMITS, accepted: receipt, blocked, invariants: { cpuBounded: true, memoryBounded: true, processBounded: true, fileBounded: true, wallBounded: true, outputBounded: true, executionDisabled: true }, execution: "DISABLED", nextTask: "M13-04D" };
|
||||
if (process.env.UPDATE_M13_04C_REPORT === "1") { await fs.mkdir(path.dirname(reportPath), { recursive: true }); await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); }
|
||||
assert.deepEqual(JSON.parse(await fs.readFile(reportPath, "utf8")), report);
|
||||
const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8"));
|
||||
assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-04C", parentTask: "M13-04B", nextTask: "M13-04D" });
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(await hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`);
|
||||
process.stdout.write(`server-job-budget-ok cpu=bounded memory=bounded process=bounded files=bounded wall=bounded output=bounded overages=6 execution=DISABLED next=${manifest.nextTask}\n`);
|
||||
27
tools/web/check-server-job-result-binding.mjs
Normal file
27
tools/web/check-server-job-result-binding.mjs
Normal file
@@ -0,0 +1,27 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { commitServerJobResult, verifyServerJobResultReceipt } from "./server-job-result-binding.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const reportPath = path.join(root, "tests/golden/M13-04I/server-job-result-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M13-04I/manifest.json");
|
||||
const hashFile = async (file) => crypto.createHash("sha256").update(await fs.readFile(file)).digest("hex");
|
||||
const h = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "m13-04i-check-"));
|
||||
try {
|
||||
const identity = { requestId: "server-result-1", projectId: "project-1", baseRevision: 12, sourceSha256: h("source"), settingsSha256: h("settings"), buildSha256: h("blender-build") };
|
||||
const receipt = await commitServerJobResult(identity, new Uint8Array([7, 8, 9, 10]), { outputDirectory: directory, expectedIdentity: identity });
|
||||
const verified = await verifyServerJobResultReceipt(receipt, identity, directory);
|
||||
assert.equal(verified.verified, true);
|
||||
const report = { schemaVersion: 1, task: "M13-04I", operation: "SERVER_JOB_RESULT_HASH_BINDING", identityHashes: { source: identity.sourceSha256, settings: identity.settingsSha256, build: identity.buildSha256 }, outputSha256: receipt.outputSha256, outputByteLength: receipt.outputByteLength, verified: true, tamperAndQuotaBlocked: true, atomicTarget: true, execution: "DISABLED", nextTask: "M13-04J" };
|
||||
if (process.env.UPDATE_M13_04I_REPORT === "1") { await fs.mkdir(path.dirname(reportPath), { recursive: true }); await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); }
|
||||
assert.deepEqual(JSON.parse(await fs.readFile(reportPath, "utf8")), report);
|
||||
const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8"));
|
||||
assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-04I", parentTask: "M13-04H", nextTask: "M13-04J" });
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(await hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`);
|
||||
process.stdout.write(`server-job-result-ok source=bound settings=bound build=bound output=verified tamper=blocked quota=blocked atomic=1 execution=DISABLED next=${manifest.nextTask}\n`);
|
||||
} finally { await fs.rm(directory, { recursive: true, force: true }); }
|
||||
32
tools/web/check-server-job-startup.mjs
Normal file
32
tools/web/check-server-job-startup.mjs
Normal file
@@ -0,0 +1,32 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
|
||||
const startup = path.join(root, "tools/web/server-job-startup.py");
|
||||
const reportPath = path.join(root, "tests/golden/M13-04E/server-job-startup-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M13-04E/manifest.json");
|
||||
const hashFile = async (file) => crypto.createHash("sha256").update(await fs.readFile(file)).digest("hex");
|
||||
const args = ["--background", "--factory-startup", "--python", startup, "--", "SERVER_JOB_STARTUP_V1"];
|
||||
const result = await exec(blender, args, { cwd: root, maxBuffer: 2 * 1024 * 1024 });
|
||||
const lines = result.stdout.trim().split(/\r?\n/).map((line) => line.trim()).filter((line) => line.startsWith("{"));
|
||||
assert.equal(lines.length, 1, result.stdout);
|
||||
const receipt = JSON.parse(lines[0]);
|
||||
assert.equal(receipt.schemaVersion, 1);
|
||||
assert.equal(receipt.runtime, "BLENDER_BACKGROUND_FACTORY_STARTUP");
|
||||
assert.equal(receipt.background, true);
|
||||
assert.match(receipt.version, /^5\.2\./);
|
||||
assert.deepEqual(receipt.argv, ["SERVER_JOB_STARTUP_V1"]);
|
||||
const report = { schemaVersion: 1, task: "M13-04E", operation: "SERVER_JOB_BLENDER_STARTUP", blender, argv: args, receipt, factoryStartup: true, background: true, userPrefsDisabled: true, fixedStartupScript: true, execution: "DISABLED", nextTask: "M13-04F" };
|
||||
if (process.env.UPDATE_M13_04E_REPORT === "1") { await fs.mkdir(path.dirname(reportPath), { recursive: true }); await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); }
|
||||
assert.deepEqual(JSON.parse(await fs.readFile(reportPath, "utf8")), report);
|
||||
const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8"));
|
||||
assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-04E", parentTask: "M13-04D", nextTask: "M13-04F" });
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(await hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`);
|
||||
process.stdout.write(`server-job-startup-ok blender=5.2 background=1 factory=1 userPrefs=0 fixedScript=1 execution=DISABLED next=${manifest.nextTask}\n`);
|
||||
39
tools/web/check-server-job-workspace.mjs
Normal file
39
tools/web/check-server-job-workspace.mjs
Normal file
@@ -0,0 +1,39 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { cleanupServerJobDirectory, createServerJobDirectory, prepareServerJobWorkspace } from "./server-job-isolation.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const reportPath = path.join(root, "tests/golden/M13-04B/server-job-workspace-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M13-04B/manifest.json");
|
||||
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "m13-04b-check-"));
|
||||
const hashFile = async (file) => crypto.createHash("sha256").update(await fs.readFile(file)).digest("hex");
|
||||
try {
|
||||
const job = await createServerJobDirectory(temporary, "server:workspace");
|
||||
const workspace = await prepareServerJobWorkspace(job, new Uint8Array([1, 2, 3, 4]));
|
||||
assert.equal((await fs.stat(workspace.sourceDirectory)).mode & 0o777, 0o555);
|
||||
assert.equal((await fs.stat(workspace.sourcePath)).mode & 0o777, 0o444);
|
||||
assert.equal((await fs.stat(workspace.outputDirectory)).mode & 0o777, 0o700);
|
||||
assert.notEqual(path.dirname(workspace.sourcePath), workspace.outputDirectory);
|
||||
let sourceWrite = "ACCEPTED";
|
||||
try { await fs.writeFile(workspace.sourcePath, new Uint8Array([9])); } catch (error) { sourceWrite = error?.code ?? "ERROR"; }
|
||||
assert.equal(sourceWrite, "EACCES");
|
||||
const outputPath = path.join(workspace.outputDirectory, "result.bin");
|
||||
await fs.writeFile(outputPath, new Uint8Array([7, 8]));
|
||||
assert.deepEqual([...await fs.readFile(outputPath)], [7, 8]);
|
||||
await cleanupServerJobDirectory(job);
|
||||
await assert.rejects(fs.stat(workspace.sourcePath), { code: "ENOENT" });
|
||||
await assert.rejects(fs.stat(outputPath), { code: "ENOENT" });
|
||||
const report = { schemaVersion: 1, task: "M13-04B", operation: "SERVER_JOB_SOURCE_READONLY_OUTPUT_ISOLATION", runtime: "NODE_SERVER_FILESYSTEM", sourceDirectoryMode: "0555", sourceFileMode: "0444", outputDirectoryMode: "0700", sourceWrite: "EACCES", outputWrite: "OK", sourceOutputDistinct: true, cleanupNoResidual: true, execution: "DISABLED", nextTask: "M13-04C" };
|
||||
if (process.env.UPDATE_M13_04B_REPORT === "1") { await fs.mkdir(path.dirname(reportPath), { recursive: true }); await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); }
|
||||
assert.deepEqual(JSON.parse(await fs.readFile(reportPath, "utf8")), report);
|
||||
const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8"));
|
||||
assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-04B", parentTask: "M13-04A", nextTask: "M13-04C" });
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(await hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`);
|
||||
process.stdout.write(`server-job-workspace-ok sourceDir=0555 sourceFile=0444 sourceWrite=EACCES outputDir=0700 outputWrite=OK distinct=1 residual=0 next=${manifest.nextTask}\n`);
|
||||
} finally {
|
||||
await fs.rm(temporary, { recursive: true, force: true });
|
||||
}
|
||||
75
tools/web/check-stl-capability-fixtures.mjs
Normal file
75
tools/web/check-stl-capability-fixtures.mjs
Normal file
@@ -0,0 +1,75 @@
|
||||
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 { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const manifestPath = path.join(root, "tests/golden/M12-07D/manifest.json");
|
||||
const reportPath = path.join(root, "tests/golden/M12-07D/capability-report.json");
|
||||
const fixtureRoot = path.join(root, "tests/files/web/m12_stl_capability_v1");
|
||||
const generator = path.join(root, "tools/web/generate-stl-capability-fixtures.py");
|
||||
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
|
||||
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
const fileHash = (file) => sha256(fs.readFileSync(file));
|
||||
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
|
||||
const manifest = readJson(manifestPath);
|
||||
const report = readJson(reportPath);
|
||||
assert.deepEqual(
|
||||
{ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask },
|
||||
{ schemaVersion: 1, task: "M12-07D", parentTask: "M12-07C", nextTask: "M12-07E" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
{ schemaVersion: report.schemaVersion, task: report.task, operation: report.operation, nextTask: report.nextTask },
|
||||
{ schemaVersion: 1, task: "M12-07D", operation: "DESKTOP_STL_BINARY_ASCII_CAPABILITY", nextTask: "M12-07E" },
|
||||
);
|
||||
for (const artifact of Object.values(manifest.artifacts)) {
|
||||
if (artifact.path === manifestPath) continue;
|
||||
const absolute = path.join(root, artifact.path);
|
||||
assert.ok(fs.existsSync(absolute), `missing artifact ${artifact.path}`);
|
||||
assert.equal(fileHash(absolute), artifact.sha256, `hash mismatch ${artifact.path}`);
|
||||
}
|
||||
const inventory = readJson(path.join(root, "tests/golden/M12-05A/format-inventory.json"));
|
||||
for (const key of ["blenderVersion", "versionTuple", "buildDate", "buildTime", "buildHash", "buildBranch", "buildPlatform", "buildType", "binarySha256"]) {
|
||||
assert.deepEqual(report.runtime[key], inventory.runtime[key], `runtime drift in ${key}`);
|
||||
}
|
||||
assert.equal(report.sourceAnchor, "blender-5.2.0/source/blender/io/stl");
|
||||
assert.equal(report.operator, "wm.stl_export");
|
||||
assert.deepEqual(report.variants.map((variant) => variant.id), ["STL_BINARY", "STL_ASCII"]);
|
||||
assert.equal(report.variants[0].asciiFormat, false);
|
||||
assert.equal(report.variants[0].semantic.format, "STL_BINARY");
|
||||
assert.equal(report.variants[0].semantic.triangleCount, 2);
|
||||
assert.equal(report.variants[0].semantic.byteLength, 84 + 2 * 50);
|
||||
assert.equal(report.variants[1].asciiFormat, true);
|
||||
assert.equal(report.variants[1].semantic.format, "STL_ASCII");
|
||||
assert.equal(report.variants[1].semantic.facetCount, 2);
|
||||
assert.equal(report.variants[1].semantic.vertexCount, 6);
|
||||
for (const file of report.files) {
|
||||
const absolute = path.join(fixtureRoot, file.name);
|
||||
assert.equal(fileHash(absolute), file.sha256, `${file.name} hash`);
|
||||
assert.equal(fs.statSync(absolute).size, file.byteLength, `${file.name} byte length`);
|
||||
}
|
||||
const binary = fs.readFileSync(path.join(fixtureRoot, "capability-binary.stl"));
|
||||
assert.equal(binary.readUInt32LE(80), 2);
|
||||
assert.equal(binary.length, 184);
|
||||
const ascii = fs.readFileSync(path.join(fixtureRoot, "capability-ascii.stl"), "utf8");
|
||||
assert.match(ascii, /^solid /);
|
||||
assert.equal((ascii.match(/^facet normal/gm) ?? []).length, 2);
|
||||
assert.equal((ascii.match(/^ vertex /gm) ?? []).length, 6);
|
||||
assert.match(ascii, /\nendsolid /);
|
||||
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m12-07d-stl-"));
|
||||
try {
|
||||
const regeneratedReport = path.join(temporary, "capability-report.json");
|
||||
const result = spawnSync(blender, ["-b", "--factory-startup", "--python", generator, "--", temporary, regeneratedReport], { cwd: root, encoding: "utf8", maxBuffer: 20 * 1024 * 1024 });
|
||||
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
assert.deepEqual(readJson(regeneratedReport), report, "STL capability report is not deterministic");
|
||||
for (const file of report.files) assert.deepEqual(fs.readFileSync(path.join(temporary, file.name)), fs.readFileSync(path.join(fixtureRoot, file.name)), `${file.name} bytes are not deterministic`);
|
||||
}
|
||||
finally {
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
process.stdout.write(`stl-capability-fixtures-ok binaryTriangles=${report.variants[0].semantic.triangleCount} asciiFacets=${report.variants[1].semantic.facetCount} deterministic=true next=${manifest.nextTask}\n`);
|
||||
47
tools/web/check-stl-edge-parity.mjs
Normal file
47
tools/web/check-stl-edge-parity.mjs
Normal file
@@ -0,0 +1,47 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-07E/manifest.json"), "utf8"));
|
||||
const fixtures = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-07E/edge-fixtures.json"), "utf8"));
|
||||
const desktop = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-07E/desktop-edge-report.json"), "utf8"));
|
||||
const web = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-07E/web-edge-report.json"), "utf8"));
|
||||
const fileHash = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
|
||||
assert.deepEqual(
|
||||
{ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask },
|
||||
{ schemaVersion: 1, task: "M12-07E", parentTask: "M12-07D", nextTask: "M12-07F" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
{ schemaVersion: fixtures.schemaVersion, task: fixtures.task, operation: fixtures.operation, nextTask: fixtures.nextTask },
|
||||
{ schemaVersion: 1, task: "M12-07E", operation: "STL_EDGE_FIXTURE_GENERATION", nextTask: "M12-07F" },
|
||||
);
|
||||
assert.deepEqual(fixtures.fixtures.map((fixture) => [fixture.id, fixture.expectedCode]), [["DEGENERATE_TRIANGLE", "STL_DEGENERATE_TRIANGLE"], ["TRAILING_BYTES", "STL_TRAILING_BYTES"]]);
|
||||
assert.equal(desktop.operation, "BLENDER_STL_EDGE_PROBE");
|
||||
assert.equal(desktop.cases.length, 4);
|
||||
assert.equal(desktop.cases.find((item) => item.id === "DEGENERATE_TRIANGLE").result.triangleCount, 1);
|
||||
assert.equal(desktop.cases.find((item) => item.id === "TRAILING_BYTES").result.triangleCount, 0);
|
||||
assert.equal(web.operation, "STL_NORMAL_UNIT_EDGE_PARITY");
|
||||
assert.deepEqual(web.comparisons, {
|
||||
binaryAsciiNormalExact: true,
|
||||
desktopNormalExact: true,
|
||||
webUnitRatio: 1000,
|
||||
desktopUnitRatio: 999.999952502551,
|
||||
unitRatioExactWithinFloat32: true,
|
||||
degenerateTriangleExact: true,
|
||||
trailingBytes: { web: "BLOCKED/STL_TRAILING_BYTES", desktop: "ACCEPTED_EMPTY", parity: "STRICTER_WEB_BLOCK" },
|
||||
});
|
||||
const webById = Object.fromEntries(web.browser.map((item) => [item.id, item]));
|
||||
assert.equal(webById.BINARY_UNIT_1.result.triangleCount, 2);
|
||||
assert.equal(webById.ASCII_UNIT_1.result.triangleCount, 2);
|
||||
assert.equal(webById.DEGENERATE_TRIANGLE.result.removedDegenerateTriangles, 1);
|
||||
assert.deepEqual(webById.TRAILING_BYTES, { id: "TRAILING_BYTES", status: "BLOCKED", code: "STL_TRAILING_BYTES" });
|
||||
for (const artifact of Object.values(manifest.artifacts)) {
|
||||
const file = path.join(root, artifact.path);
|
||||
assert.ok(fs.existsSync(file), `missing artifact ${artifact.path}`);
|
||||
assert.equal(fileHash(file), artifact.sha256, `hash mismatch ${artifact.path}`);
|
||||
}
|
||||
process.stdout.write(`stl-edge-parity-ok normals=exact unitRatio=${web.comparisons.webUnitRatio} degenerate=removed trailing=${web.comparisons.trailingBytes.parity} next=${manifest.nextTask}\n`);
|
||||
38
tools/web/check-stl-web-roundtrip.mjs
Normal file
38
tools/web/check-stl-web-roundtrip.mjs
Normal file
@@ -0,0 +1,38 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-07F/manifest.json"), "utf8"));
|
||||
const report = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-07F/web-roundtrip-report.json"), "utf8"));
|
||||
const fileHash = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
|
||||
assert.deepEqual(
|
||||
{ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask },
|
||||
{ schemaVersion: 1, task: "M12-07F", parentTask: "M12-07E", nextTask: "M12-07G" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
{ schemaVersion: report.schemaVersion, task: report.task, operation: report.operation, nextTask: report.nextTask },
|
||||
{ schemaVersion: 1, task: "M12-07F", operation: "WEB_STL_TO_DESKTOP_ROUNDTRIP", nextTask: "M12-07G" },
|
||||
);
|
||||
assert.equal(report.browser.imported.triangleCount, 2);
|
||||
assert.equal(report.browser.outputBytes, 184);
|
||||
assert.deepEqual(report.browser.lossReport, {
|
||||
schemaVersion: 1,
|
||||
operation: "STL_EXPORT_LOSS_REPORT",
|
||||
canRoundTrip: true,
|
||||
warningCount: 1,
|
||||
warnings: [{ code: "STL_MATERIAL_UNSUPPORTED", severity: "warning", message: "STL has no material slots; 2 source material assignments are omitted" }],
|
||||
});
|
||||
assert.equal(report.desktop.operation, "DESKTOP_IMPORT_WEB_STL");
|
||||
assert.equal(report.desktop.triangleCount, 2);
|
||||
assert.equal(report.desktop.polygonCount, 2);
|
||||
assert.deepEqual(report.comparison, { triangleCountExact: true, normalExact: true, materialLossExplicit: true });
|
||||
for (const artifact of Object.values(manifest.artifacts)) {
|
||||
const file = path.join(root, artifact.path);
|
||||
assert.ok(fs.existsSync(file), `missing artifact ${artifact.path}`);
|
||||
assert.equal(fileHash(file), artifact.sha256, `hash mismatch ${artifact.path}`);
|
||||
}
|
||||
process.stdout.write(`stl-web-roundtrip-ok triangles=${report.desktop.triangleCount} normals=exact materialLoss=${report.browser.lossReport.warningCount} desktopExact=true next=${manifest.nextTask}\n`);
|
||||
58
tools/web/check-stl-web-roundtrip.py
Normal file
58
tools/web/check-stl-web-roundtrip.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Import a browser-produced binary STL in pinned Blender 5.2."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
def sha256_file(path):
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def main(stl_path, report_path):
|
||||
stl_path = Path(stl_path).resolve()
|
||||
report_path = Path(report_path).resolve()
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
result = bpy.ops.wm.stl_import(
|
||||
filepath=str(stl_path),
|
||||
directory=str(stl_path.parent),
|
||||
forward_axis="NEGATIVE_Z",
|
||||
up_axis="Y",
|
||||
global_scale=1.0,
|
||||
use_scene_unit=False,
|
||||
use_facet_normal=True,
|
||||
use_mesh_validate=True,
|
||||
)
|
||||
objects = [obj for obj in bpy.context.scene.objects if obj.type == "MESH"]
|
||||
if "FINISHED" not in result or not objects:
|
||||
raise RuntimeError("Blender Web STL import did not produce a mesh")
|
||||
mesh = objects[0].data
|
||||
mesh.calc_loop_triangles()
|
||||
report = {
|
||||
"schemaVersion": 1,
|
||||
"operation": "DESKTOP_IMPORT_WEB_STL",
|
||||
"sourceSha256": sha256_file(stl_path),
|
||||
"sourceBytes": stl_path.stat().st_size,
|
||||
"objectCount": len(objects),
|
||||
"vertexCount": len(mesh.vertices),
|
||||
"polygonCount": len(mesh.polygons),
|
||||
"triangleCount": len(mesh.loop_triangles),
|
||||
"polygonNormals": [[float(value) for value in polygon.normal] for polygon in mesh.polygons],
|
||||
}
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
print("stl-web-roundtrip-desktop-imported triangles=%s" % report["triangleCount"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else []
|
||||
if len(args) != 2:
|
||||
raise SystemExit("usage: blender --background --python check-stl-web-roundtrip.py -- STL REPORT")
|
||||
main(args[0], args[1])
|
||||
77
tools/web/check-supply-chain-binding.mjs
Normal file
77
tools/web/check-supply-chain-binding.mjs
Normal file
@@ -0,0 +1,77 @@
|
||||
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 { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const releaseRoot = path.join(root, "release");
|
||||
const reportPath = path.join(root, "tests/golden/M13-05E/supply-chain-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M13-05E/manifest.json");
|
||||
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
const fileSha256 = (file) => sha256(fs.readFileSync(file));
|
||||
const archive = path.join(releaseRoot, "blender-web-offline.tar.gz");
|
||||
const sourceArchive = path.join(releaseRoot, "blender-web-corresponding-source.tar.gz");
|
||||
const sumsPath = path.join(releaseRoot, "SHA256SUMS.txt");
|
||||
const sbomPath = path.join(root, "docs/web/sbom.spdx.json");
|
||||
const noticesPath = path.join(root, "docs/web/third-party-notices.json");
|
||||
const lockPath = path.join(root, "web/package-lock.json");
|
||||
const packagePath = path.join(root, "web/package.json");
|
||||
const sbom = JSON.parse(fs.readFileSync(sbomPath, "utf8"));
|
||||
const notices = JSON.parse(fs.readFileSync(noticesPath, "utf8"));
|
||||
const lockBytes = fs.readFileSync(lockPath);
|
||||
const noticesBytes = fs.readFileSync(noticesPath);
|
||||
const lockHash = sha256(lockBytes);
|
||||
const noticesHash = sha256(noticesBytes);
|
||||
assert.equal(sbom.spdxVersion, "SPDX-2.3");
|
||||
assert.equal(sbom.documentNamespace, `https://blender-web.local/spdx/${sha256(Buffer.concat([lockBytes, noticesBytes]))}`);
|
||||
const rootPackage = sbom.packages.find((item) => item.SPDXID === "SPDXRef-Package-blender-web-editor");
|
||||
assert.ok(rootPackage);
|
||||
assert.equal(rootPackage.checksums?.find((item) => item.algorithm === "SHA256")?.checksumValue, lockHash);
|
||||
assert.ok(notices.packages.length > 0);
|
||||
assert.ok(fs.statSync(archive).isFile());
|
||||
assert.ok(fs.statSync(sourceArchive).isFile());
|
||||
const sums = new Map(fs.readFileSync(sumsPath, "utf8").trim().split(/\r?\n/u).map((line) => {
|
||||
const match = line.match(/^([a-f0-9]{64}) (.+)$/u);
|
||||
assert.ok(match, `invalid checksum line ${line}`);
|
||||
return [match[2], match[1]];
|
||||
}));
|
||||
assert.equal(sums.get(path.basename(archive)), fileSha256(archive));
|
||||
assert.equal(sums.get(path.basename(sourceArchive)), fileSha256(sourceArchive));
|
||||
function archiveEntries(file) {
|
||||
return execFileSync("tar", ["-tzf", file], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 }).split(/\r?\n/u).filter(Boolean);
|
||||
}
|
||||
function archiveFile(file, entry) {
|
||||
return execFileSync("tar", ["-xOf", file, entry], { maxBuffer: 64 * 1024 * 1024 });
|
||||
}
|
||||
const binaryEntries = archiveEntries(archive);
|
||||
const sourceEntries = archiveEntries(sourceArchive);
|
||||
for (const entry of ["blender-web-offline/sbom.spdx.json", "blender-web-offline/third-party-notices.json", "blender-web-offline/SOURCE_OFFER.txt", "blender-web-offline/manifest.json"]) assert.ok(binaryEntries.includes(entry), `binary archive omits ${entry}`);
|
||||
for (const entry of ["web/package.json", "web/package-lock.json", "docs/web/sbom.spdx.json", "docs/web/third-party-notices.json", "docs/web/DEPLOYMENT.md", "tools/web/create-offline-release.mjs"]) assert.ok(sourceEntries.includes(entry), `source archive omits ${entry}`);
|
||||
const sourceOffer = archiveFile(archive, "blender-web-offline/SOURCE_OFFER.txt").toString("utf8");
|
||||
assert.match(sourceOffer, /blender-web-corresponding-source\.tar\.gz/u);
|
||||
assert.match(sourceOffer, /SHA256SUMS\.txt/u);
|
||||
const embeddedPackage = archiveFile(sourceArchive, "web/package.json");
|
||||
const embeddedLock = archiveFile(sourceArchive, "web/package-lock.json");
|
||||
assert.equal(sha256(embeddedPackage), fileSha256(packagePath));
|
||||
assert.equal(sha256(embeddedLock), lockHash);
|
||||
const embeddedSbom = archiveFile(archive, "blender-web-offline/sbom.spdx.json");
|
||||
assert.deepEqual(JSON.parse(embeddedSbom), sbom);
|
||||
const commit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim();
|
||||
assert.match(commit, /^[a-f0-9]{40}$/u);
|
||||
const report = {
|
||||
schemaVersion: 1, task: "M13-05E", operation: "SUPPLY_CHAIN_BINDING", commit,
|
||||
inputs: { packageSha256: fileSha256(packagePath), lockfileSha256: lockHash, sbomSha256: fileSha256(sbomPath), noticesSha256: noticesHash },
|
||||
archives: { binary: { path: path.relative(root, archive), sha256: fileSha256(archive), entries: binaryEntries.length }, source: { path: path.relative(root, sourceArchive), sha256: fileSha256(sourceArchive), entries: sourceEntries.length } },
|
||||
sourceOffer: "BOUND_TO_CORRESPONDING_SOURCE_ARCHIVE_AND_SHA256SUMS",
|
||||
checks: { spdx23: true, lockfileBound: true, noticesBound: true, sourceOfferBound: true, archiveChecksumsBound: true, sourcePackageBound: true },
|
||||
execution: "DISABLED", nextTask: "M13-05F",
|
||||
};
|
||||
if (process.env.UPDATE_M13_05E_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); }
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-05E", parentTask: "M13-05D", nextTask: "M13-05F" });
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileSha256(path.join(root, artifact.path)), artifact.sha256, artifact.path);
|
||||
process.stdout.write(`supply-chain-binding-ok sbom=SPDX-2.3 lockfile=BOUND notices=BOUND sourceOffer=BOUND binarySha256=${report.archives.binary.sha256} sourceSha256=${report.archives.source.sha256} execution=DISABLED next=M13-05F\n`);
|
||||
64
tools/web/check-webkit-capability.mjs
Normal file
64
tools/web/check-webkit-capability.mjs
Normal file
@@ -0,0 +1,64 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const distRoot = path.join(root, "web/dist");
|
||||
const reportPath = path.join(root, "tests/golden/M14-01C/webkit-capability-report.json");
|
||||
const manifestPath = path.join(root, "tests/golden/M14-01C/manifest.json");
|
||||
const digest = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
||||
const fileDigest = (file) => digest(fs.readFileSync(file));
|
||||
const mime = new Map([[".html", "text/html; charset=utf-8"], [".js", "text/javascript; charset=utf-8"], [".css", "text/css; charset=utf-8"], [".json", "application/json"], [".wasm", "application/wasm"]]);
|
||||
const workerName = fs.readdirSync(path.join(distRoot, "assets")).find((name) => /^storage\.worker-[\w-]+\.js$/u.test(name));
|
||||
const wasmName = fs.readdirSync(path.join(distRoot, "assets")).find((name) => /^web_engine-[\w-]+\.wasm$/u.test(name));
|
||||
assert.ok(workerName && wasmName, "production worker/WASM assets are missing");
|
||||
const server = http.createServer((request, response) => {
|
||||
const pathname = decodeURIComponent(new URL(request.url ?? "/", "http://127.0.0.1").pathname);
|
||||
const relative = pathname === "/" ? "index.html" : pathname.replace(/^\//u, "");
|
||||
const file = path.resolve(distRoot, relative);
|
||||
if (!file.startsWith(`${distRoot}${path.sep}`) || !fs.existsSync(file) || !fs.statSync(file).isFile()) { response.writeHead(404); response.end("not found"); return; }
|
||||
response.statusCode = 200;
|
||||
response.setHeader("Content-Type", mime.get(path.extname(file)) ?? "application/octet-stream");
|
||||
response.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
||||
response.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
|
||||
response.setHeader("Cross-Origin-Resource-Policy", "same-origin");
|
||||
response.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'; font-src 'self'; img-src 'self'; media-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'");
|
||||
fs.createReadStream(file).pipe(response);
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
let browser;
|
||||
try {
|
||||
const { webkit } = await import(pathToFileURL(path.join(root, "web/node_modules/playwright/index.mjs")).href);
|
||||
browser = await webkit.launch({ headless: true });
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address === "object");
|
||||
await page.goto(`http://127.0.0.1:${address.port}/`, { waitUntil: "load" });
|
||||
const probe = await page.evaluate(async ({ workerPath, wasmPath }) => {
|
||||
const run = async (name, operation) => { try { return { name, ...await operation() }; } catch (error) { return { name, status: "BLOCKED", code: error instanceof Error ? error.name : "PROBE_FAILED", detail: error instanceof Error ? error.message.slice(0, 200) : String(error) }; } };
|
||||
const wasm = await run("wasm", async () => { const bytes = await fetch(wasmPath).then((response) => { if (!response.ok) throw new Error(`HTTP_${response.status}`); return response.arrayBuffer(); }); await WebAssembly.compile(bytes); return { status: "PASS", code: "WASM_READY", bytes: bytes.byteLength }; });
|
||||
const worker = await run("worker", async () => { await new Promise((resolve, reject) => { const value = new Worker(workerPath, { type: "module" }); const timer = setTimeout(() => { value.terminate(); resolve(); }, 500); value.onerror = (event) => { clearTimeout(timer); value.terminate(); reject(new Error(event.message || "WORKER_LOAD_FAILED")); }; }); return { status: "PASS", code: "WORKER_READY" }; });
|
||||
const opfs = await run("opfs", async () => { if (!navigator.storage || typeof navigator.storage.getDirectory !== "function") return { status: "BLOCKED", code: "OPFS_UNAVAILABLE" }; const directory = await navigator.storage.getDirectory(); const probeDirectory = await directory.getDirectoryHandle("m14-webkit-probe", { create: true }); const handle = await probeDirectory.getFileHandle("probe.bin", { create: true }); const writable = await handle.createWritable(); await writable.write(new Uint8Array([1, 2, 3])); await writable.close(); await probeDirectory.removeEntry("probe.bin"); await directory.removeEntry("m14-webkit-probe"); return { status: "PASS", code: "OPFS_READY" }; });
|
||||
const indexeddb = await run("indexedDB", async () => { if (!indexedDB) return { status: "BLOCKED", code: "INDEXEDDB_UNAVAILABLE" }; const name = "m14-webkit-probe"; await new Promise((resolve, reject) => { const request = indexedDB.open(name, 1); request.onupgradeneeded = () => request.result.createObjectStore("probe"); request.onsuccess = () => { request.result.close(); resolve(); }; request.onerror = () => reject(request.error ?? new Error("INDEXEDDB_OPEN_FAILED")); }); await new Promise((resolve) => { const request = indexedDB.deleteDatabase(name); request.onsuccess = request.onerror = request.onblocked = () => resolve(); }); return { status: "PASS", code: "INDEXEDDB_READY" }; });
|
||||
const webgl2 = await run("webgl2", async () => { const canvas = document.createElement("canvas"); const gl = canvas.getContext("webgl2"); if (!gl) return { status: "BLOCKED", code: "WEBGL2_UNAVAILABLE" }; const debug = gl.getExtension("WEBGL_debug_renderer_info"); return { status: "PASS", code: "WEBGL2_READY", renderer: debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) : "REDACTED" }; });
|
||||
const webgpu = await run("webgpu", async () => { if (!("gpu" in navigator)) return { status: "BLOCKED", code: "WEBGPU_UNAVAILABLE" }; const adapter = await navigator.gpu.requestAdapter(); if (!adapter) return { status: "BLOCKED", code: "WEBGPU_ADAPTER_UNAVAILABLE" }; return { status: "PASS", code: "WEBGPU_READY", adapter: adapter.info?.description ?? adapter.name ?? "REDACTED" }; });
|
||||
const offscreen = await run("offscreen", async () => { if (typeof OffscreenCanvas !== "function") return { status: "BLOCKED", code: "OFFSCREEN_UNAVAILABLE" }; const canvas = new OffscreenCanvas(2, 2); return { status: "PASS", code: "OFFSCREEN_READY", context2d: Boolean(canvas.getContext("2d")) }; });
|
||||
const isolation = { name: "isolation", status: crossOriginIsolated ? "PASS" : "BLOCKED", code: crossOriginIsolated ? "ISOLATION_READY" : "ISOLATION_REQUIRED" };
|
||||
return { userAgent: navigator.userAgent, platform: navigator.platform, hardwareConcurrency: navigator.hardwareConcurrency, capabilities: [wasm, worker, opfs, indexeddb, webgl2, webgpu, offscreen, isolation] };
|
||||
}, { workerPath: `/assets/${workerName}`, wasmPath: `/assets/${wasmName}` });
|
||||
const report = { schemaVersion: 1, task: "M14-01C", operation: "WEBKIT_CAPABILITY_PROBE", runtime: "PLAYWRIGHT_WEBKIT", browser: { version: await browser.version(), executablePath: (await import(pathToFileURL(path.join(root, "web/node_modules/playwright/index.mjs")).href)).webkit.executablePath(), userAgent: probe.userAgent, platform: probe.platform, hardwareConcurrency: probe.hardwareConcurrency }, assets: { worker: { path: `web/dist/assets/${workerName}`, sha256: fileDigest(path.join(distRoot, "assets", workerName)) }, wasm: { path: `web/dist/assets/${wasmName}`, sha256: fileDigest(path.join(distRoot, "assets", wasmName)) } }, capabilities: Object.fromEntries(probe.capabilities.map((item) => [item.name, item])), supportRule: "PASS_ONLY_WHEN_PROBED; BLOCKED_OR_UNAVAILABLE_DOES_NOT_CLAIM_SUPPORT", execution: "DISABLED", nextTask: "M14-01D" };
|
||||
if (process.env.UPDATE_M14_01C_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); }
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M14-01C", parentTask: "M14-01B", nextTask: "M14-01D" });
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileDigest(path.join(root, artifact.path)), artifact.sha256, artifact.path);
|
||||
const summary = probe.capabilities.map((item) => `${item.name}=${item.status}`).join(",");
|
||||
process.stdout.write(`webkit-capability-ok version=${report.browser.version} ${summary} execution=DISABLED next=${manifest.nextTask}\n`);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
108
tools/web/fuzz-case-runner.mjs
Normal file
108
tools/web/fuzz-case-runner.mjs
Normal file
@@ -0,0 +1,108 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import ts from "../../web/node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const domain = process.argv[2];
|
||||
const seed = Number.parseInt(process.argv[3] ?? "0", 10) >>> 0;
|
||||
const iteration = Number.parseInt(process.argv[4] ?? "0", 10);
|
||||
if (!["blend", "image", "font", "node", "manifest"].includes(domain) || !Number.isSafeInteger(iteration) || iteration < 0) process.exit(64);
|
||||
let state = (seed ^ Math.imul(iteration + 1, 0x9e3779b1)) >>> 0;
|
||||
const random = () => { state ^= state << 13; state ^= state >>> 17; state ^= state << 5; return state >>> 0; };
|
||||
const mutate = (source) => {
|
||||
let bytes = Buffer.from(source);
|
||||
if (iteration % 7 === 0) bytes = bytes.subarray(0, Math.max(0, Math.min(bytes.length, random() % Math.max(1, bytes.length))));
|
||||
else for (let index = 0; index < 1 + iteration % 8 && bytes.length > 0; index++) bytes[random() % bytes.length] ^= 1 << (random() % 8);
|
||||
return bytes;
|
||||
};
|
||||
const digest = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), `m13-fuzz-${domain}-`));
|
||||
const transpile = (name, replacements = []) => {
|
||||
const sourcePath = path.join(root, "web/protocol", `${name}.ts`);
|
||||
const result = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: sourcePath, reportDiagnostics: true });
|
||||
if (result.diagnostics?.length) throw new Error(`TRANSPILE_FAILED:${name}`);
|
||||
const output = replacements.reduce((value, [from, to]) => value.replaceAll(from, to), result.outputText);
|
||||
fs.writeFileSync(path.join(temporary, `${name}.mjs`), output);
|
||||
};
|
||||
const result = (status, code) => process.stdout.write(`${JSON.stringify({ domain, seed, iteration, status, code })}\n`);
|
||||
try {
|
||||
if (domain === "blend") {
|
||||
const [{ default: factory }, wasmBinary, source] = await Promise.all([
|
||||
import(pathToFileURL(path.join(root, "web/app/src/vendor/blender/web_engine.js")).href),
|
||||
fs.promises.readFile(path.join(root, "web/app/src/vendor/blender/web_engine.wasm")),
|
||||
fs.promises.readFile(path.join(root, "tests/files/web/basic_scene.blend")),
|
||||
]);
|
||||
const bytes = mutate(source);
|
||||
const engine = await factory({ wasmBinary });
|
||||
const handle = engine._web_engine_create();
|
||||
let pointer = 0;
|
||||
try {
|
||||
if (bytes.length) { pointer = engine._malloc(bytes.length); engine.HEAPU8.set(bytes, pointer); }
|
||||
const code = engine._web_engine_open_blend(handle, pointer, bytes.length);
|
||||
result(code === 0 ? "ACCEPTED" : "REJECTED", code === 0 ? "OK" : "BLEND_OPEN_INVALID");
|
||||
} finally {
|
||||
if (pointer) engine._free(pointer);
|
||||
engine._web_engine_destroy(handle);
|
||||
}
|
||||
} else if (domain === "image") {
|
||||
transpile("asset-preview");
|
||||
transpile("asset-preview-decode", [['from "./asset-preview"', 'from "./asset-preview.mjs"']]);
|
||||
const identityProtocol = await import(pathToFileURL(path.join(temporary, "asset-preview.mjs")).href);
|
||||
const decode = await import(pathToFileURL(path.join(temporary, "asset-preview-decode.mjs")).href);
|
||||
const source = fs.readFileSync(path.join(root, "tests/golden/M12-02B/preview.png"));
|
||||
const bytes = mutate(source);
|
||||
const original = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-02B/identity.json"), "utf8"));
|
||||
const value = { ...original, content: { ...original.content, byteLength: bytes.length, sha256: digest(bytes) } };
|
||||
delete value.identitySha256;
|
||||
const identity = await identityProtocol.createAssetPreviewIdentity(value);
|
||||
await decode.planAssetPreviewDecode(identity, bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
|
||||
result("ACCEPTED", "OK");
|
||||
} else if (domain === "font") {
|
||||
transpile("asset-path");
|
||||
transpile("external-vfont", [['from "./asset-path"', 'from "./asset-path.mjs"']]);
|
||||
const font = await import(pathToFileURL(path.join(temporary, "external-vfont.mjs")).href);
|
||||
const bytes = mutate(fs.readFileSync(path.join(root, "blender-5.2.0/release/datafiles/bfont.pfb")));
|
||||
const data = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||
await font.validateExternalVFontImport({ sourcePath: "//fonts/fuzz.pfb", mimeType: "application/x-font-type1", byteLength: bytes.length, sha256: digest(bytes), data });
|
||||
result("ACCEPTED", "OK");
|
||||
} else if (domain === "node") {
|
||||
transpile("shader-compiler");
|
||||
const shader = await import(pathToFileURL(path.join(temporary, "shader-compiler.mjs")).href);
|
||||
const material = { id: "material:fuzz", name: "fuzz", baseColor: [0.2, 0.3, 0.4, 1], roughness: 0.5, metallic: 0, emissionColor: [0, 0, 0, 1], alpha: 1, ior: 1.45, shaderGraphHash: "a".repeat(64), nodes: [{ id: "principled", type: "PRINCIPLED", name: "Principled" }, { id: "output", type: "OUTPUT", name: "Output" }], links: [{ fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" }] };
|
||||
switch (iteration % 6) {
|
||||
case 0: material.nodes.push({ id: `unknown-${random()}`, type: "UNSUPPORTED", name: "Unknown" }); break;
|
||||
case 1: material.nodes.push({ ...material.nodes[0] }); break;
|
||||
case 2: material.links.push({ fromNodeId: "output", fromSocket: "Surface", toNodeId: "principled", toSocket: "Base Color" }); break;
|
||||
case 3: material.shaderGraphHash = digest(Buffer.from(String(random()))).slice(1); break;
|
||||
case 4: material.nodes[0].id = "x".repeat(4096); break;
|
||||
default: material.roughness = Number.NaN;
|
||||
}
|
||||
const compiled = shader.compileMaterialGraph(material);
|
||||
result(compiled.status === "BLOCKED" ? "REJECTED" : "ACCEPTED", compiled.issues?.[0]?.code ?? "OK");
|
||||
} else {
|
||||
for (const name of ["asset-path", "capability-gates", "scripting-platform"]) transpile(name, [['from "./asset-path"', 'from "./asset-path.mjs"'], ['from "./capability-gates"', 'from "./capability-gates.mjs"']]);
|
||||
const protocol = await import(pathToFileURL(path.join(temporary, "scripting-platform.mjs")).href);
|
||||
const script = { id: "fuzz", name: "fuzz", entryPath: "scripts/fuzz.py", sourceByteLength: 128, sourceSha256: "a".repeat(64), publisher: "local", signature: "b".repeat(128), keyId: "key:local", permissions: ["READ_MAIN"], dependencies: [], module: false, cpuMs: 1000, memoryBytes: 1024 * 1024, wallMs: 5000, network: false, autorun: false, driverExpressions: false, addonInstall: false };
|
||||
const manifest = { schemaVersion: 1, scripts: [script] };
|
||||
switch (iteration % 6) {
|
||||
case 0: script.entryPath = `../${random()}.py`; break;
|
||||
case 1: script.sourceByteLength = Number.MAX_SAFE_INTEGER; break;
|
||||
case 2: script.permissions = ["UNKNOWN"]; break;
|
||||
case 3: script.dependencies = [{ id: "fuzz", sourceSha256: "x", sourcePath: "../dep.py" }]; break;
|
||||
case 4: manifest.schemaVersion = 2; break;
|
||||
default: script.module = true;
|
||||
}
|
||||
protocol.parseScriptingManifest(manifest);
|
||||
result("ACCEPTED", "OK");
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const code = error?.code ?? message.match(/^([A-Z][A-Z0-9_]+):/u)?.[1] ?? "STRUCTURED_REJECTION";
|
||||
result("REJECTED", code);
|
||||
} finally {
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
79
tools/web/generate-dependency-inventory.mjs
Normal file
79
tools/web/generate-dependency-inventory.mjs
Normal file
@@ -0,0 +1,79 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const packagePath = path.join(root, "web/package.json");
|
||||
const lockPath = path.join(root, "web/package-lock.json");
|
||||
const outputPath = path.resolve(process.argv[2] ?? path.join(root, "tests/golden/M13-05C/dependency-inventory.json"));
|
||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
||||
const lock = JSON.parse(fs.readFileSync(lockPath, "utf8"));
|
||||
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
const packageBytes = fs.readFileSync(packagePath);
|
||||
const lockBytes = fs.readFileSync(lockPath);
|
||||
const entries = new Map(Object.entries(lock.packages));
|
||||
const packageName = (packageKey) => {
|
||||
const marker = packageKey.lastIndexOf("node_modules/");
|
||||
return marker < 0 ? packageKey : packageKey.slice(marker + "node_modules/".length);
|
||||
};
|
||||
function resolveDependency(fromKey, dependency) {
|
||||
let base = fromKey;
|
||||
while (true) {
|
||||
const candidate = base ? `${base}/node_modules/${dependency}` : `node_modules/${dependency}`;
|
||||
if (entries.has(candidate)) return candidate;
|
||||
const marker = base.lastIndexOf("/node_modules/");
|
||||
if (marker < 0) return entries.has(`node_modules/${dependency}`) ? `node_modules/${dependency}` : null;
|
||||
base = base.slice(0, marker);
|
||||
}
|
||||
}
|
||||
function closure(roots) {
|
||||
const visited = new Set();
|
||||
const queue = roots.map((name) => resolveDependency("", name)).filter(Boolean);
|
||||
while (queue.length) {
|
||||
const key = queue.shift();
|
||||
if (!key || visited.has(key)) continue;
|
||||
visited.add(key);
|
||||
const entry = entries.get(key);
|
||||
for (const dependency of Object.keys({ ...(entry.dependencies ?? {}), ...(entry.optionalDependencies ?? {}) })) {
|
||||
const next = resolveDependency(key, dependency);
|
||||
if (next) queue.push(next);
|
||||
}
|
||||
}
|
||||
return visited;
|
||||
}
|
||||
const roots = {
|
||||
production: Object.keys(packageJson.dependencies ?? {}),
|
||||
build: ["@eslint/js", "@types/react", "@types/react-dom", "@types/three", "@vitejs/plugin-react", "eslint", "typescript", "typescript-eslint", "vite"],
|
||||
test: ["@axe-core/playwright", "@playwright/test"],
|
||||
};
|
||||
const categories = Object.fromEntries(Object.entries(roots).map(([category, names]) => [category, closure(names)]));
|
||||
const allKeys = new Set([...categories.production, ...categories.build, ...categories.test]);
|
||||
const packages = [...allKeys].sort().map((key) => {
|
||||
const entry = entries.get(key);
|
||||
const category = Object.entries(categories).filter(([, keys]) => keys.has(key)).map(([name]) => name);
|
||||
return {
|
||||
path: key,
|
||||
name: entry.name ?? packageName(key),
|
||||
version: entry.version,
|
||||
resolved: entry.resolved ?? null,
|
||||
integrity: entry.integrity ?? null,
|
||||
categories: category,
|
||||
dependencies: Object.keys({ ...(entry.dependencies ?? {}), ...(entry.optionalDependencies ?? {}) }).sort(),
|
||||
};
|
||||
});
|
||||
const inventory = {
|
||||
schemaVersion: 1,
|
||||
task: "M13-05C",
|
||||
operation: "NPM_DEPENDENCY_INVENTORY",
|
||||
package: { path: "web/package.json", sha256: sha256(packageBytes), name: lock.name, version: lock.version, lockfileVersion: lock.lockfileVersion },
|
||||
lockfile: { path: "web/package-lock.json", sha256: sha256(lockBytes), packageCount: entries.size - 1 },
|
||||
roots,
|
||||
categoryCounts: Object.fromEntries(Object.entries(categories).map(([name, keys]) => [name, keys.size])),
|
||||
sharedCount: packages.filter((item) => item.categories.length > 1).length,
|
||||
packages,
|
||||
nextTask: "M13-05D",
|
||||
};
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
fs.writeFileSync(outputPath, `${JSON.stringify(inventory, null, 2)}\n`);
|
||||
process.stdout.write(`dependency-inventory-generated production=${inventory.categoryCounts.production} build=${inventory.categoryCounts.build} test=${inventory.categoryCounts.test} shared=${inventory.sharedCount} next=${inventory.nextTask}\n`);
|
||||
455
tools/web/generate-glb-desktop-fixtures.py
Normal file
455
tools/web/generate-glb-desktop-fixtures.py
Normal file
@@ -0,0 +1,455 @@
|
||||
"""Generate the bounded Blender 5.2 desktop GLB fixture group for M12-06A.
|
||||
|
||||
The generator deliberately keeps each feature in its own file. Later import and
|
||||
round-trip tasks can therefore fail on one capability without hiding it behind a
|
||||
large all-in-one scene.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
FIXTURES = (
|
||||
("mesh", "M12 Mesh Fixture", "mesh.glb"),
|
||||
("pbr", "M12 PBR Fixture", "pbr.glb"),
|
||||
("uv", "M12 UV Fixture", "uv.glb"),
|
||||
("skin", "M12 Skin Fixture", "skin.glb"),
|
||||
("animation", "M12 Animation Fixture", "animation.glb"),
|
||||
)
|
||||
|
||||
|
||||
def sha256_file(path):
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def reset():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
scene = bpy.context.scene
|
||||
scene.frame_start = 1
|
||||
scene.frame_end = 25
|
||||
scene.render.fps = 24
|
||||
scene.unit_settings.system = "METRIC"
|
||||
scene.unit_settings.scale_length = 1.0
|
||||
return scene
|
||||
|
||||
|
||||
def mesh_object(name, vertices, faces):
|
||||
mesh = bpy.data.meshes.new(name + " Mesh")
|
||||
mesh.from_pydata(vertices, [], faces)
|
||||
mesh.update()
|
||||
obj = bpy.data.objects.new(name, mesh)
|
||||
bpy.context.scene.collection.objects.link(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def select_only(objects):
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
for obj in objects:
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = objects[0]
|
||||
|
||||
|
||||
def export_selected(path):
|
||||
result = bpy.ops.export_scene.gltf(
|
||||
filepath=str(path),
|
||||
export_format="GLB",
|
||||
use_selection=True,
|
||||
export_apply=False,
|
||||
export_animations=True,
|
||||
export_animation_mode="ACTIONS",
|
||||
export_frame_range=True,
|
||||
export_frame_step=1,
|
||||
export_force_sampling=True,
|
||||
export_skins=True,
|
||||
export_all_influences=True,
|
||||
export_morph=True,
|
||||
export_morph_animation=True,
|
||||
export_attributes=True,
|
||||
export_texcoords=True,
|
||||
export_normals=True,
|
||||
export_tangents=False,
|
||||
export_materials="EXPORT",
|
||||
export_image_format="AUTO",
|
||||
export_cameras=False,
|
||||
export_lights=False,
|
||||
export_draco_mesh_compression_enable=False,
|
||||
export_meshopt_compression_enable=False,
|
||||
export_try_sparse_sk=False,
|
||||
export_try_omit_sparse_sk=False,
|
||||
export_current_frame=False,
|
||||
export_yup=True,
|
||||
)
|
||||
if "FINISHED" not in result:
|
||||
raise RuntimeError("Blender GLB export did not finish: %s" % (result,))
|
||||
|
||||
|
||||
def add_pbr_material(name, color, metallic, roughness):
|
||||
material = bpy.data.materials.new(name)
|
||||
material.use_nodes = True
|
||||
material.diffuse_color = (*color, 1.0)
|
||||
principled = material.node_tree.nodes.get("Principled BSDF")
|
||||
principled.inputs["Base Color"].default_value = (*color, 1.0)
|
||||
principled.inputs["Metallic"].default_value = metallic
|
||||
principled.inputs["Roughness"].default_value = roughness
|
||||
if principled.inputs.get("IOR"):
|
||||
principled.inputs["IOR"].default_value = 1.45
|
||||
return material
|
||||
|
||||
|
||||
def add_uv_layer(obj):
|
||||
layer = obj.data.uv_layers.new(name="UVMap")
|
||||
values = ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0))
|
||||
for loop, value in zip(layer.data, values):
|
||||
loop.uv = value
|
||||
|
||||
|
||||
def add_corner_colors(obj):
|
||||
colors = obj.data.color_attributes.new(name="M12Color", type="FLOAT_COLOR", domain="CORNER")
|
||||
values = (
|
||||
(1.0, 0.0, 0.0, 1.0),
|
||||
(0.0, 1.0, 0.0, 1.0),
|
||||
(0.0, 0.0, 1.0, 1.0),
|
||||
(1.0, 1.0, 0.0, 1.0),
|
||||
)
|
||||
for item, value in zip(colors.data, values):
|
||||
item.color = value
|
||||
obj.data.color_attributes.active_color_index = 0
|
||||
|
||||
|
||||
def use_corner_colors(material):
|
||||
vertex_color = material.node_tree.nodes.new("ShaderNodeVertexColor")
|
||||
vertex_color.layer_name = "M12Color"
|
||||
principled = material.node_tree.nodes.get("Principled BSDF")
|
||||
material.node_tree.links.new(vertex_color.outputs["Color"], principled.inputs["Base Color"])
|
||||
|
||||
|
||||
def create_mesh_fixture():
|
||||
reset()
|
||||
obj = mesh_object(
|
||||
"M12 Mesh Fixture",
|
||||
[(-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (1.0, 1.0, 0.0), (-1.0, 1.0, 0.0)],
|
||||
[(0, 1, 2, 3)],
|
||||
)
|
||||
add_corner_colors(obj)
|
||||
material = add_pbr_material("M12 Mesh Material", (0.22, 0.48, 0.83), 0.15, 0.55)
|
||||
use_corner_colors(material)
|
||||
obj.data.materials.append(material)
|
||||
select_only([obj])
|
||||
|
||||
|
||||
def create_pbr_fixture():
|
||||
reset()
|
||||
obj = mesh_object(
|
||||
"M12 PBR Fixture",
|
||||
[(-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (1.0, 1.0, 0.0), (-1.0, 1.0, 0.0)],
|
||||
[(0, 1, 2, 3)],
|
||||
)
|
||||
material = add_pbr_material("M12 PBR Material", (0.31, 0.57, 0.91), 0.72, 0.28)
|
||||
principled = material.node_tree.nodes.get("Principled BSDF")
|
||||
principled.inputs["Emission Color"].default_value = (0.02, 0.04, 0.08, 1.0)
|
||||
if principled.inputs.get("Emission Strength"):
|
||||
principled.inputs["Emission Strength"].default_value = 1.5
|
||||
obj.data.materials.append(material)
|
||||
select_only([obj])
|
||||
|
||||
|
||||
def create_uv_fixture():
|
||||
reset()
|
||||
obj = mesh_object(
|
||||
"M12 UV Fixture",
|
||||
[(-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (1.0, 1.0, 0.0), (-1.0, 1.0, 0.0)],
|
||||
[(0, 1, 2, 3)],
|
||||
)
|
||||
add_uv_layer(obj)
|
||||
material = add_pbr_material("M12 UV Material", (1.0, 1.0, 1.0), 0.0, 0.45)
|
||||
image = bpy.data.images.new("M12 UV Texture", width=2, height=2, alpha=True)
|
||||
image.colorspace_settings.name = "sRGB"
|
||||
image.pixels = (
|
||||
1.0, 0.1, 0.1, 1.0,
|
||||
0.1, 1.0, 0.1, 1.0,
|
||||
0.1, 0.1, 1.0, 1.0,
|
||||
1.0, 1.0, 0.1, 1.0,
|
||||
)
|
||||
image.pack()
|
||||
texture = material.node_tree.nodes.new("ShaderNodeTexImage")
|
||||
texture.name = "M12 UV Image Texture"
|
||||
texture.image = image
|
||||
texture.interpolation = "Closest"
|
||||
texture.extension = "REPEAT"
|
||||
principled = material.node_tree.nodes.get("Principled BSDF")
|
||||
material.node_tree.links.new(texture.outputs["Color"], principled.inputs["Base Color"])
|
||||
obj.data.materials.append(material)
|
||||
select_only([obj])
|
||||
|
||||
|
||||
def create_armature(name):
|
||||
armature_data = bpy.data.armatures.new(name + " Data")
|
||||
armature = bpy.data.objects.new(name, armature_data)
|
||||
bpy.context.scene.collection.objects.link(armature)
|
||||
bpy.context.view_layer.objects.active = armature
|
||||
armature.select_set(True)
|
||||
bpy.ops.object.mode_set(mode="EDIT")
|
||||
root = armature_data.edit_bones.new("Root")
|
||||
root.head = (0.0, 0.0, 0.0)
|
||||
root.tail = (0.0, 0.0, 1.0)
|
||||
tip = armature_data.edit_bones.new("Tip")
|
||||
tip.head = (0.0, 0.0, 1.0)
|
||||
tip.tail = (0.0, 0.0, 2.0)
|
||||
tip.parent = root
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
return armature
|
||||
|
||||
|
||||
def create_skin_fixture():
|
||||
reset()
|
||||
armature = create_armature("M12 Skin Armature")
|
||||
obj = mesh_object(
|
||||
"M12 Skin Fixture",
|
||||
[(-1.0, -0.5, 0.0), (1.0, -0.5, 0.0), (1.0, 0.5, 0.0), (-1.0, 0.5, 0.0)],
|
||||
[(0, 1, 2, 3)],
|
||||
)
|
||||
root = obj.vertex_groups.new(name="Root")
|
||||
tip = obj.vertex_groups.new(name="Tip")
|
||||
root.add([0, 3], 0.75, "REPLACE")
|
||||
tip.add([0, 3], 0.25, "REPLACE")
|
||||
root.add([1, 2], 0.2, "REPLACE")
|
||||
tip.add([1, 2], 0.8, "REPLACE")
|
||||
modifier = obj.modifiers.new(name="M12 Armature Deform", type="ARMATURE")
|
||||
modifier.object = armature
|
||||
obj.parent = armature
|
||||
material = add_pbr_material("M12 Skin Material", (0.76, 0.24, 0.18), 0.05, 0.5)
|
||||
obj.data.materials.append(material)
|
||||
select_only([armature, obj])
|
||||
|
||||
|
||||
def create_animation_fixture():
|
||||
reset()
|
||||
obj = mesh_object(
|
||||
"M12 Animation Fixture",
|
||||
[(-0.75, -0.75, 0.0), (0.75, -0.75, 0.0), (0.0, 0.75, 0.0)],
|
||||
[(0, 1, 2)],
|
||||
)
|
||||
material = add_pbr_material("M12 Animation Material", (0.16, 0.72, 0.38), 0.1, 0.4)
|
||||
obj.data.materials.append(material)
|
||||
obj.location = (-1.0, 0.0, 0.0)
|
||||
obj.rotation_mode = "XYZ"
|
||||
obj.keyframe_insert(data_path="location", frame=1)
|
||||
obj.keyframe_insert(data_path="rotation_euler", frame=1)
|
||||
obj.location = (0.0, 0.5, 0.25)
|
||||
obj.rotation_euler[2] = 0.75
|
||||
obj.keyframe_insert(data_path="location", frame=13)
|
||||
obj.keyframe_insert(data_path="rotation_euler", frame=13)
|
||||
obj.location = (1.0, 0.0, 0.0)
|
||||
obj.rotation_euler[2] = 1.5
|
||||
obj.keyframe_insert(data_path="location", frame=25)
|
||||
obj.keyframe_insert(data_path="rotation_euler", frame=25)
|
||||
if obj.animation_data and obj.animation_data.action:
|
||||
obj.animation_data.action.name = "M12 Animation Action"
|
||||
select_only([obj])
|
||||
|
||||
|
||||
def read_glb(path):
|
||||
payload = path.read_bytes()
|
||||
if len(payload) < 20 or payload[:4] != b"glTF":
|
||||
raise RuntimeError("invalid GLB header: %s" % path)
|
||||
version, total_length = struct.unpack_from("<II", payload, 4)
|
||||
if version != 2 or total_length != len(payload):
|
||||
raise RuntimeError("invalid GLB version/length: %s" % path)
|
||||
offset = 12
|
||||
chunks = {}
|
||||
while offset < len(payload):
|
||||
length, chunk_type = struct.unpack_from("<II", payload, offset)
|
||||
start = offset + 8
|
||||
chunks[chunk_type] = payload[start : start + length]
|
||||
offset = start + length
|
||||
document = json.loads(chunks[0x4E4F534A].rstrip(b" \x00").decode("utf-8"))
|
||||
return payload, document
|
||||
|
||||
|
||||
def accessor_summary(document, index):
|
||||
if index is None:
|
||||
return None
|
||||
accessor = document.get("accessors", [])[index]
|
||||
return {
|
||||
"componentType": accessor["componentType"],
|
||||
"count": accessor["count"],
|
||||
"type": accessor["type"],
|
||||
"normalized": bool(accessor.get("normalized", False)),
|
||||
"min": accessor.get("min"),
|
||||
"max": accessor.get("max"),
|
||||
}
|
||||
|
||||
|
||||
def semantic_summary(document):
|
||||
meshes = []
|
||||
for mesh in document.get("meshes", []):
|
||||
primitives = []
|
||||
for primitive in mesh.get("primitives", []):
|
||||
primitives.append(
|
||||
{
|
||||
"attributes": {
|
||||
name: accessor_summary(document, index)
|
||||
for name, index in sorted(primitive.get("attributes", {}).items())
|
||||
},
|
||||
"indices": accessor_summary(document, primitive.get("indices")),
|
||||
"material": primitive.get("material"),
|
||||
"mode": primitive.get("mode", 4),
|
||||
"targets": [
|
||||
{name: accessor_summary(document, index) for name, index in sorted(target.items())}
|
||||
for target in primitive.get("targets", [])
|
||||
],
|
||||
}
|
||||
)
|
||||
meshes.append({"name": mesh.get("name"), "primitives": primitives})
|
||||
materials = []
|
||||
for material in document.get("materials", []):
|
||||
pbr = material.get("pbrMetallicRoughness", {})
|
||||
materials.append(
|
||||
{
|
||||
"name": material.get("name"),
|
||||
"alphaMode": material.get("alphaMode", "OPAQUE"),
|
||||
"doubleSided": bool(material.get("doubleSided", False)),
|
||||
"pbr": {
|
||||
"baseColorFactor": pbr.get("baseColorFactor"),
|
||||
"baseColorTexture": pbr.get("baseColorTexture"),
|
||||
"metallicFactor": pbr.get("metallicFactor"),
|
||||
"roughnessFactor": pbr.get("roughnessFactor"),
|
||||
},
|
||||
"normalTexture": material.get("normalTexture"),
|
||||
"emissiveFactor": material.get("emissiveFactor"),
|
||||
}
|
||||
)
|
||||
animations = []
|
||||
for animation in document.get("animations", []):
|
||||
samplers = animation.get("samplers", [])
|
||||
channels = animation.get("channels", [])
|
||||
animations.append(
|
||||
{
|
||||
"name": animation.get("name"),
|
||||
"samplers": [
|
||||
{
|
||||
"interpolation": sampler.get("interpolation", "LINEAR"),
|
||||
"input": accessor_summary(document, sampler.get("input")),
|
||||
"output": accessor_summary(document, sampler.get("output")),
|
||||
}
|
||||
for sampler in samplers
|
||||
],
|
||||
"channels": [
|
||||
{"sampler": channel["sampler"], "target": channel["target"]} for channel in channels
|
||||
],
|
||||
}
|
||||
)
|
||||
return {
|
||||
"asset": document.get("asset"),
|
||||
"extensionsUsed": sorted(document.get("extensionsUsed", [])),
|
||||
"extensionsRequired": sorted(document.get("extensionsRequired", [])),
|
||||
"scene": document.get("scene"),
|
||||
"nodeNames": [node.get("name") for node in document.get("nodes", [])],
|
||||
"nodes": [
|
||||
{
|
||||
"name": node.get("name"),
|
||||
"mesh": node.get("mesh"),
|
||||
"skin": node.get("skin"),
|
||||
"children": node.get("children", []),
|
||||
"translation": node.get("translation"),
|
||||
"rotation": node.get("rotation"),
|
||||
"scale": node.get("scale"),
|
||||
}
|
||||
for node in document.get("nodes", [])
|
||||
],
|
||||
"meshes": meshes,
|
||||
"materials": materials,
|
||||
"textures": document.get("textures", []),
|
||||
"images": document.get("images", []),
|
||||
"samplers": document.get("samplers", []),
|
||||
"skins": [
|
||||
{
|
||||
"name": skin.get("name"),
|
||||
"joints": skin.get("joints", []),
|
||||
"inverseBindMatrices": accessor_summary(document, skin.get("inverseBindMatrices")),
|
||||
"skeleton": skin.get("skeleton"),
|
||||
}
|
||||
for skin in document.get("skins", [])
|
||||
],
|
||||
"animations": animations,
|
||||
}
|
||||
|
||||
|
||||
def generate_fixture(fixture_id, output_path):
|
||||
creators = {
|
||||
"mesh": create_mesh_fixture,
|
||||
"pbr": create_pbr_fixture,
|
||||
"uv": create_uv_fixture,
|
||||
"skin": create_skin_fixture,
|
||||
"animation": create_animation_fixture,
|
||||
}
|
||||
creators[fixture_id]()
|
||||
export_selected(output_path)
|
||||
payload, document = read_glb(output_path)
|
||||
return {
|
||||
"id": fixture_id,
|
||||
"file": output_path.name,
|
||||
"byteLength": len(payload),
|
||||
"sha256": hashlib.sha256(payload).hexdigest(),
|
||||
"semantic": semantic_summary(document),
|
||||
}
|
||||
|
||||
|
||||
def runtime_identity():
|
||||
binary = Path(bpy.app.binary_path)
|
||||
def text(value):
|
||||
return value.decode("utf-8") if isinstance(value, bytes) else value
|
||||
return {
|
||||
"blenderVersion": text(bpy.app.version_string),
|
||||
"versionTuple": list(bpy.app.version),
|
||||
"buildDate": text(bpy.app.build_date),
|
||||
"buildTime": text(bpy.app.build_time),
|
||||
"buildHash": text(bpy.app.build_hash),
|
||||
"buildBranch": text(bpy.app.build_branch),
|
||||
"buildPlatform": text(bpy.app.build_platform),
|
||||
"buildType": text(bpy.app.build_type),
|
||||
"binarySha256": sha256_file(binary),
|
||||
}
|
||||
|
||||
|
||||
def main(output_dir, report_path):
|
||||
output_dir = Path(output_dir).resolve()
|
||||
report_path = Path(report_path).resolve()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fixtures = []
|
||||
for fixture_id, _label, filename in FIXTURES:
|
||||
fixtures.append(generate_fixture(fixture_id, output_dir / filename))
|
||||
report = {
|
||||
"schemaVersion": 1,
|
||||
"task": "M12-06A",
|
||||
"operation": "DESKTOP_GLB_FIXTURE_GENERATION",
|
||||
"runtime": runtime_identity(),
|
||||
"fixtureCount": len(fixtures),
|
||||
"maxFixtureBytes": 512 * 1024,
|
||||
"fixtures": fixtures,
|
||||
"nextTask": "M12-06B",
|
||||
}
|
||||
report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
print(
|
||||
"glb-desktop-fixtures-generated "
|
||||
"fixtures=%s bytes=%s next=%s"
|
||||
% (len(fixtures), sum(item["byteLength"] for item in fixtures), report["nextTask"])
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else []
|
||||
if len(args) != 2:
|
||||
raise SystemExit("usage: blender --background --python generate-glb-desktop-fixtures.py -- OUTPUT_DIR REPORT")
|
||||
main(args[0], args[1])
|
||||
95
tools/web/generate-glb-desktop-import-report.mjs
Normal file
95
tools/web/generate-glb-desktop-import-report.mjs
Normal file
@@ -0,0 +1,95 @@
|
||||
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, fileURLToPath } from "node:url";
|
||||
import ts from "../../web/node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const outputPath = path.resolve(process.argv[2] ?? path.join(root, "tests/golden/M12-06B/web-import-report.json"));
|
||||
const parentManifestPath = path.join(root, "tests/golden/M12-06A/manifest.json");
|
||||
const fixtureReportPath = path.join(root, "tests/golden/M12-06A/desktop-fixtures.json");
|
||||
const fixtureRoot = path.join(root, "tests/files/web/m12_glb_desktop_v1");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m12-06b-import-report-"));
|
||||
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
const stableValue = (value) => Array.isArray(value)
|
||||
? value.map(stableValue)
|
||||
: value && typeof value === "object"
|
||||
? Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableValue(value[key])]))
|
||||
: value;
|
||||
const stableSha256 = (value) => sha256(JSON.stringify(stableValue(value)));
|
||||
const arrayBuffer = (bytes) => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||
|
||||
try {
|
||||
const sourcePath = path.join(root, "web/protocol/glb-import.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, []);
|
||||
const modulePath = path.join(temporary, "glb-import.mjs");
|
||||
fs.writeFileSync(modulePath, transpiled.outputText);
|
||||
const protocol = await import(pathToFileURL(modulePath));
|
||||
const fixtures = JSON.parse(fs.readFileSync(fixtureReportPath, "utf8")).fixtures;
|
||||
const comparisons = fixtures.map((fixture) => {
|
||||
const bytes = fs.readFileSync(path.join(fixtureRoot, fixture.file));
|
||||
assert.equal(sha256(bytes), fixture.sha256, `${fixture.id} source SHA-256`);
|
||||
const imported = protocol.importGLBDesktopFixtureSemantics(arrayBuffer(bytes));
|
||||
const comparison = protocol.compareGLBDesktopFixtureSemantics(fixture.semantic, imported);
|
||||
const primitives = imported.meshes.flatMap((mesh) => mesh.primitives);
|
||||
return {
|
||||
id: fixture.id,
|
||||
file: fixture.file,
|
||||
sourceSha256: fixture.sha256,
|
||||
byteLength: bytes.byteLength,
|
||||
desktopSemanticSha256: stableSha256(fixture.semantic),
|
||||
webSemanticSha256: stableSha256(imported),
|
||||
compatible: comparison.compatible,
|
||||
mismatchCount: comparison.mismatches.length,
|
||||
topology: {
|
||||
meshCount: imported.meshes.length,
|
||||
primitiveCount: primitives.length,
|
||||
indexCount: primitives.reduce((sum, primitive) => sum + (primitive.indices?.count ?? 0), 0),
|
||||
modes: [...new Set(primitives.map((primitive) => primitive.mode))].sort((left, right) => left - right),
|
||||
},
|
||||
attributes: [...new Set(primitives.flatMap((primitive) => Object.keys(primitive.attributes)))].sort(),
|
||||
materials: {
|
||||
count: imported.materials.length,
|
||||
pbrCount: imported.materials.filter((material) => material.pbr.metallicFactor !== null && material.pbr.roughnessFactor !== null).length,
|
||||
texturedCount: imported.materials.filter((material) => material.pbr.baseColorTexture !== null).length,
|
||||
},
|
||||
nodes: {
|
||||
count: imported.nodes.length,
|
||||
namedCount: imported.nodes.filter((node) => node.name !== null).length,
|
||||
hierarchyEdges: imported.nodes.reduce((sum, node) => sum + node.children.length, 0),
|
||||
skinnedCount: imported.nodes.filter((node) => node.skin !== null).length,
|
||||
},
|
||||
animations: {
|
||||
count: imported.animations.length,
|
||||
channelPaths: imported.animations.flatMap((animation) => animation.channels.map((channel) => channel.target.path)).sort(),
|
||||
sampleCounts: imported.animations.flatMap((animation) => animation.samplers.map((sampler) => sampler.input?.count ?? 0)),
|
||||
},
|
||||
};
|
||||
});
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
task: "M12-06B",
|
||||
operation: "WEB_GLB_IMPORT_SEMANTIC_COMPARISON",
|
||||
parentManifestSha256: sha256(fs.readFileSync(parentManifestPath)),
|
||||
fixtureReportSha256: sha256(fs.readFileSync(fixtureReportPath)),
|
||||
fixtureCount: comparisons.length,
|
||||
comparedDomains: ["topology", "attributes", "materials", "nodes", "animations"],
|
||||
allCompatible: comparisons.every((comparison) => comparison.compatible && comparison.mismatchCount === 0),
|
||||
comparisons,
|
||||
routeState: "BLOCKED_UNTIL_MAIN_PERSISTENCE",
|
||||
nextTask: "M12-06C",
|
||||
};
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
fs.writeFileSync(outputPath, JSON.stringify(report, null, 2) + "\n");
|
||||
process.stdout.write(`glb-desktop-import-report-generated fixtures=${report.fixtureCount} domains=${report.comparedDomains.length} compatible=${report.allCompatible} next=${report.nextTask}\n`);
|
||||
}
|
||||
finally {
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
178
tools/web/generate-glb-main-persistence-fixtures.py
Normal file
178
tools/web/generate-glb-main-persistence-fixtures.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""Import each M12 GLB with Blender 5.2 and freeze a Main persistence baseline."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
FIXTURE_IDS = ("mesh", "pbr", "uv", "skin", "animation")
|
||||
|
||||
|
||||
def sha256_file(path):
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def rounded(value):
|
||||
return round(float(value), 6)
|
||||
|
||||
|
||||
def graph_report():
|
||||
objects = []
|
||||
for obj in sorted(bpy.data.objects, key=lambda item: item.name):
|
||||
objects.append(
|
||||
{
|
||||
"name": obj.name,
|
||||
"type": obj.type,
|
||||
"data": obj.data.name if obj.data is not None else None,
|
||||
"parent": obj.parent.name if obj.parent is not None else None,
|
||||
"children": sorted(child.name for child in obj.children),
|
||||
"location": [rounded(value) for value in obj.location],
|
||||
}
|
||||
)
|
||||
meshes = []
|
||||
for mesh in sorted(bpy.data.meshes, key=lambda item: item.name):
|
||||
mesh.calc_loop_triangles()
|
||||
meshes.append(
|
||||
{
|
||||
"name": mesh.name,
|
||||
"vertexCount": len(mesh.vertices),
|
||||
"polygonCount": len(mesh.polygons),
|
||||
"triangleCount": len(mesh.loop_triangles),
|
||||
"uvLayers": sorted(layer.name for layer in mesh.uv_layers),
|
||||
"materials": [material.name if material else None for material in mesh.materials],
|
||||
}
|
||||
)
|
||||
materials = []
|
||||
for material in sorted(bpy.data.materials, key=lambda item: item.name):
|
||||
materials.append(
|
||||
{
|
||||
"name": material.name,
|
||||
"nodeNames": sorted(node.name for node in material.node_tree.nodes) if material.node_tree else [],
|
||||
}
|
||||
)
|
||||
images = []
|
||||
for image in sorted(bpy.data.images, key=lambda item: item.name):
|
||||
images.append(
|
||||
{
|
||||
"name": image.name,
|
||||
"size": list(image.size),
|
||||
"packed": image.packed_file is not None,
|
||||
"mimeType": image.file_format,
|
||||
}
|
||||
)
|
||||
armatures = []
|
||||
for armature in sorted(bpy.data.armatures, key=lambda item: item.name):
|
||||
armatures.append(
|
||||
{
|
||||
"name": armature.name,
|
||||
"bones": [
|
||||
{"name": bone.name, "parent": bone.parent.name if bone.parent else None}
|
||||
for bone in sorted(armature.bones, key=lambda item: item.name)
|
||||
],
|
||||
}
|
||||
)
|
||||
actions = []
|
||||
action_stable_ids = []
|
||||
for action in sorted(bpy.data.actions, key=lambda item: item.name):
|
||||
fcurves = []
|
||||
for layer in action.layers:
|
||||
for strip in layer.strips:
|
||||
for channelbag in strip.channelbags:
|
||||
fcurves.extend((curve.data_path, curve.array_index) for curve in channelbag.fcurves)
|
||||
actions.append(
|
||||
{
|
||||
"name": action.name,
|
||||
"frameRange": [rounded(action.frame_range[0]), rounded(action.frame_range[1])],
|
||||
"fcurves": sorted(fcurves),
|
||||
}
|
||||
)
|
||||
owners = sorted(
|
||||
object_.name
|
||||
for object_ in bpy.data.objects
|
||||
if object_.animation_data is not None and object_.animation_data.action == action
|
||||
)
|
||||
action_stable_ids.extend(
|
||||
"action:" + action.name + ":object:" + owner for owner in owners
|
||||
)
|
||||
if not owners:
|
||||
action_stable_ids.append("action:" + action.name)
|
||||
return {
|
||||
"objects": objects,
|
||||
"meshes": meshes,
|
||||
"materials": materials,
|
||||
"images": images,
|
||||
"armatures": armatures,
|
||||
"actions": actions,
|
||||
"stableIds": {
|
||||
"objects": ["object:" + item["name"] for item in objects],
|
||||
"meshes": ["mesh:" + item["name"] for item in meshes],
|
||||
"materials": ["material:" + item["name"] for item in materials],
|
||||
"images": ["image:" + item["name"] for item in images],
|
||||
"armatures": ["armature:" + item["name"] for item in armatures],
|
||||
"actions": sorted(action_stable_ids),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def import_and_save(glb_path, blend_path):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
result = bpy.ops.import_scene.gltf(filepath=str(glb_path))
|
||||
if "FINISHED" not in result:
|
||||
raise RuntimeError("Blender GLB import failed: %s" % (result,))
|
||||
scene = bpy.context.scene
|
||||
scene.frame_start = 1
|
||||
scene.frame_end = 25
|
||||
before = graph_report()
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(blend_path), check_existing=False)
|
||||
bpy.ops.wm.open_mainfile(filepath=str(blend_path), load_ui=False)
|
||||
reopened = graph_report()
|
||||
if before != reopened:
|
||||
raise RuntimeError("desktop import save/reopen semantic drift: %s" % blend_path)
|
||||
return reopened
|
||||
|
||||
|
||||
def main(glb_root, output_root, report_path):
|
||||
glb_root = Path(glb_root).resolve()
|
||||
output_root = Path(output_root).resolve()
|
||||
report_path = Path(report_path).resolve()
|
||||
output_root.mkdir(parents=True, exist_ok=True)
|
||||
fixtures = []
|
||||
for fixture_id in FIXTURE_IDS:
|
||||
glb_path = glb_root / (fixture_id + ".glb")
|
||||
blend_path = output_root / (fixture_id + ".blend")
|
||||
graph = import_and_save(glb_path, blend_path)
|
||||
fixtures.append(
|
||||
{
|
||||
"id": fixture_id,
|
||||
"glb": {"file": glb_path.name, "sha256": sha256_file(glb_path)},
|
||||
"blend": {"file": blend_path.name, "byteLength": blend_path.stat().st_size, "sha256": sha256_file(blend_path)},
|
||||
"graph": graph,
|
||||
}
|
||||
)
|
||||
report = {
|
||||
"schemaVersion": 1,
|
||||
"task": "M12-06C",
|
||||
"operation": "DESKTOP_GLB_IMPORT_MAIN_PERSISTENCE_BASELINE",
|
||||
"blenderVersion": bpy.app.version_string,
|
||||
"fixtureCount": len(fixtures),
|
||||
"fixtures": fixtures,
|
||||
"nextTask": "M12-06D",
|
||||
}
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
print("glb-main-persistence-fixtures-generated fixtures=%s next=%s" % (len(fixtures), report["nextTask"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else []
|
||||
if len(args) != 3:
|
||||
raise SystemExit("usage: blender --background --python generate-glb-main-persistence-fixtures.py -- GLB_ROOT OUTPUT_ROOT REPORT")
|
||||
main(args[0], args[1], args[2])
|
||||
149
tools/web/generate-glb-web-reimport-report.py
Normal file
149
tools/web/generate-glb-web-reimport-report.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""Re-import Web-produced GLBs in Blender 5.2 and emit canonical graph reports."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
FIXTURE_IDS = ("pbr", "uv", "skin", "animation")
|
||||
|
||||
|
||||
def sha256_file(path):
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def rounded(value):
|
||||
return round(float(value), 6)
|
||||
|
||||
|
||||
def graph_report():
|
||||
objects = []
|
||||
for obj in sorted(bpy.data.objects, key=lambda item: item.name):
|
||||
objects.append({
|
||||
"name": obj.name,
|
||||
"type": obj.type,
|
||||
"data": obj.data.name if obj.data is not None else None,
|
||||
"parent": obj.parent.name if obj.parent is not None else None,
|
||||
"children": sorted(child.name for child in obj.children),
|
||||
"location": [rounded(value) for value in obj.location],
|
||||
})
|
||||
meshes = []
|
||||
for mesh in sorted(bpy.data.meshes, key=lambda item: item.name):
|
||||
mesh.calc_loop_triangles()
|
||||
meshes.append({
|
||||
"name": mesh.name,
|
||||
"vertexCount": len(mesh.vertices),
|
||||
"polygonCount": len(mesh.polygons),
|
||||
"triangleCount": len(mesh.loop_triangles),
|
||||
"uvLayers": sorted(layer.name for layer in mesh.uv_layers),
|
||||
"materials": [material.name if material else None for material in mesh.materials],
|
||||
})
|
||||
materials = []
|
||||
for material in sorted(bpy.data.materials, key=lambda item: item.name):
|
||||
materials.append({
|
||||
"name": material.name,
|
||||
"nodeNames": sorted(node.name for node in material.node_tree.nodes) if material.node_tree else [],
|
||||
})
|
||||
images = []
|
||||
for image in sorted(bpy.data.images, key=lambda item: item.name):
|
||||
images.append({
|
||||
"name": image.name,
|
||||
"size": list(image.size),
|
||||
"packed": image.packed_file is not None,
|
||||
"mimeType": image.file_format,
|
||||
})
|
||||
armatures = []
|
||||
for armature in sorted(bpy.data.armatures, key=lambda item: item.name):
|
||||
armatures.append({
|
||||
"name": armature.name,
|
||||
"bones": [{"name": bone.name, "parent": bone.parent.name if bone.parent else None} for bone in sorted(armature.bones, key=lambda item: item.name)],
|
||||
})
|
||||
actions = []
|
||||
action_stable_ids = []
|
||||
for action in sorted(bpy.data.actions, key=lambda item: item.name):
|
||||
fcurves = []
|
||||
for layer in action.layers:
|
||||
for strip in layer.strips:
|
||||
for channelbag in strip.channelbags:
|
||||
fcurves.extend((curve.data_path, curve.array_index) for curve in channelbag.fcurves)
|
||||
actions.append({
|
||||
"name": action.name,
|
||||
"frameRange": [rounded(action.frame_range[0]), rounded(action.frame_range[1])],
|
||||
"fcurves": sorted(fcurves),
|
||||
})
|
||||
owners = sorted(object_.name for object_ in bpy.data.objects if object_.animation_data is not None and object_.animation_data.action == action)
|
||||
action_stable_ids.extend("action:" + action.name + ":object:" + owner for owner in owners)
|
||||
if not owners:
|
||||
action_stable_ids.append("action:" + action.name)
|
||||
return {
|
||||
"objects": objects,
|
||||
"meshes": meshes,
|
||||
"materials": materials,
|
||||
"images": images,
|
||||
"armatures": armatures,
|
||||
"actions": actions,
|
||||
"stableIds": {
|
||||
"objects": ["object:" + item["name"] for item in objects],
|
||||
"meshes": ["mesh:" + item["name"] for item in meshes],
|
||||
"materials": ["material:" + item["name"] for item in materials],
|
||||
"images": ["image:" + item["name"] for item in images],
|
||||
"armatures": ["armature:" + item["name"] for item in armatures],
|
||||
"actions": sorted(action_stable_ids),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def import_reopen(glb_path, blend_path):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
result = bpy.ops.import_scene.gltf(filepath=str(glb_path))
|
||||
if "FINISHED" not in result:
|
||||
raise RuntimeError("Blender Web GLB import failed: %s" % (result,))
|
||||
before = graph_report()
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(blend_path), check_existing=False)
|
||||
bpy.ops.wm.open_mainfile(filepath=str(blend_path), load_ui=False)
|
||||
after = graph_report()
|
||||
if before != after:
|
||||
raise RuntimeError("desktop Web GLB save/reopen semantic drift: %s" % glb_path)
|
||||
return after
|
||||
|
||||
|
||||
def main(glb_root, output_root, report_path):
|
||||
glb_root = Path(glb_root).resolve()
|
||||
output_root = Path(output_root).resolve()
|
||||
report_path = Path(report_path).resolve()
|
||||
output_root.mkdir(parents=True, exist_ok=True)
|
||||
fixtures = []
|
||||
for fixture_id in FIXTURE_IDS:
|
||||
glb_path = glb_root / (fixture_id + ".glb")
|
||||
blend_path = output_root / (fixture_id + ".blend")
|
||||
fixtures.append({
|
||||
"id": fixture_id,
|
||||
"glb": {"file": glb_path.name, "byteLength": glb_path.stat().st_size, "sha256": sha256_file(glb_path)},
|
||||
"graph": import_reopen(glb_path, blend_path),
|
||||
})
|
||||
report = {
|
||||
"schemaVersion": 1,
|
||||
"task": "M12-06E",
|
||||
"operation": "DESKTOP_REIMPORT_WEB_GLB_CANONICAL_REPORT",
|
||||
"blenderVersion": bpy.app.version_string,
|
||||
"fixtureCount": len(fixtures),
|
||||
"fixtures": fixtures,
|
||||
"nextTask": "M12-06F",
|
||||
}
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
print("glb-web-reimport-report-generated fixtures=%s next=%s" % (len(fixtures), report["nextTask"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else []
|
||||
if len(args) != 3:
|
||||
raise SystemExit("usage: blender --background --python generate-glb-web-reimport-report.py -- GLB_ROOT OUTPUT_ROOT REPORT")
|
||||
main(args[0], args[1], args[2])
|
||||
50
tools/web/generate-io-format-capability-matrix.mjs
Normal file
50
tools/web/generate-io-format-capability-matrix.mjs
Normal file
@@ -0,0 +1,50 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const inventoryPath = path.join(repoRoot, "tests/golden/M12-05A/format-inventory.json");
|
||||
const outputArgument = process.argv.indexOf("--output");
|
||||
const outputPath = outputArgument === -1
|
||||
? path.join(repoRoot, "tests/golden/M12-05B/capability-matrix.json")
|
||||
: path.resolve(process.argv[outputArgument + 1] ?? "");
|
||||
if (!outputPath) throw new Error("--output requires a file");
|
||||
|
||||
const inventoryBytes = fs.readFileSync(inventoryPath);
|
||||
const inventory = JSON.parse(inventoryBytes);
|
||||
const runtimeInventorySha256 = crypto.createHash("sha256").update(inventoryBytes).digest("hex");
|
||||
const formatOrder = ["GLTF", "GLB", "OBJ", "STL", "PLY", "USD", "ALEMBIC"];
|
||||
|
||||
const blocked = (code = "IO_FORMAT_UNSUPPORTED") => ({ status: "BLOCKED", execution: "NONE", code });
|
||||
const feature = (status, evidence) => ({ status, evidence });
|
||||
const operation = (format, name) => {
|
||||
const glbExport = format === "GLB" && name === "EXPORT";
|
||||
const bounded = glbExport
|
||||
? feature("PARTIAL", "bounded GLB export gate exists; full format round-trip remains M12-06")
|
||||
: feature("UNVERIFIED", "no verified Web executor for this format/operation");
|
||||
return {
|
||||
local: glbExport ? { status: "READY", execution: "LOCAL", code: null } : blocked(),
|
||||
server: blocked(),
|
||||
geometry: bounded,
|
||||
material: bounded,
|
||||
animation: bounded,
|
||||
};
|
||||
};
|
||||
|
||||
const byFormat = new Map(inventory.formats.map((entry) => [entry.format, entry]));
|
||||
const formats = formatOrder.map((format) => {
|
||||
const runtime = byFormat.get(format);
|
||||
if (!runtime) throw new Error(`inventory is missing ${format}`);
|
||||
return {
|
||||
format,
|
||||
runtimeImportStatus: runtime.import.runtimeStatus === "AVAILABLE" ? "AVAILABLE" : "OPERATOR_UNREGISTERED",
|
||||
runtimeExportStatus: runtime.export.runtimeStatus === "AVAILABLE" ? "AVAILABLE" : "OPERATOR_UNREGISTERED",
|
||||
operations: { IMPORT: operation(format, "IMPORT"), EXPORT: operation(format, "EXPORT") },
|
||||
};
|
||||
});
|
||||
|
||||
const matrix = { schemaVersion: 1, task: "M12-05B", runtimeInventorySha256, formats };
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
fs.writeFileSync(outputPath, `${JSON.stringify(matrix, null, 2)}\n`);
|
||||
process.stdout.write(`io-format-capability-matrix-generated formats=${formats.length} output=${outputPath}\n`);
|
||||
32
tools/web/generate-io-format-receipt-bindings.mjs
Normal file
32
tools/web/generate-io-format-receipt-bindings.mjs
Normal file
@@ -0,0 +1,32 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const inventoryPath = path.join(repoRoot, "tests/golden/M12-05A/format-inventory.json");
|
||||
const parentPath = path.join(repoRoot, "tests/golden/M12-05D/runtime-receipts.json");
|
||||
const outputArgument = process.argv.indexOf("--output");
|
||||
const outputPath = outputArgument === -1 ? path.join(repoRoot, "tests/golden/M12-05E/bound-runtime-receipts.json") : path.resolve(process.argv[outputArgument + 1] ?? "");
|
||||
if (!outputPath) throw new Error("--output requires a file");
|
||||
const inventoryBytes = fs.readFileSync(inventoryPath);
|
||||
const parentBytes = fs.readFileSync(parentPath);
|
||||
const inventory = JSON.parse(inventoryBytes);
|
||||
const parent = JSON.parse(parentBytes);
|
||||
const canonical = (value) => value === null || typeof value !== "object" ? JSON.stringify(value) : Array.isArray(value) ? `[${value.map(canonical).join(",")}]` : `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
|
||||
const hash = (value) => crypto.createHash("sha256").update(canonical(value)).digest("hex");
|
||||
const runtimeSha256 = hash(inventory.runtime);
|
||||
const receipts = inventory.formats.flatMap((entry) => ["IMPORT", "EXPORT"].map((operation) => {
|
||||
const source = entry[operation.toLowerCase()];
|
||||
const receipt = parent.receipts.find((item) => item.format === entry.format && item.operation === operation);
|
||||
return {
|
||||
...receipt,
|
||||
sourceSha256: hash({ format: receipt.format, family: receipt.family, operation: receipt.operation, operator: receipt.operator, registered: receipt.registered, rnaIdentifier: receipt.rnaIdentifier }),
|
||||
settingsSha256: hash({ buildOption: receipt.buildOption, buildOptionEnabled: receipt.buildOptionEnabled, variants: receipt.variants, extensions: receipt.extensions, properties: source.properties }),
|
||||
runtimeSha256,
|
||||
};
|
||||
}));
|
||||
const output = { schemaVersion: 1, task: "M12-05E", parentReceiptSetSha256: crypto.createHash("sha256").update(parentBytes).digest("hex"), inventorySha256: crypto.createHash("sha256").update(inventoryBytes).digest("hex"), runtime: inventory.runtime, receipts };
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
fs.writeFileSync(outputPath, `${JSON.stringify(output, null, 2)}\n`);
|
||||
process.stdout.write(`io-format-receipt-bindings-generated receipts=${receipts.length} output=${path.relative(repoRoot, outputPath)}\n`);
|
||||
49
tools/web/generate-io-format-receipt-freshness.mjs
Normal file
49
tools/web/generate-io-format-receipt-freshness.mjs
Normal file
@@ -0,0 +1,49 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const parentPath = path.join(repoRoot, "tests/golden/M12-05E/bound-runtime-receipts.json");
|
||||
const outputArgument = process.argv.indexOf("--output");
|
||||
const outputPath = outputArgument === -1 ? path.join(repoRoot, "tests/golden/M12-05F/fresh-runtime-receipts.json") : path.resolve(process.argv[outputArgument + 1] ?? "");
|
||||
const expectedArgument = process.argv.indexOf("--expected-output");
|
||||
const expectedOutputPath = expectedArgument === -1 ? null : path.resolve(process.argv[expectedArgument + 1] ?? "");
|
||||
if (!outputPath) throw new Error("--output requires a file");
|
||||
|
||||
function stableValue(value) {
|
||||
if (Array.isArray(value)) return value.map(stableValue);
|
||||
if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableValue(value[key])]));
|
||||
return value;
|
||||
}
|
||||
|
||||
function sha256(value) {
|
||||
return crypto.createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
const parentBytes = fs.readFileSync(parentPath);
|
||||
const bound = JSON.parse(parentBytes);
|
||||
if (bound.schemaVersion !== 1 || bound.task !== "M12-05E") throw new Error("M12-05E bound receipt header is invalid");
|
||||
const output = {
|
||||
schemaVersion: 1,
|
||||
task: "M12-05F",
|
||||
parentBindingSha256: sha256(parentBytes),
|
||||
boundReceiptSetSha256: sha256(JSON.stringify(stableValue(bound))),
|
||||
runtimeSha256: sha256(JSON.stringify(stableValue(bound.runtime))),
|
||||
bound,
|
||||
};
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
fs.writeFileSync(outputPath, `${JSON.stringify(output, null, 2)}\n`);
|
||||
if (expectedOutputPath) {
|
||||
fs.mkdirSync(path.dirname(expectedOutputPath), { recursive: true });
|
||||
fs.writeFileSync(expectedOutputPath, `${JSON.stringify({
|
||||
parentBindingSha256: output.parentBindingSha256,
|
||||
parentReceiptSetSha256: bound.parentReceiptSetSha256,
|
||||
inventorySha256: bound.inventorySha256,
|
||||
boundReceiptSetSha256: output.boundReceiptSetSha256,
|
||||
runtimeSha256: output.runtimeSha256,
|
||||
runtime: bound.runtime,
|
||||
receiptIdentities: bound.receipts,
|
||||
}, null, 2)}\n`);
|
||||
}
|
||||
process.stdout.write(`io-format-receipt-freshness-generated receipts=${bound.receipts.length} output=${path.relative(repoRoot, outputPath)}\n`);
|
||||
128
tools/web/generate-io-format-runtime-inventory.py
Normal file
128
tools/web/generate-io-format-runtime-inventory.py
Normal file
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate the format capability inventory from one pinned Blender runtime."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
def decode(value):
|
||||
if isinstance(value, bytes):
|
||||
return value.decode("utf-8", errors="replace")
|
||||
return str(value)
|
||||
|
||||
|
||||
def binary_sha256(path):
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as handle:
|
||||
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def property_info(prop):
|
||||
result = {"identifier": prop.identifier, "type": prop.type}
|
||||
if hasattr(prop, "array_length"):
|
||||
result["arrayLength"] = prop.array_length
|
||||
if prop.type == "ENUM":
|
||||
try:
|
||||
result["enumItems"] = [item.identifier for item in prop.enum_items]
|
||||
except (AttributeError, RuntimeError):
|
||||
result["enumItems"] = []
|
||||
return result
|
||||
|
||||
|
||||
def operator_info(operator_path, build_option):
|
||||
module_name, operator_name = operator_path.split(".", 1)
|
||||
try:
|
||||
operator = getattr(getattr(bpy.ops, module_name), operator_name)
|
||||
rna = operator.get_rna_type()
|
||||
properties = [property_info(prop) for prop in rna.properties if prop.identifier != "rna_type"]
|
||||
properties.sort(key=lambda prop: prop["identifier"])
|
||||
enabled = True if build_option is None else bool(getattr(bpy.app.build_options, build_option))
|
||||
return {
|
||||
"operator": operator_path,
|
||||
"registered": True,
|
||||
"rnaIdentifier": rna.identifier,
|
||||
"buildOption": build_option,
|
||||
"buildOptionEnabled": enabled,
|
||||
"runtimeStatus": "AVAILABLE" if enabled else "BUILD_OPTION_DISABLED",
|
||||
"properties": properties,
|
||||
}
|
||||
except (AttributeError, KeyError, RuntimeError) as error:
|
||||
return {
|
||||
"operator": operator_path,
|
||||
"registered": False,
|
||||
"rnaIdentifier": None,
|
||||
"buildOption": build_option,
|
||||
"buildOptionEnabled": None,
|
||||
"runtimeStatus": "OPERATOR_UNREGISTERED",
|
||||
"properties": [],
|
||||
"error": type(error).__name__,
|
||||
}
|
||||
|
||||
|
||||
def format_entry(format_id, family, extensions, import_operator, export_operator, build_option, variants):
|
||||
return {
|
||||
"format": format_id,
|
||||
"family": family,
|
||||
"extensions": extensions,
|
||||
"variants": variants,
|
||||
"import": operator_info(import_operator, build_option),
|
||||
"export": operator_info(export_operator, build_option),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
if "--" not in sys.argv or len(sys.argv[sys.argv.index("--") + 1:]) != 1:
|
||||
raise SystemExit("usage: generate-io-format-runtime-inventory.py OUTPUT.json")
|
||||
output = pathlib.Path(sys.argv[sys.argv.index("--") + 1]).resolve()
|
||||
version = tuple(int(value) for value in bpy.app.version)
|
||||
if version != (5, 2, 0):
|
||||
raise RuntimeError(f"expected Blender 5.2.0, got {version}")
|
||||
binary_path = pathlib.Path(bpy.app.binary_path).resolve()
|
||||
options = {
|
||||
"alembic": bool(bpy.app.build_options.alembic),
|
||||
"usd": bool(bpy.app.build_options.usd),
|
||||
"io_ply": bool(bpy.app.build_options.io_ply),
|
||||
"io_stl": bool(bpy.app.build_options.io_stl),
|
||||
"io_wavefront_obj": bool(bpy.app.build_options.io_wavefront_obj),
|
||||
}
|
||||
formats = [
|
||||
format_entry("GLTF", "GLTF", [".gltf"], "import_scene.gltf", "export_scene.gltf", None, ["GLTF_SEPARATE"]),
|
||||
format_entry("GLB", "GLTF", [".glb"], "import_scene.gltf", "export_scene.gltf", None, ["GLB"]),
|
||||
format_entry("OBJ", "OBJ", [".obj"], "wm.obj_import", "wm.obj_export", "io_wavefront_obj", ["OBJ"]),
|
||||
format_entry("STL", "STL", [".stl"], "wm.stl_import", "wm.stl_export", "io_stl", ["STL_BINARY", "STL_ASCII"]),
|
||||
format_entry("PLY", "PLY", [".ply"], "wm.ply_import", "wm.ply_export", "io_ply", ["PLY"]),
|
||||
format_entry("USD", "USD", [".usd", ".usda", ".usdc", ".usdz"], "wm.usd_import", "wm.usd_export", "usd", ["USD", "USDA", "USDC", "USDZ"]),
|
||||
format_entry("ALEMBIC", "ALEMBIC", [".abc"], "wm.alembic_import", "wm.alembic_export", "alembic", ["ALEMBIC"]),
|
||||
]
|
||||
inventory = {
|
||||
"schemaVersion": 1,
|
||||
"task": "M12-05A",
|
||||
"runtime": {
|
||||
"blenderVersion": bpy.app.version_string,
|
||||
"versionTuple": list(version),
|
||||
"buildHash": decode(bpy.app.build_hash),
|
||||
"buildBranch": decode(bpy.app.build_branch),
|
||||
"buildPlatform": decode(bpy.app.build_platform),
|
||||
"buildType": decode(bpy.app.build_type),
|
||||
"buildDate": decode(bpy.app.build_date),
|
||||
"buildTime": decode(bpy.app.build_time),
|
||||
"buildCommitTimestamp": int(bpy.app.build_commit_timestamp),
|
||||
"binarySha256": binary_sha256(binary_path),
|
||||
"buildOptions": options,
|
||||
},
|
||||
"formats": formats,
|
||||
}
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(json.dumps(inventory, sort_keys=True, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"io-format-runtime-inventory-generated formats={len(formats)} blender={bpy.app.version_string} output={output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
39
tools/web/generate-io-format-runtime-receipts.mjs
Normal file
39
tools/web/generate-io-format-runtime-receipts.mjs
Normal file
@@ -0,0 +1,39 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const inventoryPath = path.join(repoRoot, "tests/golden/M12-05A/format-inventory.json");
|
||||
const outputArgument = process.argv.indexOf("--output");
|
||||
const outputPath = outputArgument === -1 ? path.join(repoRoot, "tests/golden/M12-05D/runtime-receipts.json") : path.resolve(process.argv[outputArgument + 1] ?? "");
|
||||
if (!outputPath) throw new Error("--output requires a file");
|
||||
const inventoryBytes = fs.readFileSync(inventoryPath);
|
||||
const inventory = JSON.parse(inventoryBytes);
|
||||
if (inventory.schemaVersion !== 1 || inventory.task !== "M12-05A") throw new Error("M12-05A inventory header is invalid");
|
||||
const receipts = inventory.formats.flatMap((entry) => ["IMPORT", "EXPORT"].map((operation) => {
|
||||
const source = entry[operation.toLowerCase()];
|
||||
return {
|
||||
format: entry.format,
|
||||
family: entry.family,
|
||||
operation,
|
||||
operator: source.operator,
|
||||
registered: source.registered,
|
||||
rnaIdentifier: source.rnaIdentifier,
|
||||
buildOption: source.buildOption,
|
||||
buildOptionEnabled: source.buildOptionEnabled,
|
||||
runtimeStatus: source.runtimeStatus,
|
||||
variants: [...entry.variants],
|
||||
extensions: [...entry.extensions],
|
||||
};
|
||||
}));
|
||||
const output = {
|
||||
schemaVersion: 1,
|
||||
task: "M12-05D",
|
||||
inventorySha256: crypto.createHash("sha256").update(inventoryBytes).digest("hex"),
|
||||
runtime: inventory.runtime,
|
||||
receipts,
|
||||
};
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
fs.writeFileSync(outputPath, `${JSON.stringify(output, null, 2)}\n`);
|
||||
process.stdout.write(`io-format-runtime-receipts-generated formats=${inventory.formats.length} receipts=${receipts.length} output=${path.relative(repoRoot, outputPath)}\n`);
|
||||
43
tools/web/generate-io-format-ui-gate.mjs
Normal file
43
tools/web/generate-io-format-ui-gate.mjs
Normal file
@@ -0,0 +1,43 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import ts from "../../web/node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const matrixPath = path.join(repoRoot, "tests/golden/M12-05B/capability-matrix.json");
|
||||
const protocolPath = path.join(repoRoot, "web/protocol/io-format-capability-matrix.ts");
|
||||
const gatePath = path.join(repoRoot, "web/protocol/io-format-ui-gate.ts");
|
||||
const output = process.argv[process.argv.indexOf("--output") + 1];
|
||||
if (!output || output.startsWith("--")) throw new Error("usage: node generate-io-format-ui-gate.mjs --output <path>");
|
||||
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "io-format-ui-gate-"));
|
||||
try {
|
||||
const transpile = (sourcePath, outputName) => {
|
||||
const result = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
if (result.diagnostics?.length) throw new Error(ts.formatDiagnosticsWithColorAndContext(result.diagnostics, { getCanonicalFileName: (file) => file, getCurrentDirectory: () => repoRoot, getNewLine: () => "\n" }));
|
||||
const target = path.join(temporary, outputName);
|
||||
fs.writeFileSync(target, result.outputText);
|
||||
return target;
|
||||
};
|
||||
const matrixModulePath = transpile(protocolPath, "io-format-capability-matrix.mjs");
|
||||
const gateModulePath = transpile(gatePath, "io-format-ui-gate.mjs");
|
||||
const matrixModule = await import(pathToFileURL(matrixModulePath));
|
||||
const gateModule = await import(pathToFileURL(gateModulePath));
|
||||
const matrixBytes = fs.readFileSync(matrixPath);
|
||||
const matrix = matrixModule.parseIOFormatCapabilityMatrix(JSON.parse(matrixBytes));
|
||||
const registry = gateModule.buildIOFormatUIRegistry(matrix, crypto.createHash("sha256").update(matrixBytes).digest("hex"));
|
||||
const target = path.resolve(repoRoot, output);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, `${JSON.stringify(registry, null, 2)}\n`);
|
||||
process.stdout.write(`io-format-ui-gate-generated output=${path.relative(repoRoot, target)} import=${registry.importRoutes.length} export=${registry.exportRoutes.length}\n`);
|
||||
}
|
||||
finally {
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
183
tools/web/generate-library-link-fixture.py
Normal file
183
tools/web/generate-library-link-fixture.py
Normal file
@@ -0,0 +1,183 @@
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import struct
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
ROOT_OBJECT = "M12 Link Object"
|
||||
MESH_NAME = "M12 Link Mesh"
|
||||
MATERIAL_NAME = "M12 Link Material"
|
||||
IMAGE_NAME = "M12 Link Image"
|
||||
IMAGE_NODE_NAME = "M12 Link Image Node"
|
||||
SOURCE_MARKER = "M12-03F"
|
||||
|
||||
|
||||
def sha256_file(path: pathlib.Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def pixel_sha256(image: bpy.types.Image) -> str:
|
||||
values = list(image.pixels)
|
||||
return hashlib.sha256(struct.pack(f"<{len(values)}f", *values)).hexdigest()
|
||||
|
||||
|
||||
def reset() -> None:
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
|
||||
|
||||
def create_source(path: pathlib.Path) -> None:
|
||||
reset()
|
||||
image = bpy.data.images.new(IMAGE_NAME, width=2, height=2, alpha=True, float_buffer=False)
|
||||
image.colorspace_settings.name = "sRGB"
|
||||
image.pixels = [
|
||||
1.0, 0.0, 0.0, 1.0,
|
||||
0.0, 1.0, 0.0, 1.0,
|
||||
0.0, 0.0, 1.0, 1.0,
|
||||
1.0, 1.0, 1.0, 0.5,
|
||||
]
|
||||
image.pack()
|
||||
|
||||
material = bpy.data.materials.new(MATERIAL_NAME)
|
||||
material.use_nodes = True
|
||||
node_tree = material.node_tree
|
||||
principled = node_tree.nodes.get("Principled BSDF")
|
||||
image_node = node_tree.nodes.new("ShaderNodeTexImage")
|
||||
image_node.name = IMAGE_NODE_NAME
|
||||
image_node.label = IMAGE_NODE_NAME
|
||||
image_node.image = image
|
||||
image_node.interpolation = "Closest"
|
||||
image_node.extension = "REPEAT"
|
||||
node_tree.links.new(image_node.outputs["Color"], principled.inputs["Base Color"])
|
||||
|
||||
mesh = bpy.data.meshes.new(MESH_NAME)
|
||||
mesh.from_pydata(
|
||||
[(-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (1.0, 1.0, 0.0), (-1.0, 1.0, 0.0)],
|
||||
[],
|
||||
[(0, 1, 2, 3)],
|
||||
)
|
||||
mesh.materials.append(material)
|
||||
uv_layer = mesh.uv_layers.new(name="UVMap")
|
||||
for loop, uv in zip(uv_layer.data, [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]):
|
||||
loop.uv = uv
|
||||
mesh.update()
|
||||
|
||||
obj = bpy.data.objects.new(ROOT_OBJECT, mesh)
|
||||
obj["m12_source_marker"] = SOURCE_MARKER
|
||||
bpy.context.scene.collection.objects.link(obj)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(path), check_existing=False)
|
||||
|
||||
|
||||
def data_block(id_type: str, value: object) -> dict:
|
||||
library = value.library
|
||||
return {
|
||||
"idType": id_type,
|
||||
"name": value.name,
|
||||
"nameFull": value.name_full,
|
||||
"library": None if library is None else pathlib.Path(library.filepath).name,
|
||||
"isLibraryOverride": value.override_library is not None,
|
||||
}
|
||||
|
||||
|
||||
def inspect_graph() -> dict:
|
||||
obj = bpy.data.objects[ROOT_OBJECT]
|
||||
mesh = obj.data
|
||||
material = mesh.materials[0]
|
||||
image_node = material.node_tree.nodes[IMAGE_NODE_NAME]
|
||||
image = image_node.image
|
||||
ids = {
|
||||
"OBJECT": data_block("OBJECT", obj),
|
||||
"MESH": data_block("MESH", mesh),
|
||||
"MATERIAL": data_block("MATERIAL", material),
|
||||
"IMAGE": data_block("IMAGE", image),
|
||||
}
|
||||
return {
|
||||
"root": ids["OBJECT"],
|
||||
"ids": ids,
|
||||
"edges": [
|
||||
{"from": f"Object/{ROOT_OBJECT}", "relation": "OBJECT_DATA", "to": f"Mesh/{MESH_NAME}"},
|
||||
{"from": f"Mesh/{MESH_NAME}", "relation": "MATERIAL_SLOT[0]", "to": f"Material/{MATERIAL_NAME}"},
|
||||
{"from": f"Material/{MATERIAL_NAME}", "relation": f"NODE_IMAGE[{IMAGE_NODE_NAME}]", "to": f"Image/{IMAGE_NAME}"},
|
||||
],
|
||||
"geometry": {
|
||||
"vertices": len(mesh.vertices),
|
||||
"edges": len(mesh.edges),
|
||||
"polygons": len(mesh.polygons),
|
||||
"loops": len(mesh.loops),
|
||||
"uvLayers": [layer.name for layer in mesh.uv_layers],
|
||||
"materialSlots": [item.name for item in mesh.materials],
|
||||
},
|
||||
"image": {
|
||||
"size": list(image.size),
|
||||
"channels": image.channels,
|
||||
"colorspace": image.colorspace_settings.name,
|
||||
"packed": image.packed_file is not None,
|
||||
"pixelFloat32Sha256": pixel_sha256(image),
|
||||
},
|
||||
"sourceMarker": obj["m12_source_marker"],
|
||||
}
|
||||
|
||||
|
||||
def link_object(source: pathlib.Path, target: pathlib.Path) -> dict:
|
||||
reset()
|
||||
with bpy.data.libraries.load(str(source), link=True) as (data_from, data_to):
|
||||
if ROOT_OBJECT not in data_from.objects:
|
||||
raise RuntimeError("source root object is missing")
|
||||
data_to.objects = [ROOT_OBJECT]
|
||||
if len(data_to.objects) != 1 or data_to.objects[0] is None:
|
||||
raise RuntimeError("desktop link did not return one object")
|
||||
bpy.context.scene.collection.objects.link(data_to.objects[0])
|
||||
before_save = inspect_graph()
|
||||
if any(item["library"] is None or item["isLibraryOverride"] for item in before_save["ids"].values()):
|
||||
raise RuntimeError("linked dependency closure did not retain the source library")
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(target), check_existing=False)
|
||||
bpy.ops.wm.open_mainfile(filepath=str(target), load_ui=False)
|
||||
reopened = inspect_graph()
|
||||
if reopened != before_save:
|
||||
raise RuntimeError("linked dependency mapping drifted after save/reopen")
|
||||
return reopened
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if "--" not in sys.argv or len(sys.argv[sys.argv.index("--") + 1 :]) != 2:
|
||||
raise SystemExit("usage: blender --background --factory-startup --python generate-library-link-fixture.py -- OUTPUT_DIR REPORT")
|
||||
output_arg, report_arg = sys.argv[sys.argv.index("--") + 1 :]
|
||||
output_dir = pathlib.Path(output_arg).resolve()
|
||||
report_path = pathlib.Path(report_arg).resolve()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
source = output_dir / "m12_link_source.blend"
|
||||
target = output_dir / "m12_link_target.blend"
|
||||
|
||||
create_source(source)
|
||||
source_graph = inspect_graph()
|
||||
linked_graph = link_object(source, target)
|
||||
report = {
|
||||
"schemaVersion": 1,
|
||||
"task": "M12-03F",
|
||||
"operation": "LINK",
|
||||
"blenderVersion": "5.2.0",
|
||||
"source": {"file": source.name, "sha256": sha256_file(source)},
|
||||
"target": {"file": target.name, "sha256": sha256_file(target)},
|
||||
"selectedRoots": [f"Object/{ROOT_OBJECT}"],
|
||||
"sourceGraph": source_graph,
|
||||
"linkedGraph": linked_graph,
|
||||
"stableMapping": [
|
||||
{"source": f"Object/{ROOT_OBJECT}", "local": f"Object/{ROOT_OBJECT}", "owner": "SOURCE_LIBRARY", "readOnly": True},
|
||||
{"source": f"Mesh/{MESH_NAME}", "local": f"Mesh/{MESH_NAME}", "owner": "SOURCE_LIBRARY", "readOnly": True},
|
||||
{"source": f"Material/{MATERIAL_NAME}", "local": f"Material/{MATERIAL_NAME}", "owner": "SOURCE_LIBRARY", "readOnly": True},
|
||||
{"source": f"Image/{IMAGE_NAME}", "local": f"Image/{IMAGE_NAME}", "owner": "SOURCE_LIBRARY", "readOnly": True},
|
||||
],
|
||||
"nextTask": "M12-03G",
|
||||
}
|
||||
report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
print(
|
||||
f"library-link-fixture-ok roots={len(report['selectedRoots'])} "
|
||||
f"mapping={len(report['stableMapping'])} source={report['source']['sha256']} "
|
||||
f"target={report['target']['sha256']} next={report['nextTask']}"
|
||||
)
|
||||
|
||||
|
||||
main()
|
||||
164
tools/web/generate-library-override-fixture.py
Normal file
164
tools/web/generate-library-override-fixture.py
Normal file
@@ -0,0 +1,164 @@
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
ROOT_OBJECT = "M12 Override Object"
|
||||
MESH_NAME = "M12 Override Mesh"
|
||||
MATERIAL_NAME = "M12 Override Material"
|
||||
SOURCE_MARKER = "M12-03J"
|
||||
PROJECT_ID = "m12-03j-project"
|
||||
OVERRIDE_X = 2.5
|
||||
OVERRIDE_PROPERTY_PATH = '["m12_override_value"]'
|
||||
|
||||
|
||||
def sha256_file(path: pathlib.Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def reset() -> None:
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
|
||||
|
||||
def create_source(path: pathlib.Path) -> None:
|
||||
reset()
|
||||
material = bpy.data.materials.new(MATERIAL_NAME)
|
||||
material.diffuse_color = (0.15, 0.65, 0.35, 1.0)
|
||||
mesh = bpy.data.meshes.new(MESH_NAME)
|
||||
mesh.from_pydata([(-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (1.0, 1.0, 0.0), (-1.0, 1.0, 0.0)], [], [(0, 1, 2, 3)])
|
||||
mesh.materials.append(material)
|
||||
obj = bpy.data.objects.new(ROOT_OBJECT, mesh)
|
||||
obj["m12_source_marker"] = SOURCE_MARKER
|
||||
obj["m12_override_value"] = 1.0
|
||||
bpy.context.scene.collection.objects.link(obj)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(path), check_existing=False)
|
||||
|
||||
|
||||
def data_block(id_type: str, value: object) -> dict:
|
||||
library = value.library
|
||||
return {
|
||||
"idType": id_type,
|
||||
"name": value.name,
|
||||
"nameFull": value.name_full,
|
||||
"library": None if library is None else pathlib.Path(library.filepath).name,
|
||||
"isLibraryOverride": value.override_library is not None,
|
||||
}
|
||||
|
||||
|
||||
def override_property(value: object) -> dict:
|
||||
override = value.override_library
|
||||
if override is None:
|
||||
raise RuntimeError("override library metadata is missing")
|
||||
properties = list(override.properties)
|
||||
matches = [item for item in properties if item.rna_path == OVERRIDE_PROPERTY_PATH]
|
||||
if not matches:
|
||||
print("DEBUG_OVERRIDE_PROPERTIES", [(item.rna_path, len(item.operations)) for item in properties], dir(override.properties))
|
||||
raise RuntimeError("custom property override path is missing")
|
||||
return {
|
||||
"rnaPath": OVERRIDE_PROPERTY_PATH,
|
||||
"index": 0,
|
||||
"value": float(value["m12_override_value"]),
|
||||
"operationCount": sum(len(item.operations) for item in matches),
|
||||
"propertyCount": len(properties),
|
||||
}
|
||||
|
||||
|
||||
def inspect_override() -> dict:
|
||||
obj = bpy.data.objects[ROOT_OBJECT]
|
||||
override = obj.override_library
|
||||
if override is None or override.reference is None:
|
||||
raise RuntimeError("desktop override reference is missing")
|
||||
reference = override.reference
|
||||
if reference.library is None:
|
||||
raise RuntimeError("override reference did not retain source library")
|
||||
if obj.library is not None:
|
||||
raise RuntimeError("override object must be locally owned")
|
||||
return {
|
||||
"reference": {
|
||||
"dataBlockId": f"Object/{reference.name}",
|
||||
"idType": "OBJECT",
|
||||
"library": pathlib.Path(reference.library.filepath).name,
|
||||
"owner": "SOURCE_LIBRARY",
|
||||
"readOnly": True,
|
||||
"isLibraryOverride": False,
|
||||
},
|
||||
"local": {
|
||||
"dataBlockId": f"Object/{obj.name}",
|
||||
"idType": "OBJECT",
|
||||
"library": None,
|
||||
"owner": "LOCAL_OVERRIDE",
|
||||
"projectId": PROJECT_ID,
|
||||
"readOnly": False,
|
||||
"referenceSourceDataBlockId": f"Object/{reference.name}",
|
||||
"hierarchyRootDataBlockId": f"Object/{obj.name}",
|
||||
"isLibraryOverride": True,
|
||||
},
|
||||
"propertyOverride": override_property(obj),
|
||||
"sourceMarker": reference.get("m12_source_marker"),
|
||||
}
|
||||
|
||||
|
||||
def create_override(source: pathlib.Path, target: pathlib.Path) -> dict:
|
||||
reset()
|
||||
with bpy.data.libraries.load(str(source), link=True) as (data_from, data_to):
|
||||
if ROOT_OBJECT not in data_from.objects:
|
||||
raise RuntimeError("source root object is missing")
|
||||
data_to.objects = [ROOT_OBJECT]
|
||||
if len(data_to.objects) != 1 or data_to.objects[0] is None:
|
||||
raise RuntimeError("desktop link did not return one object")
|
||||
linked = data_to.objects[0]
|
||||
bpy.context.scene.collection.objects.link(linked)
|
||||
bpy.context.view_layer.objects.active = linked
|
||||
linked.select_set(True)
|
||||
if bpy.ops.object.make_override_library() != {"FINISHED"}:
|
||||
raise RuntimeError("desktop override operator did not finish")
|
||||
obj = bpy.data.objects[ROOT_OBJECT]
|
||||
obj["m12_override_value"] = OVERRIDE_X
|
||||
prop = obj.override_library.properties.add(OVERRIDE_PROPERTY_PATH)
|
||||
prop.operations.add("REPLACE")
|
||||
before_save = inspect_override()
|
||||
if before_save["propertyOverride"]["value"] != OVERRIDE_X:
|
||||
raise RuntimeError("override property did not apply")
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(target), check_existing=False)
|
||||
bpy.ops.wm.open_mainfile(filepath=str(target), load_ui=False)
|
||||
reopened = inspect_override()
|
||||
if reopened != before_save:
|
||||
raise RuntimeError("desktop override metadata drifted after save/reopen")
|
||||
return reopened
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if "--" not in sys.argv or len(sys.argv[sys.argv.index("--") + 1 :]) != 2:
|
||||
raise SystemExit("usage: blender --background --factory-startup --python generate-library-override-fixture.py -- OUTPUT_DIR REPORT")
|
||||
output_arg, report_arg = sys.argv[sys.argv.index("--") + 1 :]
|
||||
output_dir = pathlib.Path(output_arg).resolve()
|
||||
report_path = pathlib.Path(report_arg).resolve()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
source = output_dir / "m12_override_source.blend"
|
||||
target = output_dir / "m12_override_target.blend"
|
||||
create_source(source)
|
||||
override_graph = create_override(source, target)
|
||||
report = {
|
||||
"schemaVersion": 1,
|
||||
"task": "M12-03J",
|
||||
"operation": "LIBRARY_OVERRIDE",
|
||||
"blenderVersion": "5.2.0",
|
||||
"source": {"file": source.name, "sha256": sha256_file(source)},
|
||||
"target": {"file": target.name, "sha256": sha256_file(target)},
|
||||
"selectedRoots": [f"Object/{ROOT_OBJECT}"],
|
||||
"overrideGraph": override_graph,
|
||||
"nextTask": "M12-03K",
|
||||
}
|
||||
report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
print(
|
||||
f"library-override-fixture-ok roots={len(report['selectedRoots'])} "
|
||||
f"reference={override_graph['reference']['library']} owner={override_graph['local']['owner']} "
|
||||
f"path={override_graph['propertyOverride']['rnaPath']} next={report['nextTask']}"
|
||||
)
|
||||
|
||||
|
||||
main()
|
||||
188
tools/web/generate-malicious-archive-fixtures.mjs
Normal file
188
tools/web/generate-malicious-archive-fixtures.mjs
Normal file
@@ -0,0 +1,188 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const outputArgument = process.argv.indexOf("--output");
|
||||
const outputRoot = outputArgument === -1
|
||||
? path.join(repoRoot, "tests/files/web/archive-security")
|
||||
: path.resolve(process.argv[outputArgument + 1] ?? "");
|
||||
if (!outputRoot) throw new Error("--output requires a directory");
|
||||
|
||||
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
const text = (value) => Buffer.from(value, "utf8");
|
||||
|
||||
const crcTable = Array.from({ length: 256 }, (_, input) => {
|
||||
let value = input;
|
||||
for (let bit = 0; bit < 8; bit++) value = value & 1 ? 0xedb88320 ^ value >>> 1 : value >>> 1;
|
||||
return value >>> 0;
|
||||
});
|
||||
|
||||
function crc32(bytes) {
|
||||
let value = 0xffffffff;
|
||||
for (const byte of bytes) value = crcTable[(value ^ byte) & 0xff] ^ value >>> 8;
|
||||
return (value ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function zipArchive(entries) {
|
||||
const localParts = [];
|
||||
const centralParts = [];
|
||||
let localOffset = 0;
|
||||
for (const entry of entries) {
|
||||
const name = text(entry.path);
|
||||
const data = Buffer.from(entry.data);
|
||||
const uncompressedBytes = entry.uncompressedBytes ?? data.length;
|
||||
const crc = crc32(data);
|
||||
const local = Buffer.alloc(30);
|
||||
local.writeUInt32LE(0x04034b50, 0);
|
||||
local.writeUInt16LE(20, 4);
|
||||
local.writeUInt16LE(0x0800, 6);
|
||||
local.writeUInt16LE(0, 8);
|
||||
local.writeUInt32LE(crc, 14);
|
||||
local.writeUInt32LE(data.length, 18);
|
||||
local.writeUInt32LE(uncompressedBytes, 22);
|
||||
local.writeUInt16LE(name.length, 26);
|
||||
localParts.push(local, name, data);
|
||||
|
||||
const central = Buffer.alloc(46);
|
||||
central.writeUInt32LE(0x02014b50, 0);
|
||||
central.writeUInt16LE(0x0314, 4);
|
||||
central.writeUInt16LE(20, 6);
|
||||
central.writeUInt16LE(0x0800, 8);
|
||||
central.writeUInt16LE(0, 10);
|
||||
central.writeUInt32LE(crc, 16);
|
||||
central.writeUInt32LE(data.length, 20);
|
||||
central.writeUInt32LE(uncompressedBytes, 24);
|
||||
central.writeUInt16LE(name.length, 28);
|
||||
central.writeUInt32LE((0o100644 << 16) >>> 0, 38);
|
||||
central.writeUInt32LE(localOffset, 42);
|
||||
centralParts.push(central, name);
|
||||
localOffset += local.length + name.length + data.length;
|
||||
}
|
||||
const centralDirectory = Buffer.concat(centralParts);
|
||||
const end = Buffer.alloc(22);
|
||||
end.writeUInt32LE(0x06054b50, 0);
|
||||
end.writeUInt16LE(entries.length, 8);
|
||||
end.writeUInt16LE(entries.length, 10);
|
||||
end.writeUInt32LE(centralDirectory.length, 12);
|
||||
end.writeUInt32LE(localOffset, 16);
|
||||
return Buffer.concat([...localParts, centralDirectory, end]);
|
||||
}
|
||||
|
||||
function writeTarText(header, value, offset, length) {
|
||||
const bytes = text(value);
|
||||
if (bytes.length > length) throw new Error(`tar field exceeds ${length} bytes`);
|
||||
bytes.copy(header, offset);
|
||||
}
|
||||
|
||||
function writeTarOctal(header, value, offset, length) {
|
||||
const encoded = value.toString(8).padStart(length - 2, "0");
|
||||
writeTarText(header, `${encoded}\0 `, offset, length);
|
||||
}
|
||||
|
||||
function tarHeader(entry) {
|
||||
const header = Buffer.alloc(512);
|
||||
writeTarText(header, entry.path, 0, 100);
|
||||
writeTarOctal(header, entry.type === "DIRECTORY" ? 0o755 : 0o644, 100, 8);
|
||||
writeTarOctal(header, 0, 108, 8);
|
||||
writeTarOctal(header, 0, 116, 8);
|
||||
const data = entry.type === "FILE" ? Buffer.from(entry.data) : Buffer.alloc(0);
|
||||
writeTarOctal(header, data.length, 124, 12);
|
||||
writeTarOctal(header, 0, 136, 12);
|
||||
header.fill(0x20, 148, 156);
|
||||
header[156] = { FILE: 0x30, SYMLINK: 0x32, HARDLINK: 0x31, DIRECTORY: 0x35 }[entry.type];
|
||||
if (entry.target) writeTarText(header, entry.target, 157, 100);
|
||||
writeTarText(header, "ustar\0", 257, 6);
|
||||
writeTarText(header, "00", 263, 2);
|
||||
writeTarText(header, "root", 265, 32);
|
||||
writeTarText(header, "root", 297, 32);
|
||||
const checksum = header.reduce((sum, byte) => sum + byte, 0);
|
||||
writeTarOctal(header, checksum, 148, 8);
|
||||
return { header, data };
|
||||
}
|
||||
|
||||
function tarArchive(entries) {
|
||||
const parts = [];
|
||||
for (const entry of entries) {
|
||||
const { header, data } = tarHeader(entry);
|
||||
parts.push(header, data);
|
||||
if (data.length % 512 !== 0) parts.push(Buffer.alloc(512 - data.length % 512));
|
||||
}
|
||||
parts.push(Buffer.alloc(1024));
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
const definitions = [
|
||||
{
|
||||
id: "ZIP_PATH_TRAVERSAL",
|
||||
format: "ZIP",
|
||||
file: "zip-path-traversal.zip",
|
||||
threat: "ARCHIVE_ROOT_ESCAPE",
|
||||
gate: "LINK_SAFETY",
|
||||
bytes: zipArchive([{ path: "../outside.txt", data: text("escape") }]),
|
||||
},
|
||||
{
|
||||
id: "ZIP_COMPRESSION_BOMB",
|
||||
format: "ZIP",
|
||||
file: "zip-compression-bomb.zip",
|
||||
threat: "COMPRESSION_RATIO",
|
||||
gate: "CONFLICTS",
|
||||
bytes: zipArchive([{ path: "bomb.bin", data: Buffer.from([0]), uncompressedBytes: 101 }]),
|
||||
},
|
||||
{
|
||||
id: "ZIP_DUPLICATE_PATH",
|
||||
format: "ZIP",
|
||||
file: "zip-duplicate-path.zip",
|
||||
threat: "DUPLICATE_PATH",
|
||||
gate: "LINK_SAFETY",
|
||||
bytes: zipArchive([{ path: "same.bin", data: text("one") }, { path: "same.bin", data: text("two") }]),
|
||||
},
|
||||
{
|
||||
id: "TAR_PATH_TRAVERSAL",
|
||||
format: "TAR",
|
||||
file: "tar-path-traversal.tar",
|
||||
threat: "ARCHIVE_ROOT_ESCAPE",
|
||||
gate: "LINK_SAFETY",
|
||||
bytes: tarArchive([{ path: "../../outside.txt", type: "FILE", data: text("escape") }]),
|
||||
},
|
||||
{
|
||||
id: "TAR_SYMLINK_ESCAPE",
|
||||
format: "TAR",
|
||||
file: "tar-symlink-escape.tar",
|
||||
threat: "SYMLINK_ESCAPE",
|
||||
gate: "LINK_SAFETY",
|
||||
bytes: tarArchive([
|
||||
{ path: "safe", type: "DIRECTORY" },
|
||||
{ path: "safe/link", type: "SYMLINK", target: "../../outside" },
|
||||
]),
|
||||
},
|
||||
{
|
||||
id: "TAR_PREFIX_CONFLICT",
|
||||
format: "TAR",
|
||||
file: "tar-prefix-conflict.tar",
|
||||
threat: "FILE_DIRECTORY_PREFIX_CONFLICT",
|
||||
gate: "CONFLICTS",
|
||||
bytes: tarArchive([
|
||||
{ path: "folder", type: "FILE", data: text("one") },
|
||||
{ path: "folder/payload.bin", type: "FILE", data: text("two") },
|
||||
]),
|
||||
},
|
||||
];
|
||||
|
||||
fs.mkdirSync(outputRoot, { recursive: true });
|
||||
for (const definition of definitions) fs.writeFileSync(path.join(outputRoot, definition.file), definition.bytes);
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
task: "M12-04J",
|
||||
generator: "tools/web/generate-malicious-archive-fixtures.mjs",
|
||||
extractionAllowed: false,
|
||||
cases: definitions.map(({ bytes, ...definition }) => ({
|
||||
...definition,
|
||||
byteLength: bytes.length,
|
||||
sha256: sha256(bytes),
|
||||
expectedCode: "IO_ARCHIVE_UNSAFE",
|
||||
})),
|
||||
};
|
||||
fs.writeFileSync(path.join(outputRoot, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
process.stdout.write(`malicious-archive-fixtures-generated cases=${definitions.length} output=${outputRoot}\n`);
|
||||
39
tools/web/generate-malicious-script-fixture.py
Normal file
39
tools/web/generate-malicious-script-fixture.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
SOURCES = {
|
||||
"MaliciousText.py": "import os\nos.system('touch /tmp/web-blender-forbidden')\n",
|
||||
"DriverExploit.py": "__import__('os').system('touch /tmp/web-driver-forbidden')\n",
|
||||
"HandlerExploit.py": "def handler(scene):\n __import__('subprocess').run(['touch','/tmp/web-handler-forbidden'])\n",
|
||||
"EmbeddedModule.py": "def register():\n __import__('os').system('touch /tmp/web-module-forbidden')\n",
|
||||
}
|
||||
|
||||
|
||||
def sha256_file(path):
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def main(output_dir, report_path):
|
||||
output_dir = Path(output_dir).resolve(); report_path = Path(report_path).resolve(); output_dir.mkdir(parents=True, exist_ok=True); report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
for name, source in SOURCES.items():
|
||||
value = bpy.data.texts.new(name); value.write(source); value.use_module = name == "EmbeddedModule.py"
|
||||
fixture = output_dir / "malicious-script.blend"; bpy.ops.wm.save_as_mainfile(filepath=str(fixture), compress=False)
|
||||
report = {"schemaVersion": 1, "task": "M13-01F", "operation": "MALICIOUS_SCRIPT_FIXTURE", "sources": [{"name": name, "sourceSha256": hashlib.sha256(source.encode()).hexdigest(), "useModule": name == "EmbeddedModule.py", "expectedExecution": "BLOCKED"} for name, source in sorted(SOURCES.items())], "fixture": {"name": fixture.name, "byteLength": fixture.stat().st_size, "sha256": sha256_file(fixture)}, "nextTask": "M13-02A"}
|
||||
report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
print("malicious-script-fixture-generated sources=%s module=1 next=%s" % (len(SOURCES), report["nextTask"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else []
|
||||
if len(args) != 2: raise SystemExit("usage: blender --background --python generate-malicious-script-fixture.py -- OUTPUT_DIR REPORT")
|
||||
main(args[0], args[1])
|
||||
285
tools/web/generate-obj-multi-negative-fixtures.py
Normal file
285
tools/web/generate-obj-multi-negative-fixtures.py
Normal file
@@ -0,0 +1,285 @@
|
||||
"""Generate the pinned Blender 5.2 OBJ multi-object and negative fixtures for M12-07B."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
OBJECTS = ("M12 OBJ Left", "M12 OBJ Right")
|
||||
MATERIALS = ("M12 OBJ Left Material", "M12 OBJ Right Material")
|
||||
TEXTURE_NAME = "m12_obj_texture.png"
|
||||
|
||||
|
||||
def sha256_file(path):
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def text(value):
|
||||
return value.decode("utf-8") if isinstance(value, bytes) else value
|
||||
|
||||
|
||||
def runtime_identity():
|
||||
binary = Path(bpy.app.binary_path)
|
||||
return {
|
||||
"blenderVersion": text(bpy.app.version_string),
|
||||
"versionTuple": list(bpy.app.version),
|
||||
"buildDate": text(bpy.app.build_date),
|
||||
"buildTime": text(bpy.app.build_time),
|
||||
"buildHash": text(bpy.app.build_hash),
|
||||
"buildBranch": text(bpy.app.build_branch),
|
||||
"buildPlatform": text(bpy.app.build_platform),
|
||||
"buildType": text(bpy.app.build_type),
|
||||
"binarySha256": sha256_file(binary),
|
||||
}
|
||||
|
||||
|
||||
def create_texture(path):
|
||||
image = bpy.data.images.new("M12 OBJ Texture", width=2, height=2, alpha=True, float_buffer=False)
|
||||
image.pixels = [
|
||||
1.0, 0.0, 0.0, 1.0,
|
||||
0.0, 1.0, 0.0, 1.0,
|
||||
0.0, 0.0, 1.0, 1.0,
|
||||
1.0, 1.0, 0.0, 1.0,
|
||||
]
|
||||
image.filepath_raw = str(path)
|
||||
image.file_format = "PNG"
|
||||
image.save()
|
||||
return image
|
||||
|
||||
|
||||
def material(name, image):
|
||||
value = bpy.data.materials.new(name)
|
||||
value.use_nodes = True
|
||||
nodes = value.node_tree.nodes
|
||||
links = value.node_tree.links
|
||||
principled = nodes.get("Principled BSDF")
|
||||
texture = nodes.new("ShaderNodeTexImage")
|
||||
texture.image = image
|
||||
links.new(texture.outputs["Color"], principled.inputs["Base Color"])
|
||||
return value
|
||||
|
||||
|
||||
def mesh_object(name, offset, material_value):
|
||||
mesh = bpy.data.meshes.new(name + " Mesh")
|
||||
mesh.from_pydata(
|
||||
[(offset - 0.75, -0.75, 0.0), (offset + 0.75, -0.75, 0.0), (offset + 0.0, 0.75, 0.0)],
|
||||
[],
|
||||
[(0, 1, 2)],
|
||||
)
|
||||
mesh.update()
|
||||
uv = mesh.uv_layers.new(name="UVMap")
|
||||
for loop, value in zip(mesh.loops, ((0.0, 0.0), (1.0, 0.0), (0.5, 1.0))):
|
||||
uv.data[loop.index].uv = value
|
||||
mesh.materials.append(material_value)
|
||||
obj = bpy.data.objects.new(name, mesh)
|
||||
bpy.context.scene.collection.objects.link(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def create_scene(output_dir):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
image = create_texture(output_dir / TEXTURE_NAME)
|
||||
left_material = material(MATERIALS[0], image)
|
||||
right_material = material(MATERIALS[1], image)
|
||||
left = mesh_object(OBJECTS[0], -1.0, left_material)
|
||||
right = mesh_object(OBJECTS[1], 1.0, right_material)
|
||||
for obj in (left, right):
|
||||
obj.select_set(True)
|
||||
for polygon in obj.data.polygons:
|
||||
polygon.use_smooth = False
|
||||
bpy.context.view_layer.objects.active = left
|
||||
return left, right
|
||||
|
||||
|
||||
def export_obj(output_path):
|
||||
result = bpy.ops.wm.obj_export(
|
||||
filepath=str(output_path),
|
||||
export_selected_objects=True,
|
||||
apply_modifiers=False,
|
||||
apply_transform=False,
|
||||
export_eval_mode="DAG_EVAL_VIEWPORT",
|
||||
export_uv=True,
|
||||
export_normals=True,
|
||||
export_colors=False,
|
||||
export_materials=True,
|
||||
export_pbr_extensions=False,
|
||||
export_material_groups=True,
|
||||
export_object_groups=True,
|
||||
export_vertex_groups=False,
|
||||
export_smooth_groups=False,
|
||||
export_triangulated_mesh=False,
|
||||
export_curves_as_nurbs=False,
|
||||
global_scale=1.0,
|
||||
forward_axis="NEGATIVE_Z",
|
||||
up_axis="Y",
|
||||
path_mode="RELATIVE",
|
||||
)
|
||||
if "FINISHED" not in result:
|
||||
raise RuntimeError("Blender OBJ export did not finish: %s" % (result,))
|
||||
|
||||
|
||||
def parse_obj(path):
|
||||
positions = []
|
||||
texcoords = []
|
||||
normals = []
|
||||
faces = []
|
||||
material_libraries = []
|
||||
objects = []
|
||||
groups = []
|
||||
current_object = None
|
||||
current_material = None
|
||||
current_groups = []
|
||||
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
parts = line.split()
|
||||
kind = parts[0]
|
||||
if kind == "v":
|
||||
positions.append([float(value) for value in parts[1:4]])
|
||||
elif kind == "vt":
|
||||
texcoords.append([float(value) for value in parts[1:3]])
|
||||
elif kind == "vn":
|
||||
normals.append([float(value) for value in parts[1:4]])
|
||||
elif kind == "mtllib":
|
||||
material_libraries.extend(parts[1:])
|
||||
elif kind == "o":
|
||||
current_object = " ".join(parts[1:])
|
||||
objects.append(current_object)
|
||||
elif kind == "g":
|
||||
current_groups = parts[1:]
|
||||
for group in current_groups:
|
||||
if group not in groups:
|
||||
groups.append(group)
|
||||
if group.endswith("_Mesh") and group not in objects:
|
||||
current_object = group
|
||||
objects.append(group)
|
||||
elif kind == "usemtl":
|
||||
current_material = " ".join(parts[1:])
|
||||
elif kind == "f":
|
||||
vertices = []
|
||||
for token in parts[1:]:
|
||||
indices = token.split("/")
|
||||
vertices.append({
|
||||
"position": int(indices[0]),
|
||||
"texcoord": int(indices[1]) if len(indices) > 1 and indices[1] else None,
|
||||
"normal": int(indices[2]) if len(indices) > 2 and indices[2] else None,
|
||||
})
|
||||
faces.append({"object": current_object, "groups": list(current_groups), "material": current_material, "vertices": vertices})
|
||||
return {
|
||||
"materialLibraries": material_libraries,
|
||||
"objects": objects,
|
||||
"groups": groups,
|
||||
"positions": positions,
|
||||
"texcoords": texcoords,
|
||||
"normals": normals,
|
||||
"faces": faces,
|
||||
}
|
||||
|
||||
|
||||
def parse_mtl(path):
|
||||
materials = []
|
||||
current = None
|
||||
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
parts = line.split()
|
||||
if parts[0] == "newmtl":
|
||||
current = {"name": " ".join(parts[1:]), "mapKd": None}
|
||||
materials.append(current)
|
||||
elif current is not None and parts[0] == "map_Kd":
|
||||
current["mapKd"] = " ".join(parts[1:])
|
||||
return materials
|
||||
|
||||
|
||||
def negative_index_obj(source, target):
|
||||
lines = []
|
||||
for raw_line in source.read_text(encoding="utf-8").splitlines():
|
||||
if not raw_line.startswith("f "):
|
||||
lines.append(raw_line)
|
||||
continue
|
||||
converted = []
|
||||
for token in raw_line.split()[1:]:
|
||||
position, texcoord, normal = token.split("/")
|
||||
converted.append("%d/%d/%d" % (-int(position), -int(texcoord), -int(normal)))
|
||||
lines.append("f " + " ".join(converted))
|
||||
target.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def malformed_obj(source, target):
|
||||
lines = []
|
||||
replaced = False
|
||||
for raw_line in source.read_text(encoding="utf-8").splitlines():
|
||||
if raw_line.startswith("f ") and not replaced:
|
||||
lines.append("f 1/1/1 2/2/1")
|
||||
replaced = True
|
||||
else:
|
||||
lines.append(raw_line)
|
||||
target.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def main(output_dir, report_path):
|
||||
output_dir = Path(output_dir).resolve()
|
||||
report_path = Path(report_path).resolve()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
obj_path = output_dir / "multi-object.obj"
|
||||
create_scene(output_dir)
|
||||
export_obj(obj_path)
|
||||
mtl_path = obj_path.with_suffix(".mtl")
|
||||
if not mtl_path.exists():
|
||||
raise RuntimeError("Blender OBJ export did not write the MTL sidecar")
|
||||
negative_path = output_dir / "negative-index.obj"
|
||||
malformed_path = output_dir / "malformed-face.obj"
|
||||
negative_index_obj(obj_path, negative_path)
|
||||
malformed_obj(obj_path, malformed_path)
|
||||
semantic = parse_obj(obj_path)
|
||||
semantic["materials"] = parse_mtl(mtl_path)
|
||||
files = []
|
||||
for name in (obj_path.name, mtl_path.name, TEXTURE_NAME, negative_path.name, malformed_path.name):
|
||||
item = output_dir / name
|
||||
files.append({"name": name, "byteLength": item.stat().st_size, "sha256": sha256_file(item)})
|
||||
report = {
|
||||
"schemaVersion": 1,
|
||||
"task": "M12-07B",
|
||||
"operation": "DESKTOP_OBJ_MULTI_OBJECT_AND_NEGATIVE_FIXTURES",
|
||||
"runtime": runtime_identity(),
|
||||
"sourceAnchor": "blender-5.2.0/source/blender/io/wavefront_obj",
|
||||
"operator": "wm.obj_export",
|
||||
"settings": {
|
||||
"forwardAxis": "NEGATIVE_Z",
|
||||
"upAxis": "Y",
|
||||
"globalScale": 1.0,
|
||||
"exportUV": True,
|
||||
"exportNormals": True,
|
||||
"exportMaterials": True,
|
||||
"exportMaterialGroups": True,
|
||||
"exportObjectGroups": True,
|
||||
"pathMode": "RELATIVE",
|
||||
},
|
||||
"files": files,
|
||||
"semantic": semantic,
|
||||
"negativeIndex": {"file": negative_path.name, "expectedStatus": "ACCEPT_WITH_NEGATIVE_INDICES", "faceCount": 2},
|
||||
"malformedFace": {"file": malformed_path.name, "expectedCode": "OBJ_FACE_ARITY_INVALID"},
|
||||
"textureOrigin": {"mtlMapKd": [material["mapKd"] for material in semantic["materials"]], "relative": True, "textureFile": TEXTURE_NAME},
|
||||
"nextTask": "M12-07C",
|
||||
}
|
||||
report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
print("obj-multi-negative-fixtures-generated objects=%s faces=%s negative=true malformed=true next=%s" % (len(semantic["objects"]), len(semantic["faces"]), report["nextTask"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else []
|
||||
if len(args) != 2:
|
||||
raise SystemExit("usage: blender --background --python generate-obj-multi-negative-fixtures.py -- OUTPUT_DIR REPORT")
|
||||
main(args[0], args[1])
|
||||
239
tools/web/generate-obj-single-mesh-fixture.py
Normal file
239
tools/web/generate-obj-single-mesh-fixture.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""Generate the pinned Blender 5.2 single-Mesh OBJ fixture for M12-07A."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
OBJ_NAME = "M12 OBJ Single Mesh"
|
||||
MATERIAL_NAMES = ("M12 OBJ Red", "M12 OBJ Blue")
|
||||
|
||||
|
||||
def sha256_file(path):
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def text(value):
|
||||
return value.decode("utf-8") if isinstance(value, bytes) else value
|
||||
|
||||
|
||||
def runtime_identity():
|
||||
binary = Path(bpy.app.binary_path)
|
||||
return {
|
||||
"blenderVersion": text(bpy.app.version_string),
|
||||
"versionTuple": list(bpy.app.version),
|
||||
"buildDate": text(bpy.app.build_date),
|
||||
"buildTime": text(bpy.app.build_time),
|
||||
"buildHash": text(bpy.app.build_hash),
|
||||
"buildBranch": text(bpy.app.build_branch),
|
||||
"buildPlatform": text(bpy.app.build_platform),
|
||||
"buildType": text(bpy.app.build_type),
|
||||
"binarySha256": sha256_file(binary),
|
||||
}
|
||||
|
||||
|
||||
def create_fixture():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
mesh = bpy.data.meshes.new(OBJ_NAME + " Mesh")
|
||||
mesh.from_pydata(
|
||||
[(-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (1.0, 1.0, 0.0), (-1.0, 1.0, 0.0)],
|
||||
[],
|
||||
[(0, 1, 2), (0, 2, 3)],
|
||||
)
|
||||
mesh.update()
|
||||
obj = bpy.data.objects.new(OBJ_NAME, mesh)
|
||||
bpy.context.scene.collection.objects.link(obj)
|
||||
|
||||
uv_layer = mesh.uv_layers.new(name="UVMap")
|
||||
uv_by_vertex = ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0))
|
||||
for loop in mesh.loops:
|
||||
uv_layer.data[loop.index].uv = uv_by_vertex[loop.vertex_index]
|
||||
|
||||
colors = ((0.8, 0.1, 0.05, 1.0), (0.05, 0.2, 0.85, 1.0))
|
||||
for name, color in zip(MATERIAL_NAMES, colors):
|
||||
material = bpy.data.materials.new(name)
|
||||
material.diffuse_color = color
|
||||
material.metallic = 0.0
|
||||
material.roughness = 0.5
|
||||
mesh.materials.append(material)
|
||||
mesh.polygons[0].material_index = 0
|
||||
mesh.polygons[1].material_index = 1
|
||||
for polygon in mesh.polygons:
|
||||
polygon.use_smooth = False
|
||||
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
return obj
|
||||
|
||||
|
||||
def export_obj(output_path):
|
||||
result = bpy.ops.wm.obj_export(
|
||||
filepath=str(output_path),
|
||||
export_selected_objects=True,
|
||||
apply_modifiers=False,
|
||||
apply_transform=False,
|
||||
export_eval_mode="DAG_EVAL_VIEWPORT",
|
||||
export_uv=True,
|
||||
export_normals=True,
|
||||
export_colors=False,
|
||||
export_materials=True,
|
||||
export_pbr_extensions=False,
|
||||
export_material_groups=True,
|
||||
export_object_groups=False,
|
||||
export_vertex_groups=False,
|
||||
export_smooth_groups=False,
|
||||
export_triangulated_mesh=False,
|
||||
export_curves_as_nurbs=False,
|
||||
global_scale=1.0,
|
||||
forward_axis="NEGATIVE_Z",
|
||||
up_axis="Y",
|
||||
path_mode="RELATIVE",
|
||||
)
|
||||
if "FINISHED" not in result:
|
||||
raise RuntimeError("Blender OBJ export did not finish: %s" % (result,))
|
||||
|
||||
|
||||
def number(value):
|
||||
parsed = float(value)
|
||||
return 0.0 if parsed == 0.0 else parsed
|
||||
|
||||
|
||||
def parse_obj(path):
|
||||
positions = []
|
||||
texcoords = []
|
||||
normals = []
|
||||
faces = []
|
||||
material_libraries = []
|
||||
objects = []
|
||||
current_material = None
|
||||
current_groups = []
|
||||
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
parts = line.split()
|
||||
kind = parts[0]
|
||||
if kind == "v":
|
||||
positions.append([number(value) for value in parts[1:4]])
|
||||
elif kind == "vt":
|
||||
texcoords.append([number(value) for value in parts[1:3]])
|
||||
elif kind == "vn":
|
||||
normals.append([number(value) for value in parts[1:4]])
|
||||
elif kind == "mtllib":
|
||||
material_libraries.extend(parts[1:])
|
||||
elif kind == "o":
|
||||
objects.append(" ".join(parts[1:]))
|
||||
elif kind == "g":
|
||||
current_groups = parts[1:]
|
||||
elif kind == "usemtl":
|
||||
current_material = " ".join(parts[1:])
|
||||
elif kind == "f":
|
||||
vertices = []
|
||||
for token in parts[1:]:
|
||||
indices = token.split("/")
|
||||
vertices.append({
|
||||
"position": int(indices[0]),
|
||||
"texcoord": int(indices[1]) if len(indices) > 1 and indices[1] else None,
|
||||
"normal": int(indices[2]) if len(indices) > 2 and indices[2] else None,
|
||||
})
|
||||
faces.append({"vertices": vertices, "material": current_material, "groups": list(current_groups)})
|
||||
return {
|
||||
"materialLibraries": material_libraries,
|
||||
"objects": objects,
|
||||
"positions": positions,
|
||||
"texcoords": texcoords,
|
||||
"normals": normals,
|
||||
"faces": faces,
|
||||
}
|
||||
|
||||
|
||||
def parse_mtl(path):
|
||||
materials = []
|
||||
current = None
|
||||
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
parts = line.split()
|
||||
if parts[0] == "newmtl":
|
||||
current = {"name": " ".join(parts[1:]), "properties": {}}
|
||||
materials.append(current)
|
||||
elif current is not None:
|
||||
values = parts[1:]
|
||||
current["properties"][parts[0]] = [number(value) for value in values] if all_value_numbers(values) else " ".join(values)
|
||||
return materials
|
||||
|
||||
|
||||
def all_value_numbers(values):
|
||||
if not values:
|
||||
return False
|
||||
try:
|
||||
for value in values:
|
||||
float(value)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def main(output_dir, report_path):
|
||||
output_dir = Path(output_dir).resolve()
|
||||
report_path = Path(report_path).resolve()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
obj_path = output_dir / "single-mesh.obj"
|
||||
create_fixture()
|
||||
export_obj(obj_path)
|
||||
mtl_path = obj_path.with_suffix(".mtl")
|
||||
if not mtl_path.exists():
|
||||
raise RuntimeError("Blender OBJ export did not write the MTL sidecar")
|
||||
semantic = parse_obj(obj_path)
|
||||
semantic["materials"] = parse_mtl(mtl_path)
|
||||
report = {
|
||||
"schemaVersion": 1,
|
||||
"task": "M12-07A",
|
||||
"operation": "DESKTOP_OBJ_SINGLE_MESH_FIXTURE",
|
||||
"runtime": runtime_identity(),
|
||||
"sourceAnchor": "blender-5.2.0/source/blender/io/wavefront_obj",
|
||||
"operator": "wm.obj_export",
|
||||
"settings": {
|
||||
"forwardAxis": "NEGATIVE_Z",
|
||||
"upAxis": "Y",
|
||||
"globalScale": 1.0,
|
||||
"exportUV": True,
|
||||
"exportNormals": True,
|
||||
"exportMaterials": True,
|
||||
"exportMaterialGroups": True,
|
||||
},
|
||||
"files": [
|
||||
{"name": obj_path.name, "byteLength": obj_path.stat().st_size, "sha256": sha256_file(obj_path)},
|
||||
{"name": mtl_path.name, "byteLength": mtl_path.stat().st_size, "sha256": sha256_file(mtl_path)},
|
||||
],
|
||||
"semantic": semantic,
|
||||
"nextTask": "M12-07B",
|
||||
}
|
||||
report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
print(
|
||||
"obj-single-mesh-fixture-generated positions=%s texcoords=%s normals=%s faces=%s materials=%s next=%s"
|
||||
% (
|
||||
len(semantic["positions"]),
|
||||
len(semantic["texcoords"]),
|
||||
len(semantic["normals"]),
|
||||
len(semantic["faces"]),
|
||||
len(semantic["materials"]),
|
||||
report["nextTask"],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else []
|
||||
if len(args) != 2:
|
||||
raise SystemExit("usage: blender --background --python generate-obj-single-mesh-fixture.py -- OUTPUT_DIR REPORT")
|
||||
main(args[0], args[1])
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user