/** * Artifact export path validation and normalization. * * Artifacts may ONLY originate from /output. Rejects absolute paths, `..` * traversal, /workspace, /tmp, and anything outside /output. * * Pure logic, no Docker dependency, unit-testable. */ import path from "node:path"; import { FS, MAX_ARTIFACT_BYTES } from "./types.ts"; /** Errors thrown during artifact validation. */ export class ArtifactError extends Error {} /** * Validate a caller-supplied artifact path and normalize it to a safe relative * path with no leading slashes or `..` segments. * * Accepts both POSIX and Windows-style separators. Throws ArtifactError on any * path that is absolute, escapes /output, or references /workspace or /tmp. */ export function normalizeArtifactPath(requested: string): string { if (typeof requested !== "string" || requested.trim() === "") { throw new ArtifactError("Artifact path must be a non-empty string."); } const trimmed = requested.trim(); // Reject absolute paths outright (both POSIX and Windows drive letters). if (path.posix.isAbsolute(trimmed) || /^[a-zA-Z]:[\\/]/.test(trimmed)) { throw new ArtifactError(`Artifact path must be relative: "${trimmed}"`); } // Normalize separators to POSIX and collapse `.` / `..`. const normalized = path.posix.normalize(trimmed.replace(/\\/g, "/")); // Leading `..` (or absolute after normalize) escapes /output. if (normalized.startsWith("..") || path.posix.isAbsolute(normalized)) { throw new ArtifactError(`Artifact path escapes /output: "${trimmed}"`); } // Reject explicit well-known disallowed roots. const firstSegment = normalized.split("/")[0]; if (firstSegment === "workspace" || firstSegment === "tmp") { throw new ArtifactError( `Artifact path must resolve under /output; "${trimmed}" resolves under /${firstSegment}.`, ); } if (normalized === "." || normalized === "") { throw new ArtifactError("Artifact path must name a file or directory under /output."); } return normalized; } /** * Build the guest (container) path for a normalized artifact path. * Always under /output. */ export function guestArtifactPath(normalized: string): string { return path.posix.join(FS.output, normalized); } /** * True when a single exported artifact's byte size is within the per-run total * budget. Tracks running total against MAX_ARTIFACT_BYTES. */ export function withinArtifactBudget(currentTotalBytes: number, additionalBytes: number): boolean { return currentTotalBytes + additionalBytes <= MAX_ARTIFACT_BYTES; } /** Per-run artifact budget tracker. */ export class ArtifactBudget { private used = 0; /** Add bytes to the budget; returns false (without consuming) if over budget. */ tryConsume(bytes: number): boolean { if (!Number.isFinite(bytes) || bytes < 0) return false; if (this.used + bytes > MAX_ARTIFACT_BYTES) return false; this.used += bytes; return true; } get usedBytes(): number { return this.used; } }