/** * apps/worker — files pipeline temp-dir management (TASK-0102 §3/§7). * * Objects are downloaded to a local temp dir for the ClamAV scan (clamd * `zCONTSCAN` needs a path) and for streaming metadata extraction. Security * invariants: * * - the temp dir lives on a noexec mount in the worker image (the bytes * are NEVER executed — metadata parsers + clamd only read them); * - the object "never stays on disk > scan duration + 5 min": the * processor deletes its own temp file in a `finally`, and a 5-minute * cleanup cron deletes anything older than FILES_TEMP_MAX_AGE_MS * (crash/backstop — a SIGKILL mid-pipeline still gets reaped); * - a disk guard aborts a run as FATAL when free space < * FILES_TEMP_FREE_MIN_BYTES (512 MB default) so a full disk can't wedge * the worker or corrupt other services' storage. */ import { statfs, readdir, unlink, chmod } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { randomUUID } from "node:crypto"; import { getLogger } from "../../logger.js"; import { FatalError } from "./errors.js"; const log = getLogger("files.temp"); /** Resolve the temp dir (FILES_TEMP_DIR or os.tmpdir()/factory-files). */ export function tempDir(env: { FILES_TEMP_DIR: string }): string { return env.FILES_TEMP_DIR || path.join(os.tmpdir(), "factory-files"); } /** * Disk guard (TASK-0102 §7): throw FATAL when free space on the temp dir's * filesystem is below the threshold. statfs is async (Node 18.15+). */ export async function assertDiskSpace( dir: string, minFreeBytes: number ): Promise { let free: number; try { const stat = await statfs(dir); free = stat.bavail * stat.bsize; } catch (err) { // statfs unsupported → we can't verify; log and allow (dev safety). log.warn({ err: (err as Error).message, dir }, "statfs failed — disk guard skipped"); return; } if (free < minFreeBytes) { throw new FatalError( `temp disk guard: ${Math.floor(free / 1024 / 1024)} MB free < ${Math.floor( minFreeBytes / 1024 / 1024 )} MB required`, "disk_full" ); } } /** * Create a private temp file in the pipeline temp dir. Returns its path; * the caller owns deletion (finally block + cleanup cron as backstop). */ export async function createTempFile( env: { FILES_TEMP_DIR: string }, minFreeBytes: number ): Promise { const dir = tempDir(env); // mkdir is best-effort here (the dir is created at boot by the worker); // a failure will surface as a clear download error instead. await import("node:fs/promises").then((m) => m.mkdir(dir, { recursive: true })); // clamd runs as its own user (the `clamav` service account on bare metal; // a separate container in some layouts). A 0700 dir (mkdir's default) is // NOT traversable by it, so `zCONTSCAN ` accepts the connection and // then times out — every scan would fail with "clamav scan timeout". // 0755 lets clamd traverse + read; the files are random-UUID named and // live for seconds (deleted in `finally` + reaped by the cleanup cron), // so the transient cross-user readability is the documented trade-off. try { await chmod(dir, 0o755); } catch (err) { log.warn( { err: (err as Error).message, dir }, "temp dir chmod 0755 failed — a different-user clamd will time out on zCONTSCAN" ); } await assertDiskSpace(dir, minFreeBytes); return path.join(dir, `file-${randomUUID()}`); } /** Delete a temp file if it exists. Never throws (finally-block safe). */ export async function removeTempFile(p: string | undefined): Promise { if (!p) return; try { await unlink(p); } catch (err) { if ((err as NodeJS.ErrnoException).code !== "ENOENT") { log.warn({ err: (err as Error).message, p }, "temp file cleanup failed (cron backstops)"); } } } export interface TempCleanupResult { deleted: number; bytesFreed: number; } /** * The 5-minute backstop (TASK-0102 §7): delete files in the temp dir older * than `maxAgeMs`. Files currently in use are newer than the threshold * (pipeline runs are far shorter), so this only reaps crashes' leftovers. * A directory with a live file still processing (mtime fresh) is untouched. */ export async function cleanupTempFiles( env: { FILES_TEMP_DIR: string; FILES_TEMP_MAX_AGE_MS: number } ): Promise { const dir = tempDir(env); const now = Date.now(); let deleted = 0; let bytesFreed = 0; let entries: string[]; try { entries = await readdir(dir); } catch { return { deleted: 0, bytesFreed: 0 }; // dir missing → nothing to clean } for (const name of entries) { const p = path.join(dir, name); try { const st = await statfsSafe(p); if (!st) continue; if (now - st.mtimeMs > env.FILES_TEMP_MAX_AGE_MS) { await unlink(p); deleted += 1; bytesFreed += st.size; } } catch (err) { if ((err as NodeJS.ErrnoException).code !== "ENOENT") { log.warn({ err: (err as Error).message, p }, "temp cleanup: stat/unlink failed"); } } } if (deleted > 0) { log.info({ deleted, bytesFreed }, "temp cleanup: reaped stale files"); } return { deleted, bytesFreed }; } async function statfsSafe( p: string ): Promise<{ mtimeMs: number; size: number } | null> { const { stat } = await import("node:fs/promises"); try { const st = await stat(p); if (!st.isFile()) return null; return { mtimeMs: st.mtimeMs, size: st.size }; } catch { return null; } }