import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; export const SERVER_JOB_DIRECTORY_SCHEMA = 1; const JOB_ID = /^[-A-Za-z0-9:_./]{1,256}$/; function validateJobId(jobId) { if (typeof jobId !== "string" || !JOB_ID.test(jobId) || jobId.includes("..")) throw new Error("SERVER_JOB_DIRECTORY_INVALID: jobId is invalid"); } function validateRoot(root) { if (typeof root !== "string" || !path.isAbsolute(root)) throw new Error("SERVER_JOB_DIRECTORY_INVALID: root must be absolute"); return path.resolve(root); } export async function createServerJobDirectory(root, jobId) { const resolvedRoot = validateRoot(root); validateJobId(jobId); await fs.mkdir(resolvedRoot, { recursive: true, mode: 0o700 }); const randomPrefix = `.blender-job-${crypto.randomUUID()}-`; const directory = await fs.mkdtemp(path.join(resolvedRoot, randomPrefix), { encoding: "utf8" }); await fs.chmod(directory, 0o700); return Object.freeze({ schemaVersion: SERVER_JOB_DIRECTORY_SCHEMA, jobId, root: resolvedRoot, path: directory, directoryName: path.basename(directory), state: "ALLOCATED", cleanupCount: 0 }); } export async function cleanupServerJobDirectory(job) { if (!job || job.schemaVersion !== SERVER_JOB_DIRECTORY_SCHEMA || typeof job.path !== "string" || typeof job.root !== "string") throw new Error("SERVER_JOB_DIRECTORY_INVALID: receipt is invalid"); if (job.state === "CLEANED") return job; const root = validateRoot(job.root); const directory = path.resolve(job.path); if (path.dirname(directory) !== root || !path.basename(directory).startsWith(".blender-job-")) throw new Error("SERVER_JOB_DIRECTORY_INVALID: directory escaped the job root"); await fs.chmod(directory, 0o700); try { await fs.chmod(path.join(directory, "source"), 0o700); } catch (error) { if (error?.code !== "ENOENT") throw error; } await fs.rm(directory, { recursive: true, force: false }); return Object.freeze({ ...job, state: "CLEANED", cleanupCount: 1 }); } export async function prepareServerJobWorkspace(job, sourceBytes, sourceName = "source.blend") { if (!job || job.schemaVersion !== SERVER_JOB_DIRECTORY_SCHEMA || job.state !== "ALLOCATED") throw new Error("SERVER_JOB_WORKSPACE_INVALID: job receipt is not allocated"); if (!(sourceBytes instanceof Uint8Array) || sourceBytes.byteLength < 1) throw new Error("SERVER_JOB_WORKSPACE_INVALID: source bytes are empty"); if (typeof sourceName !== "string" || !/^[A-Za-z0-9_.-]{1,128}$/.test(sourceName) || sourceName.includes("..")) throw new Error("SERVER_JOB_WORKSPACE_INVALID: source name is invalid"); const sourceDirectory = path.join(job.path, "source"); const outputDirectory = path.join(job.path, "output"); await fs.mkdir(sourceDirectory, { mode: 0o700 }); await fs.mkdir(outputDirectory, { mode: 0o700 }); const sourcePath = path.join(sourceDirectory, sourceName); await fs.writeFile(sourcePath, sourceBytes, { mode: 0o444, flag: "wx" }); await fs.chmod(sourceDirectory, 0o555); await fs.chmod(sourcePath, 0o444); await fs.chmod(outputDirectory, 0o700); return Object.freeze({ schemaVersion: SERVER_JOB_DIRECTORY_SCHEMA, sourceDirectory, sourcePath, outputDirectory, sourceMode: "0444", sourceDirectoryMode: "0555", outputDirectoryMode: "0700", sourceReadOnly: true, outputWritable: true }); }