import fs from 'node:fs'; import path from 'node:path'; import { LIMITS, type Warning } from './types.js'; import { WarningCollector } from './warnings.js'; export interface ArtifactFileCandidate { relPath: string; size: number; fd: number; stat: fs.Stats; } export interface ArtifactScanResult { candidates: ArtifactFileCandidate[]; warnings: Warning[]; } export function validateRelativeArtifactPath(name: string): { valid: boolean; reason?: string } { const valid = typeof name === 'string' && !!name && !name.startsWith('/') && !/[\\\x00-\x1f\x7f-\x9f]/.test(name) && Buffer.byteLength(name) <= LIMITS.MAX_PATH_BYTES && name.split('/').every(c => !!c && c !== '.' && c !== '..'); return valid ? { valid } : { valid: false, reason: 'Invalid relative artifact name' }; } const directoryFlags = fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW; // Linux descriptor-relative traversal. /proc/self/fd is kernel-owned: following // that magic link uses an already-open directory, never an agent symlink. function at(fd: number, name: string): string { return `/proc/self/fd/${fd}/${name}`; } function openRoot(root: string): number { let fd = fs.openSync('/', directoryFlags); try { for (const component of path.resolve(root).split('/').filter(Boolean)) { const next = fs.openSync(at(fd, component), directoryFlags); fs.closeSync(fd); fd = next; } return fd; } catch (err) { fs.closeSync(fd); throw err; } } export function closeArtifacts(candidates: ArtifactFileCandidate[]): void { for (const c of candidates) { try { fs.closeSync(c.fd); } catch {} } } export function scanArtifactsDirectory(root: string): ArtifactScanResult { const candidates: ArtifactFileCandidate[] = []; const warnings = new WarningCollector(); const warn = (code: string) => { warnings.add({ code, message: 'An artifact was omitted during filesystem validation.' }); }; const seen = new Set(); let total = 0, visited = 0; function walk(fd: number, prefix: string): void { const dir = fs.opendirSync(`/proc/self/fd/${fd}`); try { let item: fs.Dirent | null; while ((item = dir.readSync())) { if (++visited > 10000) { warn('artifact_scan_limit'); return; } const name = prefix + item.name; if (!validateRelativeArtifactPath(name).valid) { warn('artifact_invalid_path'); continue; } const location = at(fd, item.name); const stat = fs.lstatSync(location); if (stat.isDirectory()) { let next: number; try { next = fs.openSync(location, directoryFlags); } catch { warn('artifact_open_failed'); continue; } try { walk(next, name + '/'); } finally { fs.closeSync(next); } if (visited > 10000) return; continue; } if (!stat.isFile() || stat.nlink !== 1) { warn('artifact_not_regular'); continue; } const normalized = name.normalize('NFC'); if (seen.has(normalized)) { warn('artifact_duplicate_name'); continue; } seen.add(normalized); if (stat.size > LIMITS.ARTIFACT_MAX_FILE_BYTES) { warn('artifact_oversized'); continue; } if (candidates.length >= LIMITS.ARTIFACT_MAX_FILES) { warn('artifact_count_exceeded'); continue; } if (total + stat.size > LIMITS.ARTIFACT_MAX_TOTAL_BYTES) { warn('artifact_total_limit_exceeded'); continue; } let file: number; // O_NONBLOCK prevents a raced FIFO open from blocking the exporter. try { file = fs.openSync(location, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK); } catch { warn('artifact_open_failed'); continue; } const opened = fs.fstatSync(file); if (!opened.isFile() || opened.nlink !== 1 || opened.ino !== stat.ino || opened.dev !== stat.dev || opened.size !== stat.size) { fs.closeSync(file); warn('artifact_descriptor_mismatch'); continue; } total += opened.size; // Preserve the verified descriptor across backend acknowledgements. candidates.push({ relPath: name, fd: file, size: opened.size, stat: opened }); } } finally { dir.closeSync(); } } let fd: number; try { fd = openRoot(root); } catch { warn('artifact_root_rejected'); return { candidates, warnings: warnings.snapshot() }; } try { walk(fd, ''); } catch { warn('artifact_scan_failed'); } finally { fs.closeSync(fd); } return { candidates, warnings: warnings.snapshot() }; } export interface ArtifactTransferCallbacks { sendBegin: (id: string, name: string, size: number) => Promise<'accept' | 'skip'>; sendChunk: (id: string, index: number, data: string) => Promise; sendEnd: (id: string, size: number, chunks: number) => Promise; } function unchanged(c: ArtifactFileCandidate): void { const s = fs.fstatSync(c.fd); if (!s.isFile() || s.nlink !== 1 || s.ino !== c.stat.ino || s.dev !== c.stat.dev || s.size !== c.size || s.mtimeMs !== c.stat.mtimeMs || s.ctimeMs !== c.stat.ctimeMs) throw new Error('Artifact changed after validation'); } export async function exportArtifacts(candidates: ArtifactFileCandidate[], callbacks: ArtifactTransferCallbacks): Promise { let stored = 0, seq = 0; const buffer = Buffer.alloc(LIMITS.ARTIFACT_CHUNK_MAX_DECODED_BYTES); for (const c of candidates) { unchanged(c); const id = `a_t_${++seq}`; if (await callbacks.sendBegin(id, c.relPath, c.size) === 'skip') continue; unchanged(c); let offset = 0, chunks = 0; while (offset < c.size) { const n = fs.readSync(c.fd, buffer, 0, Math.min(buffer.length, c.size - offset), offset); if (!n) throw new Error('Artifact truncated'); offset += n; await callbacks.sendChunk(id, chunks++, buffer.subarray(0, n).toString('base64')); } if (fs.readSync(c.fd, buffer, 0, 1, offset) !== 0) throw new Error('Artifact grew'); unchanged(c); await callbacks.sendEnd(id, offset, chunks); stored++; } return stored; }