// paper-crunch.mjs — On-demand xelatex for a private, uncommitted document. // // Upload a self-contained .tar.gz of a paper directory, get a PDF back. This // is the *opposite* of papers-builder.mjs: nothing here is committed, nothing // is deployed. It never writes to system/public/papers.aesthetic.computer/, // never runs `papers/cli.mjs publish|deploy`, and never touches the git clone // — it only *reads* the house .sty files and webfonts out of it, so a bundle // that uses the AC styles resolves its fonts. That constraint is the whole // reason this module exists: the oven is the only box with the toolchain, and // some documents must never reach papers.aesthetic.computer. // // The bundle is untrusted. Entries are checked before extraction, the gzip // stream is capped mid-flight, the build runs shell-escape off under a // wall-clock deadline, and the sources are shredded the moment the PDF is out. import { promises as fs, existsSync, createWriteStream } from "fs"; import path from "path"; import zlib from "zlib"; import { Readable, Transform } from "stream"; import { pipeline } from "stream/promises"; import { randomUUID } from "crypto"; import { spawn, execFile } from "child_process"; import { fileURLToPath } from "url"; const MAX_RECENT_JOBS = 20; const MAX_LOG_LINES = 2000; const MAX_BUNDLE_BYTES = 32 * 1024 * 1024; // compressed upload const MAX_EXTRACT_BYTES = 192 * 1024 * 1024; // decompressed tar const BUILD_TIMEOUT_MS = 180_000; // whole 4-pass chain const JOB_TTL_MS = 30 * 60 * 1000; // then the PDF is gone too const WORK_ROOT = process.env.CRUNCH_WORK_DIR || "/tmp/oven-crunch"; // The house .sty + webfonts live in the oven's git clone. Off the droplet // (local dev) fall back to this repo, so a laptop with a TeX install can // exercise the same sandbox. const HERE = path.dirname(fileURLToPath(import.meta.url)); const ASSET_ROOT = [process.env.NATIVE_GIT_DIR, "/opt/oven/native-git", path.resolve(HERE, "..")] .filter(Boolean) .find((dir) => existsSync(path.join(dir, "system", "public", "type", "webfonts"))); const jobs = new Map(); const jobOrder = []; let activeJobId = null; function nowISO() { return new Date().toISOString(); } function stripAnsi(s) { return String(s || "").replace(/\u001b\[[0-9;]*m/g, ""); } function addLogLine(job, stream, line) { const clean = stripAnsi(line).replace(/\r/g, "").trimEnd(); if (!clean) return; job.logs.push({ ts: nowISO(), stream, line: clean }); if (job.logs.length > MAX_LOG_LINES) job.logs.splice(0, job.logs.length - MAX_LOG_LINES); job.updatedAt = nowISO(); } function makeSnapshot(job, opts = {}) { const { includeLogs = false, tail = 200 } = opts; const snap = { id: job.id, name: job.name, tex: job.tex, status: job.status, stage: job.stage, percent: job.percent, createdAt: job.createdAt, startedAt: job.startedAt, updatedAt: job.updatedAt, finishedAt: job.finishedAt, error: job.error, pages: job.pages, pdfBytes: job.pdfBytes, pdfReady: !!job.pdfPath, expiresAt: job.expiresAt, logCount: job.logs.length, elapsedMs: job.startedAt ? (job.finishedAt ? Date.parse(job.finishedAt) : Date.now()) - Date.parse(job.startedAt) : 0, }; if (includeLogs) { const start = Math.max(0, job.logs.length - Math.max(0, tail)); snap.logs = job.logs.slice(start); } return snap; } function wireStream(job, proc, streamName) { let pending = ""; const s = streamName === "stdout" ? proc.stdout : proc.stderr; s.on("data", (chunk) => { pending += chunk.toString(); let idx; while ((idx = pending.indexOf("\n")) >= 0) { addLogLine(job, streamName, pending.slice(0, idx)); pending = pending.slice(idx + 1); } }); s.on("end", () => { if (pending) addLogLine(job, streamName, pending); }); } function tar(args, cwd) { return new Promise((resolve, reject) => { execFile("tar", args, { cwd, timeout: 60_000, maxBuffer: 16 * 1024 * 1024 }, (err, stdout) => { if (err) return reject(err); resolve(stdout); }); }); } // A tar entry we will not extract: anything that could land outside the job // dir, plus links of any kind (a symlink to /etc would be readable by xelatex). function rejectEntry(name) { if (!name || name.startsWith("/") || /^[A-Za-z]:/.test(name)) return "absolute path"; if (name.split("/").includes("..")) return "parent traversal"; return null; } // Decompress through a hard ceiling, so a gzip bomb can't fill the droplet. async function gunzipCapped(bundle, tarPath) { let seen = 0; const ceiling = new Transform({ transform(chunk, _enc, cb) { seen += chunk.length; if (seen > MAX_EXTRACT_BYTES) return cb(new Error(`bundle expands past ${MAX_EXTRACT_BYTES} bytes`)); cb(null, chunk); }, }); await pipeline(Readable.from(bundle), zlib.createGunzip(), ceiling, createWriteStream(tarPath)); } // Lay out a fake repo root, because ac-paper-*.sty loads fonts by the relative // path a real paper sees: `../../system/public/type/webfonts/` from // papers//. So the sources go two levels down and the webfonts hang off // the sandbox root. The house styles are linked in beside them, so a bundle // may \usepackage{ac-paper-essay} without shipping a copy. async function pitchSandbox(job) { const root = path.join(job.dir, "root"); const papers = path.join(root, "papers"); const src = path.join(papers, "src"); await fs.mkdir(src, { recursive: true }); if (!ASSET_ROOT) { addLogLine(job, "stderr", " WARN: no AC asset root — house fonts and styles unavailable"); return { root, papers, src }; } const type = path.join(root, "system", "public", "type"); await fs.mkdir(type, { recursive: true }); await fs.symlink( path.join(ASSET_ROOT, "system", "public", "type", "webfonts"), path.join(type, "webfonts"), ); for (const sty of await fs.readdir(path.join(ASSET_ROOT, "papers"))) { if (!sty.endsWith(".sty")) continue; await fs.symlink(path.join(ASSET_ROOT, "papers", sty), path.join(papers, sty)); } addLogLine(job, "stdout", ` FONTS ${path.join(ASSET_ROOT, "system/public/type/webfonts")}`); return { root, papers, src }; } async function unpack(job, src) { const tarPath = path.join(job.dir, "bundle.tar"); await gunzipCapped(job.bundle, tarPath); job.bundle = null; // the upload buffer has served its purpose const names = (await tar(["-tf", tarPath])).split("\n").filter(Boolean); if (!names.length) throw new Error("bundle is empty"); for (const name of names) { const why = rejectEntry(name); if (why) throw new Error(`refusing bundle entry (${why}): ${name}`); } // -tv leads each line with the mode string; `l`/`h` are sym/hard links. for (const line of (await tar(["-tvf", tarPath])).split("\n")) { if (line[0] === "l" || line[0] === "h") throw new Error(`refusing bundle entry (link): ${line.trim()}`); } await tar(["-xf", tarPath, "-C", src, "--no-same-owner"]); await fs.rm(tarPath, { force: true }); addLogLine(job, "stdout", ` UNPACK ${names.length} entries`); } // The entrypoint is the one .tex with a \documentclass — unless the caller // named it, in which case take them at their word. async function findTex(src, wanted) { const texs = (await fs.readdir(src)).filter((f) => f.endsWith(".tex")); if (wanted) { const named = wanted.endsWith(".tex") ? wanted : `${wanted}.tex`; if (!texs.includes(named)) throw new Error(`no ${named} in bundle (found: ${texs.join(", ") || "no .tex at all"})`); return named; } if (!texs.length) throw new Error("no .tex at the top of the bundle"); const docs = []; for (const t of texs) { const body = await fs.readFile(path.join(src, t), "utf8").catch(() => ""); if (body.includes("\\documentclass")) docs.push(t); } if (docs.length === 1) return docs[0]; if (docs.length > 1) throw new Error(`ambiguous entrypoint — pass --tex (candidates: ${docs.join(", ")})`); throw new Error(`no .tex declares a \\documentclass (found: ${texs.join(", ")})`); } function killGroup(proc) { try { process.kill(-proc.pid, "SIGKILL"); } catch { try { proc.kill("SIGKILL"); } catch {} } } function run(job, cmd, args, cwd, env, msLeft) { return new Promise((resolve) => { const proc = spawn(cmd, args, { cwd, env, detached: true, // own process group, so a hung pass dies whole stdio: ["ignore", "pipe", "pipe"], }); job.process = proc; wireStream(job, proc, "stdout"); wireStream(job, proc, "stderr"); const alarm = setTimeout(() => { job.timedOut = true; killGroup(proc); }, Math.max(1, msLeft)); proc.on("error", (err) => { clearTimeout(alarm); job.process = null; addLogLine(job, "stderr", ` ${cmd}: ${err.message}`); resolve(127); }); proc.on("close", (code) => { clearTimeout(alarm); job.process = null; resolve(code); }); }); } // The silent failure: fontspec can't find a font, falls back to nullfont, and // xelatex still exits 0 with a stub PDF. Same sniff as papers/cli.mjs. async function logShowsBrokenBuild(logPath) { const log = await fs.readFile(logPath, "utf8").catch(() => ""); return log.includes("! Package fontspec Error") || log.includes("nullfont"); } async function pdfPages(pdfPath) { return new Promise((resolve) => { execFile("pdfinfo", [pdfPath], { timeout: 10_000 }, (err, stdout) => { if (err) return resolve(null); const m = stdout.match(/^Pages:\s+(\d+)/m); resolve(m ? parseInt(m[1], 10) : null); }); }); } async function crunch(job) { job.status = "running"; job.startedAt = nowISO(); job.stage = "unpack"; job.percent = 5; const { papers, src } = await pitchSandbox(job); await unpack(job, src); job.tex = await findTex(src, job.wantedTex); const base = job.tex.replace(/\.tex$/, ""); addLogLine(job, "stdout", ` CRUNCH ${job.tex}`); const env = { ...process.env, TERM: "dumb", TEXINPUTS: `.:${papers}:`, openout_any: "p", // xelatex writes inside the sandbox or not at all shell_escape: "f", }; const deadline = Date.now() + BUILD_TIMEOUT_MS; const xelatex = ["-interaction=nonstopmode", "-file-line-error", "-no-shell-escape", job.tex]; const passes = [ ["xelatex", xelatex, "pass 1"], ["bibtex", [base], "bibtex"], ["xelatex", xelatex, "pass 2"], ["xelatex", xelatex, "pass 3"], ]; for (const [i, [cmd, args, label]] of passes.entries()) { job.stage = label; job.percent = 10 + Math.round((i / passes.length) * 80); addLogLine(job, "stdout", ` ${label.toUpperCase()}`); await run(job, cmd, args, src, env, deadline - Date.now()); if (job.timedOut) throw new Error(`build exceeded ${BUILD_TIMEOUT_MS / 1000}s — killed`); if (job.status === "cancelled") throw new Error("cancelled"); // Exit codes are advisory here: xelatex returns non-zero on warnings and // bibtex on a missing .bib. The PDF is the only success criterion. } job.stage = "verify"; job.percent = 95; const built = path.join(src, `${base}.pdf`); if (!existsSync(built)) throw new Error(`no PDF produced — see the log for the LaTeX error`); if (await logShowsBrokenBuild(path.join(src, `${base}.log`))) throw new Error("fonts failed to load (fontspec/nullfont) — the PDF would be a stub"); const out = path.join(job.dir, "out.pdf"); await fs.rename(built, out); job.pdfPath = out; job.pdfBytes = (await fs.stat(out)).size; job.pages = await pdfPages(out); addLogLine( job, "stdout", ` OK ${base}.pdf — ${job.pdfBytes} bytes${job.pages ? `, ${job.pages}pp` : ""}`, ); } async function runCrunchJob(job) { try { await crunch(job); job.status = "success"; job.stage = "done"; job.percent = 100; } catch (err) { job.status = job.status === "cancelled" ? "cancelled" : "failed"; job.stage = job.status; job.error = err.message || String(err); addLogLine(job, "stderr", ` FAILED: ${job.error}`); } finally { job.finishedAt = nowISO(); job.bundle = null; job.expiresAt = new Date(Date.now() + JOB_TTL_MS).toISOString(); // Shred the sources either way — a private document does not linger on the // droplet. The PDF (already moved out) survives until the job is reaped. await fs.rm(path.join(job.dir, "root"), { recursive: true, force: true }).catch(() => {}); await fs.rm(path.join(job.dir, "bundle.tar"), { force: true }).catch(() => {}); if (activeJobId === job.id) activeJobId = null; } } async function reap() { const now = Date.now(); for (const id of [...jobOrder]) { const job = jobs.get(id); if (!job || id === activeJobId) continue; if (!job.expiresAt || Date.parse(job.expiresAt) > now) continue; await fs.rm(job.dir, { recursive: true, force: true }).catch(() => {}); jobs.delete(id); jobOrder.splice(jobOrder.indexOf(id), 1); } // Anything on disk we've forgotten (a restart mid-job) goes too. for (const name of await fs.readdir(WORK_ROOT).catch(() => [])) { if (jobs.has(name)) continue; await fs.rm(path.join(WORK_ROOT, name), { recursive: true, force: true }).catch(() => {}); } } setInterval(() => reap().catch(() => {}), 5 * 60 * 1000).unref(); export async function startPaperCrunch(bundle, options = {}) { if (!Buffer.isBuffer(bundle) || !bundle.length) { const err = new Error("empty bundle — POST a gzipped tar of the paper directory"); err.code = "CRUNCH_BAD_BUNDLE"; throw err; } if (bundle.length > MAX_BUNDLE_BYTES) { const err = new Error(`bundle over ${MAX_BUNDLE_BYTES} bytes`); err.code = "CRUNCH_BAD_BUNDLE"; throw err; } if (activeJobId) { const err = new Error(`Paper crunch already running: ${activeJobId}`); err.code = "CRUNCH_BUSY"; err.activeJobId = activeJobId; throw err; } const id = randomUUID().slice(0, 10); const job = { id, name: String(options.name || "paper").replace(/[^\w.-]/g, "-").slice(0, 60), wantedTex: options.tex ? path.basename(String(options.tex)) : null, tex: null, dir: path.join(WORK_ROOT, id), bundle, status: "queued", stage: "queued", percent: 0, createdAt: nowISO(), startedAt: null, updatedAt: nowISO(), finishedAt: null, expiresAt: null, process: null, timedOut: false, pdfPath: null, pdfBytes: null, pages: null, error: null, logs: [], }; await fs.mkdir(job.dir, { recursive: true }); jobs.set(id, job); jobOrder.unshift(id); while (jobOrder.length > MAX_RECENT_JOBS) { const old = jobOrder.pop(); const stale = jobs.get(old); if (old === activeJobId || !stale) continue; jobs.delete(old); fs.rm(stale.dir, { recursive: true, force: true }).catch(() => {}); } activeJobId = id; runCrunchJob(job).catch(() => {}); return makeSnapshot(job); } export function getPaperCrunch(jobId, opts = {}) { const job = jobs.get(jobId); return job ? makeSnapshot(job, opts) : null; } export function getPaperCrunchesSummary() { return { activeJobId, active: activeJobId ? makeSnapshot(jobs.get(activeJobId)) : null, recent: jobOrder .map((id) => jobs.get(id)) .filter(Boolean) .map((j) => makeSnapshot(j)), }; } export function getPaperCrunchPdf(jobId) { const job = jobs.get(jobId); if (!job) return null; if (!job.pdfPath || !existsSync(job.pdfPath)) return null; return { path: job.pdfPath, name: `${job.name}.pdf`, bytes: job.pdfBytes }; } export function cancelPaperCrunch(jobId) { const job = jobs.get(jobId); if (!job) return { ok: false, error: "not found" }; if (job.status !== "running") return { ok: false, error: "not running" }; job.status = "cancelled"; if (job.process) killGroup(job.process); return { ok: true }; } export const CRUNCH_LIMITS = { MAX_BUNDLE_BYTES, MAX_EXTRACT_BYTES, BUILD_TIMEOUT_MS, JOB_TTL_MS };