109 lines
7.3 KiB
JavaScript
109 lines
7.3 KiB
JavaScript
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 });
|
|
}
|