import assert from "node:assert/strict"; import crypto from "node:crypto"; import fs from "node:fs"; import fsp from "node:fs/promises"; import http from "node:http"; import os from "node:os"; import path from "node:path"; import { createRequire } from "node:module"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { fileURLToPath } from "node:url"; const execFileAsync = promisify(execFile); const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const fixture = path.join(root, "tests/files/web/m11_render_reference.blend"); const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender"); const renderScript = path.join(root, "tools/web/render-server-job.py"); const golden = JSON.parse(await fsp.readFile(path.join(root, "tests/golden/M11-06/server-render-job.json"), "utf8")); const sourceBytes = await fsp.readFile(fixture); const sourceArrayBuffer = sourceBytes.buffer.slice(sourceBytes.byteOffset, sourceBytes.byteOffset + sourceBytes.byteLength); const buildSha256 = crypto.createHash("sha256").update(await fsp.readFile(blender)).digest("hex"); const versionOutput = (await execFileAsync(blender, ["--version"], { cwd: root })).stdout; const versionLine = versionOutput.split(/\r?\n/).find((line) => line.startsWith("Blender ")) ?? ""; const buildVersion = versionLine.match(/^Blender\s+(5\.2\.[0-9]+)\b/)?.[1]; assert.equal(buildVersion, "5.2.0", `unexpected Blender build: ${versionOutput}`); assert.equal(golden.schemaVersion, 1); assert.equal(golden.task, "M11-06"); assert.equal(golden.blenderVersion, buildVersion); assert.ok(fs.existsSync(fixture), "M11-04 source fixture is missing"); const requireFromWeb = createRequire(path.join(root, "web/package.json")); const ts = requireFromWeb("typescript"); const protocolSource = fs.readFileSync(path.join(root, "web/protocol/server-render-job.ts"), "utf8") .replace('import type { ErrorCode } from "./error";\n', ""); const transpiled = ts.transpileModule(protocolSource, { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: "server-render-job.ts", reportDiagnostics: true, }); assert.deepEqual(transpiled.diagnostics, []); const protocol = await import(`data:text/javascript;base64,${Buffer.from(transpiled.outputText).toString("base64")}`); const settings = { renderEngine: "BLENDER_EEVEE", frameStart: 1, frameEnd: 1, resolutionX: 256, resolutionY: 256, resolutionPercentage: 100, samples: 1, outputMime: "image/png", transparent: false, }; const build = { version: buildVersion, buildSha256 }; const temporary = await fsp.mkdtemp(path.join(os.tmpdir(), "m11-server-render-job-")); function json(response, status, value) { const body = Buffer.from(`${JSON.stringify(value)}\n`); response.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store", "Content-Length": body.length }); response.end(body); } const server = http.createServer(async (request, response) => { if (request.method !== "POST" || request.url !== "/v1/render/jobs") return json(response, 404, { code: "NOT_FOUND" }); const chunks = []; let sourceByteLength = 0; try { for await (const chunk of request) { chunks.push(chunk); sourceByteLength += chunk.length; if (sourceByteLength > 512 * 1024 * 1024) throw new Error("SERVER_RENDER_SOURCE_INVALID: upload exceeds budget"); } const body = Buffer.concat(chunks); const requestHeader = request.headers["x-render-request"]; if (typeof requestHeader !== "string") throw new Error("SERVER_RENDER_REQUEST_INVALID: missing request metadata"); const renderRequest = await protocol.verifyServerRenderJobRequest(JSON.parse(requestHeader), body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength)); if (renderRequest.blenderBuild.version !== build.version || renderRequest.blenderBuild.buildSha256 !== build.buildSha256) { throw Object.assign(new Error("SERVER_RENDER_BUILD_MISMATCH: requested build is not the executable serving this job"), { code: "SERVER_RENDER_BUILD_MISMATCH" }); } const fileStem = renderRequest.jobId.replace(/[^A-Za-z0-9_.-]/g, "_"); const sourcePath = path.join(temporary, `${fileStem}.blend`); const outputPath = path.join(temporary, `${fileStem}.png`); const settingsPath = path.join(temporary, `${fileStem}.json`); await fsp.writeFile(sourcePath, body, { flag: "wx", mode: 0o600 }); await fsp.writeFile(settingsPath, `${JSON.stringify(renderRequest.settings)}\n`, { flag: "wx", mode: 0o600 }); await execFileAsync(blender, ["--background", "--factory-startup", "--python", renderScript, "--", sourcePath, outputPath, settingsPath], { cwd: root, maxBuffer: 2 * 1024 * 1024 }); const outputBytes = await fsp.readFile(outputPath); const result = await protocol.createServerRenderJobResult(renderRequest, outputBytes.buffer.slice(outputBytes.byteOffset, outputBytes.byteOffset + outputBytes.byteLength)); json(response, 200, result); } catch (error) { json(response, 400, { code: error?.code ?? "SERVER_RENDER_FAILED", message: error instanceof Error ? error.message : String(error) }); } }); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); }); const address = server.address(); assert.ok(address && typeof address !== "string"); const origin = `http://127.0.0.1:${address.port}`; try { const renderRequest = await protocol.createServerRenderJobRequest(sourceArrayBuffer, build, settings, { jobId: "render:m11-06", sourceRevision: 11 }); const response = await fetch(`${origin}/v1/render/jobs`, { method: "POST", headers: { "Content-Type": "application/x-blend", "X-Render-Request": JSON.stringify(renderRequest) }, body: sourceBytes, duplex: "half", }); const responseText = await response.text(); assert.equal(response.status, 200, responseText); const result = JSON.parse(responseText); const outputPath = path.join(temporary, "verified-output.png"); const outputBytes = await fsp.readFile(path.join(temporary, "render_m11-06.png")); await fsp.writeFile(outputPath, outputBytes); await protocol.verifyServerRenderJobResult(result, renderRequest, outputBytes.buffer.slice(outputBytes.byteOffset, outputBytes.byteOffset + outputBytes.byteLength)); assert.equal(result.sourceBlendSha256, renderRequest.sourceBlendSha256); assert.equal(result.settingsSha256, renderRequest.settingsSha256); assert.equal(result.sourceBlendSha256, golden.sourceBlendSha256); assert.equal(result.settingsSha256, golden.settingsSha256); assert.equal(result.outputMime, golden.output.mime); assert.ok(result.outputByteLength >= golden.output.minimumByteLength); assert.match(result.outputSha256, new RegExp(golden.output.sha256Pattern)); const tamperedSource = Buffer.from(sourceBytes); tamperedSource[0] ^= 0xff; const sourceFailure = await fetch(`${origin}/v1/render/jobs`, { method: "POST", headers: { "Content-Type": "application/x-blend", "X-Render-Request": JSON.stringify(renderRequest) }, body: tamperedSource, duplex: "half", }); assert.equal(sourceFailure.status, 400); assert.equal((await sourceFailure.json()).code, golden.tamperCodes.source); const wrongBuildRequest = await protocol.createServerRenderJobRequest(sourceArrayBuffer, { ...build, buildSha256: "b".repeat(64) }, settings, { jobId: "render:wrong-build", sourceRevision: 11 }); const buildFailure = await fetch(`${origin}/v1/render/jobs`, { method: "POST", headers: { "Content-Type": "application/x-blend", "X-Render-Request": JSON.stringify(wrongBuildRequest) }, body: sourceBytes, duplex: "half", }); assert.equal(buildFailure.status, 400); assert.equal((await buildFailure.json()).code, golden.tamperCodes.build); const settingsFailureRequest = { ...renderRequest, settings: { ...renderRequest.settings, samples: 2 } }; const settingsFailure = await fetch(`${origin}/v1/render/jobs`, { method: "POST", headers: { "Content-Type": "application/x-blend", "X-Render-Request": JSON.stringify(settingsFailureRequest) }, body: sourceBytes, duplex: "half", }); assert.equal(settingsFailure.status, 400); assert.equal((await settingsFailure.json()).code, golden.tamperCodes.settings); const tamperedOutput = Buffer.from(outputBytes); tamperedOutput[tamperedOutput.length - 1] ^= 0xff; await assert.rejects( protocol.verifyServerRenderJobResult(result, renderRequest, tamperedOutput.buffer.slice(tamperedOutput.byteOffset, tamperedOutput.byteOffset + tamperedOutput.byteLength)), { code: golden.tamperCodes.output }, ); process.stdout.write(`server-render-job-ok blender=${buildVersion} source=${renderRequest.sourceBlendSha256} settings=${renderRequest.settingsSha256} output=${result.outputSha256} bytes=${result.outputByteLength} source-hash=1 build-hash=1 settings-hash=1 output-hash=1 tamper=4\n`); } finally { await new Promise((resolve) => server.close(resolve)); await fsp.rm(temporary, { recursive: true, force: true }); }