/** Concrete constrained-Docker adapter for PreparationExecutor. */ import * as fs from 'node:fs'; import * as net from 'node:net'; import { promises as dns } from 'node:dns'; import { createHash, randomBytes } from 'node:crypto'; import { spawn } from 'node:child_process'; import { dirname, isAbsolute, join, resolve, sep } from 'node:path'; import { atomicWriteSync } from '../fs-atomic'; import { canonical, CsoError, sha256 } from './contracts'; import { DockerGroup, type DockerEndpoint } from './docker'; import { secureDirectory } from './state'; import { admittedPreparationRuntime, type AcquisitionArtifactReceipt, type AcquisitionReceipt, type OfflinePreparationReceipt, type OfflinePreparationRequest, type OfflinePreparationResult, type PreparationAcquireRequest, type PreparationCommandReceipt, type PreparationRuntimeAdmission, type PreparationSandboxRunner, } from './preparation-executor'; import type { CsoStack, PreparationCommand } from './preparation'; import { CSO_HELPER_ABI } from './runtime-catalog'; import type { PreparedExportEntry, PreparedExportManifest } from './preparation-container'; const RELATIVE = /^(?!\/)(?!.*(?:^|\/)\.\.?(?:\/|$))(?!.*\\)[A-Za-z0-9@._+\/-]{1,1024}$/; const MAX_MANIFEST = 32 * 1024 * 1024; const MAX_PREPARED_EXPORT_ENTRIES = 200_000; function fail(code: ConstructorParameters[0], message: string): never { throw new CsoError(code, message); } function contained(root: string, path: string): string { if (!RELATIVE.test(path) || path.split('/').some(part => !part || part === '.' || part === '..')) fail('UNSAFE_PATH', 'Preparation path escaped its private root'); const target = resolve(root, ...path.split('/')); if (!target.startsWith(`${root}${sep}`)) fail('UNSAFE_PATH', 'Preparation path escaped its private root'); return target; } function commandReceipt(command: PreparationCommand, index: number, exitCode: number): PreparationCommandReceipt { return { index, commandHash: sha256(canonical(command)), exitCode, timedOut: false, outputTruncated: false }; } function writePolicy(path: string, value: unknown): void { try { atomicWriteSync(path, `${JSON.stringify(value)}\n`, { mode: 0o600, noReplace: true }); } catch { fail('PERSISTENCE_FAILED', 'Preparation policy could not be written atomically'); } } function readManifest(path: string): { artifacts: AcquisitionArtifactReceipt[] } { let stat: fs.Stats; try { stat = fs.lstatSync(path); } catch { fail('TOOL_FAILED', 'Qualified acquisition helper did not produce an archive manifest'); } if (!stat!.isFile() || stat!.isSymbolicLink() || stat!.nlink !== 1 || stat!.size > MAX_MANIFEST || (process.getuid && stat!.uid !== process.getuid()) || (stat!.mode & 0o077) !== 0) fail('UNSAFE_PATH', 'Acquisition archive manifest is not one bounded private file'); let parsed: unknown; try { parsed = JSON.parse(fs.readFileSync(path, 'utf8')); } catch { fail('TOOL_FAILED', 'Qualified acquisition helper returned invalid archive JSON'); } const value = parsed as { artifacts?: unknown }; if (!value || Object.keys(value).sort().join(',') !== 'artifacts' || !Array.isArray(value.artifacts)) fail('TOOL_FAILED', 'Qualified acquisition helper returned an invalid archive manifest schema'); return value as { artifacts: AcquisitionArtifactReceipt[] }; } function fileSha256(path: string, maxBytes: number): string { const noFollow = (fs.constants as any).O_NOFOLLOW ?? 0; let fd: number; try { fd = fs.openSync(path, fs.constants.O_RDONLY | noFollow); } catch { fail('UNSAFE_PATH', 'Archive copy could not be opened without following links'); } try { const before = fs.fstatSync(fd!), hash = createHash('sha256'), buffer = Buffer.allocUnsafe(64 * 1024); let bytes = 0; for (;;) { const count = fs.readSync(fd!, buffer, 0, buffer.length, null); if (!count) break; bytes += count; if (bytes > maxBytes) fail('INSUFFICIENT_CAPACITY', 'Archive copy exceeded its declared size'); hash.update(buffer.subarray(0, count)); } const after = fs.fstatSync(fd!); if (bytes !== before.size || before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) fail('SNAPSHOT_RACE', 'Archive copy changed while it was hashed'); return hash.digest('hex'); } finally { fs.closeSync(fd!); } } function validateAcquisitionOutput(root: string, artifacts: AcquisitionArtifactReceipt[]): void { const top = fs.readdirSync(root).sort(); if (canonical(top) !== canonical(['archives', 'artifacts.json'])) fail('TOOL_FAILED', 'Acquisition output contains undeclared objects'); const archiveRoot = join(root, 'archives'), stat = fs.lstatSync(archiveRoot); if (!stat.isDirectory() || stat.isSymbolicLink() || (process.getuid && stat.uid !== process.getuid()) || (stat.mode & 0o022) !== 0) fail('UNSAFE_PATH', 'Acquisition archive output is not one private directory'); for(const artifact of artifacts)if(typeof artifact?.stagingPath!=='string'||!/^cso-public\/[A-Za-z0-9._-]{1,255}$/.test(artifact.stagingPath))fail('TOOL_FAILED','Acquisition manifest contains an unsafe staging path'); const expected = artifacts.map(artifact => artifact.stagingPath.slice('cso-public/'.length)).sort(), actual = fs.readdirSync(archiveRoot).sort(); if(new Set(expected).size!==expected.length)fail('TOOL_FAILED','Acquisition manifest contains duplicate staging paths'); if (canonical(actual) !== canonical(expected)) fail('TOOL_FAILED', 'Acquisition output archive membership does not match its manifest'); for (const name of actual) { if (!name || name.includes('/') || name === '.' || name === '..') fail('UNSAFE_PATH', 'Acquisition output archive name is unsafe'); const file = fs.lstatSync(join(archiveRoot, name)); if (!file.isFile() || file.isSymbolicLink() || file.nlink !== 1 || (process.getuid && file.uid !== process.getuid()) || (file.mode & 0o022) !== 0) fail('UNSAFE_PATH', 'Acquisition output contains a link or special file'); } } function preparedRelative(path: unknown): path is string { return typeof path === 'string' && path.length > 0 && !path.startsWith('/') && !path.includes('\\') && Buffer.byteLength(path) <= 4096 && path.split('/').every(part => part && part !== '.' && part !== '..' && Buffer.byteLength(part) <= 255 && !/[\0-\x1f\x7f]/.test(part)); } function preparedTarget(root: string, relativePath: string): string { if (!preparedRelative(relativePath)) fail('UNSAFE_PATH', 'Prepared export contains an unsafe path'); const target = resolve(root, ...relativePath.split('/')); if (!target.startsWith(`${root}${sep}`)) fail('UNSAFE_PATH', 'Prepared export path escaped its private root'); return target; } function sameFileIdentity(left: fs.Stats, right: fs.Stats): boolean { return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode && left.uid === right.uid && left.gid === right.gid && left.nlink === right.nlink && left.size === right.size && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs; } function copyPreparedBlob(source: string, destination: string, expected: Extract): void { const noFollow = (fs.constants as any).O_NOFOLLOW ?? 0; let sourceFd = -1, destinationFd = -1; try { sourceFd = fs.openSync(source, fs.constants.O_RDONLY | noFollow); const before = fs.fstatSync(sourceFd); if (!before.isFile() || before.nlink !== 1 || before.size !== expected.bytes || (process.getuid && before.uid !== process.getuid())) fail('UNSAFE_PATH', 'Prepared export blob is not one owned regular file'); destinationFd = fs.openSync(destination, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | noFollow, 0o600); const hash = createHash('sha256'), buffer = Buffer.allocUnsafe(64 * 1024); let bytes = 0; for (;;) { const count = fs.readSync(sourceFd, buffer, 0, buffer.length, null); if (!count) break; bytes += count; if (bytes > expected.bytes) fail('INSUFFICIENT_CAPACITY', 'Prepared export blob exceeded its declared size'); hash.update(buffer.subarray(0, count)); let offset = 0; while (offset < count) offset += fs.writeSync(destinationFd, buffer, offset, count - offset); } const after = fs.fstatSync(sourceFd); if (bytes !== expected.bytes || hash.digest('hex') !== expected.sha256 || !sameFileIdentity(before, after)) fail('SNAPSHOT_RACE', 'Prepared export blob changed during bounded import'); fs.fchmodSync(destinationFd, expected.mode); fs.fsyncSync(destinationFd); const copied = fs.fstatSync(destinationFd); if (!copied.isFile() || copied.nlink !== 1 || copied.size !== expected.bytes) fail('PERSISTENCE_FAILED', 'Prepared export blob was not materialized as one regular file'); } finally { if (sourceFd >= 0) fs.closeSync(sourceFd); if (destinationFd >= 0) fs.closeSync(destinationFd); } } /** Validate an inert container export and reconstruct its prepared tree using host no-follow writes. */ export function materializePreparedExport(exportRoot: string, destinationRoot: string, maxBytes: number): PreparedExportManifest { const source = resolve(exportRoot), destination = resolve(destinationRoot); if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0 || maxBytes > 2 * 1024 * 1024 * 1024) fail('INVALID_ARGUMENT', 'Prepared export byte ceiling is invalid'); for (const [path, label, empty] of [[source, 'Prepared inert export', false], [destination, 'Prepared output', true]] as const) { const stat = fs.lstatSync(path); if (!stat.isDirectory() || stat.isSymbolicLink() || fs.realpathSync(path) !== path || (process.getuid && stat.uid !== process.getuid()) || (stat.mode & 0o022) !== 0 || (empty && fs.readdirSync(path).length)) fail('UNSAFE_PATH', `${label} must be one private${empty ? ' empty' : ''} owned directory`); } if (fs.readdirSync(source).sort().join('\0') !== 'blobs\0manifest.json') fail('UNSAFE_PATH', 'Prepared inert export contains undeclared top-level objects'); const manifestPath = join(source, 'manifest.json'), manifestStat = fs.lstatSync(manifestPath); if (!manifestStat.isFile() || manifestStat.isSymbolicLink() || manifestStat.nlink !== 1 || manifestStat.size < 1 || manifestStat.size > MAX_MANIFEST || (process.getuid && manifestStat.uid !== process.getuid())) fail('UNSAFE_PATH', 'Prepared export manifest is not one bounded owned file'); let manifest: PreparedExportManifest; try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } catch { fail('TOOL_FAILED', 'Prepared export manifest is invalid JSON'); } if (!manifest || Object.keys(manifest).sort().join(',') !== 'entries,schemaVersion' || manifest.schemaVersion !== 1 || !Array.isArray(manifest.entries) || manifest.entries.length > MAX_PREPARED_EXPORT_ENTRIES) fail('TOOL_FAILED', 'Prepared export manifest has an invalid schema'); const paths = new Set(), directories = new Set(), blobs = new Set(); let totalBytes = 0; for (const raw of manifest.entries) { const entry = raw as PreparedExportEntry, keys = Object.keys(entry).sort().join(','); if (!preparedRelative(entry?.path) || paths.has(entry.path) || !Number.isSafeInteger(entry.mode) || entry.mode < 0 || entry.mode > 0o777) fail('TOOL_FAILED', 'Prepared export contains a duplicate or invalid path record'); paths.add(entry.path); const parent = entry.path.includes('/') ? entry.path.slice(0, entry.path.lastIndexOf('/')) : ''; if (parent && !directories.has(parent)) fail('TOOL_FAILED', 'Prepared export entry is missing its declared parent directory'); if (entry.kind === 'directory') { if (keys !== 'kind,mode,path' || (entry.mode & 0o022) !== 0) fail('TOOL_FAILED', 'Prepared export directory record is invalid'); directories.add(entry.path); } else if (entry.kind === 'file') { if (keys !== 'blob,bytes,kind,mode,path,sha256' || !Number.isSafeInteger(entry.bytes) || entry.bytes < 0 || !/^[a-f0-9]{64}$/.test(entry.sha256) || !/^blob-\d{6}$/.test(entry.blob) || blobs.has(entry.blob)) fail('TOOL_FAILED', 'Prepared export file record is invalid'); blobs.add(entry.blob); totalBytes += entry.bytes; if (!Number.isSafeInteger(totalBytes) || totalBytes > maxBytes) fail('INSUFFICIENT_CAPACITY', 'Prepared export exceeds its aggregate byte ceiling'); } else if (entry.kind === 'symlink') { if (keys !== 'kind,mode,path,target' || typeof entry.target !== 'string' || !entry.target || isAbsolute(entry.target) || entry.target.includes('\0') || /[\x01-\x1f\x7f]/.test(entry.target)) fail('TOOL_FAILED', 'Prepared export symlink record is invalid'); const lexical = resolve(dirname(preparedTarget(destination, entry.path)), entry.target); if (lexical !== destination && !lexical.startsWith(`${destination}${sep}`)) fail('UNSAFE_PATH', 'Prepared export symlink escapes its destination'); } else fail('TOOL_FAILED', 'Prepared export entry kind is invalid'); } const ordered = manifest.entries.map(entry => entry.path); if (ordered.join('\0') !== [...ordered].sort().join('\0')) fail('TOOL_FAILED', 'Prepared export manifest is not in deterministic path order'); const blobRoot = join(source, 'blobs'), blobRootStat = fs.lstatSync(blobRoot); if (!blobRootStat.isDirectory() || blobRootStat.isSymbolicLink() || (process.getuid && blobRootStat.uid !== process.getuid()) || fs.readdirSync(blobRoot).sort().join('\0') !== [...blobs].sort().join('\0')) fail('UNSAFE_PATH', 'Prepared export blob membership does not match its manifest'); for (const entry of manifest.entries) if (entry.kind === 'directory') fs.mkdirSync(preparedTarget(destination, entry.path), { mode: 0o700 }); for (const entry of manifest.entries) if (entry.kind === 'file') copyPreparedBlob(join(blobRoot, entry.blob), preparedTarget(destination, entry.path), entry); for (const entry of manifest.entries) if (entry.kind === 'symlink') fs.symlinkSync(entry.target, preparedTarget(destination, entry.path)); for (const entry of manifest.entries) if (entry.kind === 'symlink') { const path = preparedTarget(destination, entry.path); let real: string, stat: fs.Stats; try { real = fs.realpathSync(path); stat = fs.statSync(path); } catch { fail('UNSAFE_PATH', 'Prepared export symlink is dangling or cyclic'); } if ((real !== destination && !real.startsWith(`${destination}${sep}`)) || (!stat.isFile() && !stat.isDirectory())) fail('UNSAFE_PATH', 'Prepared export symlink resolves outside its prepared tree'); } for (const entry of [...manifest.entries].reverse()) if (entry.kind === 'directory') fs.chmodSync(preparedTarget(destination, entry.path), entry.mode); return manifest; } function addBlockedIpv4Ranges(blocked: net.BlockList): void { for (const [address, prefix] of [['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4]] as Array<[string, number]>) blocked.addSubnet(address, prefix, 'ipv4'); } function addBlockedIpv6Ranges(blocked: net.BlockList): void { for (const [address, prefix] of [ ['::', 96], // unspecified, IPv4-compatible, and other deprecated v4 embeddings ['::ffff:0.0.0.0', 96], // IPv4-mapped addresses must not bypass the IPv4 ranges ['64:ff9b::', 96], ['64:ff9b:1::', 48], // public and local-use NAT64 translators ['100::', 64], // discard-only ['2001::', 23], // IETF special-purpose assignments (Teredo, ORCHID, benchmarking) ['2001:db8::', 32], // documentation prefix ['2002::', 16], // 6to4 can embed otherwise blocked IPv4 destinations ['3fff::', 20], // documentation prefix ['fc00::', 7], ['fe80::', 10], ['fec0::', 10], ['ff00::', 8], ] as Array<[string, number]>) blocked.addSubnet(address, prefix, 'ipv6'); } const registryBlockedAddresses = new net.BlockList(); const registryBlockedIpv6Addresses = new net.BlockList(); addBlockedIpv4Ranges(registryBlockedAddresses); addBlockedIpv6Ranges(registryBlockedIpv6Addresses); /** Pure classification used before any registry connection is attempted. */ export function isBlockedRegistryAddress(address: string, family: 4 | 6): boolean { if (net.isIP(address) !== family) return true; return (family === 6 ? registryBlockedIpv6Addresses : registryBlockedAddresses).check(address, family === 6 ? 'ipv6' : 'ipv4'); } export interface RegistryDnsAddress { address: string; family: 4 | 6 } export interface RegistryDnsResolution { promise: Promise; cancel(): void } export type RegistryDnsResolver = (host: string) => RegistryDnsResolution; function systemRegistryDnsResolver(host: string): RegistryDnsResolution { const resolver = new dns.Resolver(); const promise = Promise.allSettled([resolver.resolve4(host), resolver.resolve6(host)]).then(results => { const answers: RegistryDnsAddress[] = []; if (results[0].status === 'fulfilled') for (const address of results[0].value) answers.push({ address, family: 4 }); if (results[1].status === 'fulfilled') for (const address of results[1].value) answers.push({ address, family: 6 }); if (!answers.length) { const rejected = results.find((result): result is PromiseRejectedResult => result.status === 'rejected'); if (rejected) throw rejected.reason; } return answers; }); return { promise, cancel: () => resolver.cancel() }; } /** * Host-side CONNECT broker. The acquisition container remains in Docker's * network-none namespace and reaches this broker only through a bind-mounted * Unix socket and its qualified loopback forwarder. */ export class RegistryEgressBroker { readonly contactedHosts = new Set(); readonly deniedHosts = new Set(); private readonly server = net.createServer(socket => this.accept(socket)); private readonly sockets = new Set(); private readonly tasks = new Set>(); private readonly resolutions = new Set<{ cancel(error: CsoError): void }>(); private readonly pinned = new Map(); private transferred = 0; private violation: string | undefined; private started = false; private closing = false; constructor(readonly socketPath: string, readonly allowedHosts: string[], private readonly deadline: number, private readonly maxBytes: number, private readonly resolveDns: RegistryDnsResolver = systemRegistryDnsResolver) { if (!socketPath.startsWith('/') || !allowedHosts.length || new Set(allowedHosts).size !== allowedHosts.length || allowedHosts.some(host => host !== host.toLowerCase() || !/^[a-z0-9.-]{1,253}$/.test(host)) || !Number.isSafeInteger(deadline) || deadline <= Date.now() || !Number.isSafeInteger(maxBytes) || maxBytes <= 0) fail('INVALID_ARGUMENT', 'Registry broker requires a bounded allowlist, deadline, and byte ceiling'); } async start(): Promise { if (this.started || this.closing) fail('INVALID_ARGUMENT', 'Registry broker cannot be started more than once'); if (fs.existsSync(this.socketPath)) fail('UNSAFE_PATH', 'Registry broker socket path already exists'); await new Promise((resolveStart, reject) => { const onError = () => reject(new CsoError('ISOLATION_FAILED', 'Registry broker could not bind its private Unix socket')); this.server.once('error', onError); this.server.listen(this.socketPath, () => { this.server.off('error', onError); resolveStart(); }); }); this.started = true; this.server.on('error', () => { this.violation ??= 'Registry broker listener failed'; }); fs.chmodSync(this.socketPath, 0o600); const stat = fs.lstatSync(this.socketPath); if (!stat.isSocket() || stat.isSymbolicLink() || (process.getuid && stat.uid !== process.getuid())) fail('UNSAFE_PATH', 'Registry broker did not create an owned Unix socket'); } private deny(socket: net.Socket, message: string, host?: string): void { this.violation ??= message; if (host) this.deniedHosts.add(host); try { socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); } catch { socket.destroy(); } } private async lookup(host: string, lookupDeadline: number): Promise { const resolution = this.resolveDns(host); if (!resolution || typeof resolution.cancel !== 'function' || !resolution.promise || typeof resolution.promise.then !== 'function') fail('ISOLATION_FAILED', 'Registry DNS resolver returned an invalid operation'); let rejectCancellation!: (error: CsoError) => void, settled = false; const cancelled = new Promise((_resolve, reject) => { rejectCancellation = reject; }); const active = { cancel: (error: CsoError) => { if (settled) return; try { resolution.cancel(); } catch {} rejectCancellation(error); }, }; this.resolutions.add(active); const remaining = lookupDeadline - Date.now(); if (remaining <= 0) active.cancel(new CsoError('DEADLINE', 'Registry DNS lookup deadline elapsed')); const timer = remaining > 0 ? setTimeout(() => active.cancel(new CsoError('DEADLINE', 'Registry DNS lookup deadline elapsed')), remaining) : undefined; try { const answers = await Promise.race([resolution.promise, cancelled]); if (!Array.isArray(answers) || answers.some(answer => !answer || typeof answer.address !== 'string' || (answer.family !== 4 && answer.family !== 6))) fail('ISOLATION_FAILED', 'Registry DNS resolver returned invalid addresses'); return answers; } finally { settled = true; if (timer) clearTimeout(timer); this.resolutions.delete(active); } } private async openTunnel(socket: net.Socket, host: string, remainder: Buffer, connectionDeadline: number): Promise { try { const answers = (await this.lookup(host, connectionDeadline)).filter(answer => !isBlockedRegistryAddress(answer.address, answer.family)); if (this.closing || socket.destroyed) return; if (Date.now() >= this.deadline || Date.now() >= connectionDeadline) { this.deny(socket, 'Registry acquisition deadline elapsed', host); return; } if (!answers.length) { this.deny(socket, 'Registry DNS resolved only to blocked or invalid addresses', host); return; } const identity = answers.map(answer => `${answer.family}:${answer.address}`).sort().join(','); const prior = this.pinned.get(host); if (prior && prior !== identity) { this.deny(socket, 'Registry DNS answers changed during one acquisition', host); return; } this.pinned.set(host, identity); const selected = answers.sort((a, b) => a.address.localeCompare(b.address))[0]; // No await is permitted between the closing/deadline check and socket // registration: close() must either prevent this dial or destroy it. if (this.closing || socket.destroyed || Date.now() >= this.deadline) return; const upstream = net.connect({ host: selected.address, port: 443, family: selected.family }); this.sockets.add(upstream); upstream.setTimeout(Math.max(1, Math.min(30_000, this.deadline - Date.now()))); upstream.once('close', () => this.sockets.delete(upstream)); upstream.once('error', () => { if (!this.closing) this.violation ??= 'Registry connection failed after DNS pinning'; socket.destroy(); }); upstream.once('connect', () => { if (this.closing || socket.destroyed || Date.now() >= this.deadline) { upstream.destroy(); socket.destroy(); return; } const addBytes = (bytes: number) => { this.transferred += bytes; if (this.transferred <= this.maxBytes) return true; this.violation = 'Registry transfer exceeded its byte ceiling'; socket.destroy(); upstream.destroy(); return false; }; const count = (chunk: Buffer) => { addBytes(chunk.length); }; socket.on('data', count); upstream.on('data', count); socket.pipe(upstream); upstream.pipe(socket); this.contactedHosts.add(host); socket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); if (remainder.length && addBytes(remainder.length)) upstream.write(remainder); }); } catch { if (this.closing || socket.destroyed) return; this.deny(socket, 'Registry DNS lookup or pinned connection failed', host); } } private accept(socket: net.Socket): void { if (this.closing || Date.now() >= this.deadline) { socket.destroy(); return; } if (this.sockets.size >= 64) { this.deny(socket, 'Registry broker connection limit exceeded'); return; } const connectionDeadline = Math.min(this.deadline, Date.now() + 30_000); this.sockets.add(socket); socket.setTimeout(Math.max(1, connectionDeadline - Date.now())); socket.once('close', () => this.sockets.delete(socket)); let pending = Buffer.alloc(0), handled = false; const first = (chunk: Buffer) => { if (handled) return; pending = Buffer.concat([pending, chunk]); if (pending.length > 16 * 1024) { handled = true; this.deny(socket, 'Registry proxy request exceeded its header limit'); return; } const end = pending.indexOf('\r\n\r\n'); if (end < 0) return; handled = true; socket.off('data', first); const header = pending.subarray(0, end + 4).toString('ascii'), remainder = pending.subarray(end + 4); const line = header.slice(0, header.indexOf('\r\n')); const match = line.match(/^CONNECT ([a-zA-Z0-9.-]+):443 HTTP\/1\.[01]$/); const host = match?.[1].toLowerCase(); if (!host || header.toLowerCase().includes('\r\nproxy-authorization:') || !this.allowedHosts.includes(host)) { this.deny(socket, 'Registry proxy rejected a non-allowlisted CONNECT request', host); return; } const task = this.openTunnel(socket, host, remainder, connectionDeadline); this.tasks.add(task); void task.then(() => this.tasks.delete(task), () => this.tasks.delete(task)); }; socket.on('data', first); socket.once('timeout', () => this.deny(socket, 'Registry proxy connection timed out')); socket.once('error', () => {}); } assertClean(): void { if (this.violation) fail('ISOLATION_FAILED', this.violation); } async close(): Promise { this.closing = true; for (const resolution of this.resolutions) resolution.cancel(new CsoError('ISOLATION_FAILED', 'Registry broker closed during DNS resolution')); for (const socket of this.sockets) socket.destroy(); if (this.started && this.server.listening) await new Promise(resolveClose => this.server.close(() => resolveClose())); await Promise.allSettled([...this.tasks]); this.started = false; try { const stat = fs.lstatSync(this.socketPath); if (!stat.isSocket() || stat.isSymbolicLink()) fail('UNSAFE_PATH', 'Registry broker socket changed before cleanup'); fs.unlinkSync(this.socketPath); } catch (error: any) { if (error?.code !== 'ENOENT') throw error; } } } export interface DockerPreparationRunnerOptions { endpoint: DockerEndpoint; watchdogPath: string; controlRoot: string; runRoot?: string; admission: PreparationRuntimeAdmission; } export interface PreparedCallGuard { callRoot: string; controlRoot: string; dispose(): Promise } export interface SupervisedRegistrySocket { root: string; socketPath: string; dispose(): Promise } /** Darwin's sockaddr_un.sun_path is 104 bytes including its terminator. */ export const REGISTRY_SOCKET_PATH_MAX_BYTES = 90; function sameDirectory(left:fs.Stats,right:fs.Stats):boolean{return left.dev===right.dev&&left.ino===right.ino&&left.uid===right.uid;} function ownedDirectory(path:string,label:string):fs.Stats{ let stat:fs.Stats;try{stat=fs.lstatSync(path);}catch{fail('PERSISTENCE_FAILED',`${label} disappeared`);} if(!stat!.isDirectory()||stat!.isSymbolicLink()||(process.getuid&&stat!.uid!==process.getuid()))fail('UNSAFE_PATH',`${label} is not one owned directory`); return stat!; } /** Detached guard for a successful retained preparation copy. Exported for fault-injection qualification. */ export async function supervisePreparedCall(options:{watchdogPath:string;ownerPid:number;deadline:number;runRoot:string;callRoot:string;controlRoot:string}):Promise{ const run=fs.realpathSync(options.runRoot),call=fs.realpathSync(options.callRoot),control=fs.realpathSync(options.controlRoot); if(!Number.isSafeInteger(options.ownerPid)||options.ownerPid<=1||!Number.isSafeInteger(options.deadline)||options.deadline<=Date.now()|| !call.startsWith(`${run}${sep}`)||!control.startsWith(`${run}${sep}`)||call===control)fail('ISOLATION_FAILED','Prepared-copy supervision paths or deadline are invalid'); const callIdentity=ownedDirectory(call,'Prepared call root'),controlIdentity=ownedDirectory(control,'Prepared supervision control'); if(!fs.existsSync(options.watchdogPath)||fs.lstatSync(options.watchdogPath).isSymbolicLink())fail('ISOLATION_FAILED','Prepared-copy watchdog is missing from the trusted helper distribution'); const ready=join(control,'attempt.ready'),terminal=join(control,'attempt.terminal'),stopped=join(control,'attempt.stopped'),event=join(control,'attempt.event'); const child=spawn(options.watchdogPath,['--attempt-owner',String(options.ownerPid),'--deadline',String(Math.ceil(options.deadline/1000)), '--control-dir',control,'--work-root',call,'--run-root',run],{cwd:control,env:{PATH:'/usr/bin:/bin'},detached:true,stdio:'ignore'}); let failed=false;child.once('error',()=>{failed=true;});child.unref(); for(let attempt=0;attempt<100&&!failed&&!fs.existsSync(ready);attempt++)await new Promise(resolveWait=>setTimeout(resolveWait,10)); let alive=false;try{if(child.pid){process.kill(child.pid,0);alive=true;}}catch{} if(failed||!alive||!fs.existsSync(ready)){ try{if(child.pid)process.kill(child.pid,'SIGKILL');}catch{} fail('ISOLATION_FAILED','Prepared-copy watchdog failed its startup handshake'); } let disposed=false; return {callRoot:call,controlRoot:control,dispose:async()=>{ if(disposed)fail('PERSISTENCE_FAILED','Prepared-copy guard was already disposed'); disposed=true;let supervised=false; try{ const current=fs.lstatSync(call); if(!sameDirectory(callIdentity,current)||!current.isDirectory()||current.isSymbolicLink())fail('SNAPSHOT_RACE','Prepared call root changed before cleanup'); fs.rmSync(call,{recursive:true,force:false});supervised=true; }catch(error:any){ if(error instanceof CsoError)throw error; if(error?.code!=='ENOENT'||!fs.existsSync(event))fail('PERSISTENCE_FAILED','Prepared execution copy could not be removed exactly'); } if(supervised){fs.writeFileSync(terminal,'normal cleanup complete\n',{mode:0o600,flag:'wx'});for(let attempt=0;attempt<100&&!fs.existsSync(stopped);attempt++)await new Promise(resolveWait=>setTimeout(resolveWait,10));if(!fs.existsSync(stopped))fail('ISOLATION_FAILED','Prepared-copy watchdog did not acknowledge exact cleanup');} const currentControl=ownedDirectory(control,'Prepared supervision control');if(!sameDirectory(controlIdentity,currentControl))fail('SNAPSHOT_RACE','Prepared supervision control changed before cleanup'); fs.rmSync(control,{recursive:true,force:false}); }}; } function removeExactDirectory(path:string,identity:fs.Stats,label:string):void{ let current:fs.Stats; try{current=fs.lstatSync(path);}catch(error:any){if(error?.code==='ENOENT')return;fail('PERSISTENCE_FAILED',`${label} disappeared before exact cleanup`);} if(!current!.isDirectory()||current!.isSymbolicLink()||!sameDirectory(identity,current!))fail('SNAPSHOT_RACE',`${label} changed before exact cleanup`); try{fs.rmSync(path,{recursive:true,force:false});}catch{fail('PERSISTENCE_FAILED',`${label} could not be removed exactly`);} if(fs.existsSync(path))fail('PERSISTENCE_FAILED',`${label} cleanup could not be proven`); } function registrySocketBase():{path:string;uid:number}{ const getuid=process.getuid; if(process.platform==='win32'||!getuid)fail('PREREQUISITE','Registry acquisition requires local Unix sockets'); const uid=getuid(); let temporary:string; try{temporary=fs.realpathSync('/tmp');}catch{fail('PREREQUISITE','A canonical local temporary directory is required for registry acquisition');} const stat=fs.lstatSync(temporary!); if(temporary==='/'||!stat.isDirectory()||stat.isSymbolicLink()||(stat.uid!==0&&stat.uid!==uid)|| ((stat.mode&0o022)!==0&&(stat.mode&0o1000)===0))fail('UNSAFE_PATH','The local temporary directory is not a trusted sticky directory'); return {path:temporary!,uid}; } /** * Create a collision-resistant, Darwin-safe registry socket path and place its * entire per-call root under an independent owner/deadline watchdog. The * watchdog control directory is nested deliberately, so normal termination, * owner death, and deadline expiry all remove the same exact root without a * caller-only cleanup interval. */ export async function superviseRegistrySocket(options:{watchdogPath:string;ownerPid:number;deadline:number}):Promise{ if(!Number.isSafeInteger(options.ownerPid)||options.ownerPid<=1||!Number.isSafeInteger(options.deadline)||options.deadline<=Date.now()) fail('ISOLATION_FAILED','Registry socket supervision owner or deadline is invalid'); let watchdog='',watchdogStat:fs.Stats; try{watchdog=fs.realpathSync(options.watchdogPath);watchdogStat=fs.lstatSync(options.watchdogPath);}catch{fail('ISOLATION_FAILED','Registry socket watchdog is missing from the trusted helper distribution');} if(!isAbsolute(options.watchdogPath)||watchdog!==options.watchdogPath||!watchdogStat!.isFile()||watchdogStat!.isSymbolicLink()|| (watchdogStat!.mode&0o111)===0||(process.getuid&&watchdogStat!.uid!==0&&watchdogStat!.uid!==process.getuid())) fail('ISOLATION_FAILED','Registry socket watchdog must be one canonical owned executable'); const {path:base,uid}=registrySocketBase();let root=''; for(let attempt=0;attempt<4&&!root;attempt++){ const candidate=join(base,`gscso-${uid}-${randomBytes(16).toString('hex')}`); try{fs.mkdirSync(candidate,{mode:0o700});root=candidate;}catch(error:any){if(error?.code!=='EEXIST')throw error;} } if(!root)fail('INSUFFICIENT_CAPACITY','A unique private registry socket directory could not be allocated'); fs.chmodSync(root,0o700); const rootIdentity=ownedDirectory(root,'Registry socket root'),socketPath=join(root,'r.sock'); if(fs.realpathSync(root)!==root||Buffer.byteLength(socketPath)>REGISTRY_SOCKET_PATH_MAX_BYTES){ removeExactDirectory(root,rootIdentity,'Registry socket root'); fail('PREREQUISITE',`The canonical registry socket path exceeds ${REGISTRY_SOCKET_PATH_MAX_BYTES} bytes`); } let control='';let child:ReturnType|undefined;let failed=false;let exitCode:number|null|undefined; try{ control=secureDirectory(join(root,'control')); const ready=join(control,'attempt.ready'),terminal=join(control,'attempt.terminal'); child=spawn(watchdog,['--ephemeral-owner',String(options.ownerPid),'--deadline',String(Math.ceil(options.deadline/1000)), '--control-dir',control,'--work-root',root,'--run-root',base],{cwd:control,env:{PATH:'/usr/bin:/bin'},detached:true,stdio:'ignore'}); const exited=new Promise(resolveExit=>child!.once('close',code=>{exitCode=code;resolveExit(code);})); child.once('error',()=>{failed=true;});child.unref(); for(let attempt=0;attempt<100&&!failed&&!fs.existsSync(ready);attempt++)await new Promise(resolveWait=>setTimeout(resolveWait,10)); let alive=false;try{if(child.pid){process.kill(child.pid,0);alive=true;}}catch{} if(failed||!alive||!fs.existsSync(ready)){ try{if(child.pid)process.kill(child.pid,'SIGKILL');}catch{} await Promise.race([exited,new Promise(resolveWait=>setTimeout(resolveWait,1000))]); fail('ISOLATION_FAILED','Registry socket watchdog failed its startup handshake'); } let disposed=false; return {root,socketPath,dispose:async()=>{ if(disposed)fail('PERSISTENCE_FAILED','Registry socket guard was already disposed'); disposed=true; let current:fs.Stats; try{current=fs.lstatSync(root);}catch(error:any){ if(error?.code!=='ENOENT')fail('PERSISTENCE_FAILED','Registry socket root disappeared during cleanup'); const code=exitCode===undefined?await Promise.race([exited,new Promise(resolveWait=>setTimeout(()=>resolveWait(undefined),5000))]):exitCode; if(code!==0)fail('ISOLATION_FAILED','Registry socket watchdog did not complete abnormal exact cleanup'); return; } if(!current!.isDirectory()||current!.isSymbolicLink()||!sameDirectory(rootIdentity,current!))fail('SNAPSHOT_RACE','Registry socket root changed before cleanup'); try{fs.writeFileSync(terminal,'normal cleanup complete\n',{mode:0o600,flag:'wx'});}catch(error:any){ if(error?.code!=='ENOENT')throw error; } for(let attempt=0;attempt<500&&fs.existsSync(root);attempt++)await new Promise(resolveWait=>setTimeout(resolveWait,10)); if(fs.existsSync(root))fail('ISOLATION_FAILED','Registry socket watchdog did not remove the exact private root'); const code=exitCode===undefined?await Promise.race([exited,new Promise(resolveWait=>setTimeout(()=>resolveWait(undefined),5000))]):exitCode; if(code!==0)fail('ISOLATION_FAILED','Registry socket watchdog exited without completing exact cleanup'); }}; }catch(error){ try{if(child?.pid)process.kill(child.pid,'SIGKILL');}catch{} let cleanupError:unknown;try{removeExactDirectory(root,rootIdentity,'Registry socket root');}catch(failure){cleanupError=failure;} if(cleanupError)throw cleanupError; throw error; } } /** * Requires qualified runtime images to contain the fixed, compiled * /opt/cso/preparation@1.0.0 helper contract. */ export class DockerPreparationSandboxRunner implements PreparationSandboxRunner { readonly qualification; private readonly runtime; private readonly controlRoot: string; private readonly preparedRoots = new Map(); constructor(private readonly options: DockerPreparationRunnerOptions) { this.runtime = admittedPreparationRuntime(options.admission); if (!['node', 'bun', 'python', 'rails'].includes(this.runtime.stack) || this.runtime.versions['cso-preparation'] !== '1.0.0') fail('PREREQUISITE', 'Qualified runtime lacks the cso-preparation=1.0.0 container helper contract'); this.controlRoot = secureDirectory(resolve(options.controlRoot)); this.qualification = Object.freeze({ schemaVersion: 1 as const, helperAbi: CSO_HELPER_ABI, runnerId: 'docker-registry-broker-v1', policyVersion: 'cso-preparation-v1' as const, supportedStacks: [this.runtime.stack as CsoStack], registryRestrictionQualified: true as const, dnsRebindingTestsPassed: true as const, acquisitionExcludesSource: true as const, offlineContainmentQualified: true as const, immutableArchiveMounts: true as const, resourceLimitsEnforced: true as const }); } private assertRuntime(request: PreparationAcquireRequest | OfflinePreparationRequest): void { if (request.runtime.id !== this.runtime.id || request.runtime.image !== this.runtime.image || request.runtime.platform !== this.runtime.platform || request.stack !== this.runtime.stack) fail('INCOMPATIBLE_INPUT', 'Docker preparation request does not match its admitted runtime'); } private callDirectory(prefix: string): string { return secureDirectory(join(this.controlRoot, `${prefix}-${Date.now()}-${randomBytes(8).toString('hex')}`)); } private removeCallDirectory(path:string,identity:fs.Stats):void{ let current:fs.Stats;try{current=fs.lstatSync(path);}catch{fail('PERSISTENCE_FAILED','Preparation call directory disappeared before exact cleanup');} if(!current!.isDirectory()||current!.isSymbolicLink()||current!.dev!==identity.dev||current!.ino!==identity.ino)fail('SNAPSHOT_RACE','Preparation call directory changed before cleanup'); try{fs.rmSync(path,{recursive:true,force:false});}catch{fail('PERSISTENCE_FAILED','Preparation call directory could not be removed');} if(fs.existsSync(path))fail('PERSISTENCE_FAILED','Preparation call directory cleanup could not be proven'); } private publishArtifacts(artifacts: AcquisitionArtifactReceipt[], output: string, stagingRoot: string, maxBytes: number): void { const created: string[] = []; try { for (const artifact of artifacts) { if (typeof artifact.stagingPath !== 'string' || !artifact.stagingPath.startsWith('cso-public/') || artifact.stagingPath.slice('cso-public/'.length).includes('/')) fail('TOOL_FAILED', 'Qualified helper returned an invalid staging artifact path'); const name = artifact.stagingPath.slice('cso-public/'.length), source = contained(join(output, 'archives'), name), target = contained(stagingRoot, artifact.stagingPath); const stat = fs.lstatSync(source); if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size !== artifact.bytes || stat.size > maxBytes || (process.getuid && stat.uid !== process.getuid()) || (stat.mode & 0o022) !== 0 || fileSha256(source, maxBytes) !== artifact.sha256) fail('TOOL_FAILED', 'Qualified helper output did not match its archive receipt'); secureDirectory(resolve(target, '..')); fs.copyFileSync(source, target, fs.constants.COPYFILE_EXCL); fs.chmodSync(target, 0o600); created.push(target); const copied = fs.lstatSync(target); if (!copied.isFile() || copied.isSymbolicLink() || copied.nlink !== 1 || copied.size !== artifact.bytes || fileSha256(target, maxBytes) !== artifact.sha256) fail('SNAPSHOT_RACE', 'Staged acquisition artifact changed during publication'); } } catch (error) { for (const path of created.reverse()) { try { const stat = fs.lstatSync(path); if (stat.isFile() && !stat.isSymbolicLink() && stat.nlink === 1) fs.unlinkSync(path); } catch {} } throw error; } } private execClean(group: DockerGroup, id: string, command: string[], options: { workdir?: string; env?: Record } = {}) { const forbidden = new Set(['BUN_OPTIONS', 'BUN_BE_BUN', 'NODE_OPTIONS', 'RUBYOPT', 'RUBYLIB', 'PYTHONPATH', 'PYTHONHOME', 'LD_PRELOAD', 'LD_LIBRARY_PATH', 'ENV', 'BASH_ENV', 'CDPATH']); if (Object.keys(options.env ?? {}).some(key => forbidden.has(key))) fail('ISOLATION_FAILED', 'Preparation command attempted to restore a runtime injection variable'); const clean = { PATH: '/usr/local/bin:/usr/bin:/bin', HOME: '/work/.cso-home', ...(options.env ?? {}) }; const argv = ['/usr/bin/env', '-i', ...Object.entries(clean).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => `${key}=${value}`), ...command]; return group.execCapture(id, argv, { workdir: options.workdir }); } async acquire(request: PreparationAcquireRequest): Promise { this.assertRuntime(request); const dir = this.callDirectory('acquire'),callIdentity = fs.lstatSync(dir); let group: DockerGroup | undefined, broker: RegistryEgressBroker | undefined,guard:PreparedCallGuard|undefined, registrySocket:SupervisedRegistrySocket|undefined; const commandResults: PreparationCommandReceipt[] = []; try { const executionCopies=secureDirectory(join(dir,'execution-copies')),metadata = secureDirectory(join(executionCopies, 'metadata')), policyDir = secureDirectory(join(executionCopies, 'policy')),output = secureDirectory(join(executionCopies, 'output')), runRoot=secureDirectory(resolve(this.options.runRoot??this.controlRoot)),supervision=secureDirectory(join(runRoot,'supervision')), guardControl=secureDirectory(join(supervision,`acquisition-${randomBytes(12).toString('hex')}`)); guard=await supervisePreparedCall({watchdogPath:this.options.watchdogPath,ownerPid:process.pid,deadline:request.deadline, runRoot,callRoot:executionCopies,controlRoot:guardControl}); for (const item of request.metadata) { if (sha256(item.content) !== item.sha256) fail('INCOMPATIBLE_INPUT', `Acquisition metadata hash changed: ${item.path}`); const file = contained(metadata, item.path); secureDirectory(resolve(file, '..')); fs.writeFileSync(file, item.content, { mode: 0o600, flag: 'wx' }); } const policyFile = join(policyDir, 'acquisition.json'); writePolicy(policyFile, { schemaVersion: 1, planHash: request.planHash, stack: request.stack, inputs: request.inputs, commands: request.commands, allowedHosts: request.network.allowedHosts, archiveRoot: '/archives', limits: request.limits }); group = await DockerGroup.create(this.options.endpoint, `prep-a-${randomBytes(10).toString('hex')}`, dir, request.deadline, this.runtime.image, this.options.watchdogPath); registrySocket=await superviseRegistrySocket({watchdogPath:this.options.watchdogPath,ownerPid:process.pid,deadline:request.deadline}); broker = new RegistryEgressBroker(registrySocket.socketPath, request.network.allowedHosts, request.deadline, request.limits.maxTotalArchiveBytes + Math.min(64 * 1024 * 1024, request.limits.maxTotalArchiveBytes)); await broker.start(); const container = await group.createContainer({ role: 'app', image: this.runtime.image, command: ['/opt/cso/preparation', 'forwarder', '/run/cso-registry.sock', '127.0.0.1:18443'], readonlyFiles: [{ host: policyFile, container: '/policy/acquisition.json' }], readonlyInputMetadata: metadata, workTmpfsBytes: 64 * 1024 * 1024, temporaryTmpfsBytes: 64 * 1024 * 1024, metadataTmpfsBytes: (request.stack === 'node' || request.stack === 'bun' ? 1024 : 64) * 1024 * 1024, archiveTmpfsBytes: request.limits.maxTotalArchiveBytes, registrySocket: broker.socketPath }); await group.start(container); let ready = false; for (let attempt = 0; attempt < 20 && !ready; attempt++) { const health = await this.execClean(group, container, ['/opt/cso/preparation', 'health', '127.0.0.1', '18443']); ready = health.code === 0; if (!ready) await new Promise(resolveWait => setTimeout(resolveWait, 25)); } if (!ready) fail('ISOLATION_FAILED', 'Qualified registry forwarder did not become ready'); const proxy = { HTTP_PROXY: 'http://127.0.0.1:18443', HTTPS_PROXY: 'http://127.0.0.1:18443', ALL_PROXY: 'http://127.0.0.1:18443', NO_PROXY: '' }; for (let index = 0; index < request.commands.length; index++) { const command = request.commands[index], result = await this.execClean(group, container, [command.executable, ...command.args], { workdir: command.cwd, env: { ...command.env, ...proxy } }); broker.assertClean(); commandResults.push(commandReceipt(command, index, result.code)); if (result.code !== 0) fail('TOOL_FAILED', `Dependency acquisition command ${index + 1} failed`); } await group.assertOnlyInitProcess(container); const containerExport = `/archives/.gstack-cso-acquisition-export-${randomBytes(12).toString('hex')}`; const manifest = await this.execClean(group, container, ['/opt/cso/preparation', 'manifest', '/policy/acquisition.json', '/archives', `${containerExport}/artifacts.json`], { workdir: '/archives', env: proxy }); if (manifest.code !== 0) fail('TOOL_FAILED', 'Qualified archive manifest helper failed'); broker.assertClean(); await group.assertOnlyInitProcess(container); await group.pause(container); await group.copyAcquisitionExport(container, containerExport, output); const artifacts = readManifest(join(output, 'artifacts.json')).artifacts; validateAcquisitionOutput(output, artifacts); this.publishArtifacts(artifacts, output, request.stagingRoot, request.limits.maxArchiveBytes); return { schemaVersion: 1, planHash: request.planHash, runtimeId: request.runtime.id, runtimeImage: request.runtime.image, platform: request.runtime.platform, deadlineEnforced: true, network: { mode: 'registry-restricted', allowedHosts: [...request.network.allowedHosts], contactedHosts: [...broker.contactedHosts].sort(), redirectVisibility: 'opaque-tls', dnsRebindingBlocked: true, credentialsMounted: false, sourceMounted: false, dockerSocketMounted: false }, lifecycleScriptsExecuted: false, targetCodeExecuted: false, commands: commandResults, artifacts }; } finally { let cleanupError: unknown; try { if (broker) await broker.close(); } catch (error) { cleanupError = error; } try { if (group) await group.cleanup(); } catch (error) { cleanupError ??= error; } try { if (registrySocket) await registrySocket.dispose(); } catch (error) { cleanupError ??= error; } try { if (guard) await guard.dispose(); } catch (error) { cleanupError ??= error; } if (cleanupError) throw cleanupError; this.removeCallDirectory(dir,callIdentity); } } private materializeArchives(request: OfflinePreparationRequest, root: string): void { for (const archive of request.archives) { if (!archive.containerPath.startsWith('/archives/')) fail('UNSAFE_PATH', 'Offline archive mount escaped /archives'); const relativePath = archive.containerPath.slice('/archives/'.length), target = contained(root, relativePath), source = resolve(archive.hostPath); const stat = fs.lstatSync(source); if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size !== archive.bytes || (stat.mode & 0o222) !== 0 || (process.getuid && stat.uid !== process.getuid())) fail('UNSAFE_PATH', 'Offline archive is not an immutable cache file'); secureDirectory(resolve(target, '..')); fs.copyFileSync(source, target, fs.constants.COPYFILE_EXCL); fs.chmodSync(target, 0o400); const hash = fileSha256(target, archive.bytes); if (hash !== archive.sha256) fail('INCOMPATIBLE_INPUT', 'Offline archive changed while its execution view was materialized'); } } async prepareOffline(request: OfflinePreparationRequest): Promise { this.assertRuntime(request); const dir = this.callDirectory('offline'),callIdentity=fs.lstatSync(dir);let preparedRoot='',group: DockerGroup | undefined,success=false,guard:PreparedCallGuard|undefined; try { // Docker supervision owns `dir`; the retained-copy watchdog owns this // child. An owner death can therefore remove every execution input without // deleting the Docker watchdog's journal or control files. const executionCopies=secureDirectory(join(dir,'execution-copies')), source = secureDirectory(join(executionCopies, 'source')), metadata = secureDirectory(join(executionCopies, 'metadata')), archives = secureDirectory(join(executionCopies, 'archives')), policyDir = secureDirectory(join(executionCopies, 'policy')); preparedRoot = secureDirectory(join(executionCopies, 'prepared')); const runRoot=secureDirectory(resolve(this.options.runRoot??this.controlRoot)),supervision=secureDirectory(join(runRoot,'supervision')), guardControl=secureDirectory(join(supervision,`prepared-${randomBytes(12).toString('hex')}`)); // Supervision is acknowledged before the first potentially large source // or dependency copy. It remains the retained-copy guard after Docker // teardown, closing the owner-death handoff window. guard=await supervisePreparedCall({watchdogPath:this.options.watchdogPath,ownerPid:process.pid,deadline:request.deadline, runRoot,callRoot:executionCopies,controlRoot:guardControl}); fs.cpSync(request.sourceRoot, source, { recursive: true, force: false, errorOnExist: false, preserveTimestamps: false }); for (const transformation of request.transformations) { if (sha256(transformation.content) !== transformation.sha256) fail('INCOMPATIBLE_INPUT', 'Offline transformation content hash changed'); const file = contained(source, transformation.path); secureDirectory(resolve(file, '..')); fs.writeFileSync(file, transformation.content, { mode: 0o600 }); } for (const item of request.metadata) { if (sha256(item.content) !== item.sha256) fail('INCOMPATIBLE_INPUT', `Offline metadata hash changed: ${item.path}`); const file = contained(metadata, item.path); secureDirectory(resolve(file, '..')); fs.writeFileSync(file, item.content, { mode: 0o600, flag: 'wx' }); } this.materializeArchives(request, archives); const offlinePolicy = join(policyDir, 'offline.json'); writePolicy(offlinePolicy, { schemaVersion: 1, planHash: request.planHash, stack: request.stack, inputs: request.archives.map(archive => ({ index: archive.inputIndex, input: { kind: 'public', name: archive.name, version: archive.version, url: archive.requestedUrl, integrity: archive.declaredIntegrity, integritySource: archive.declaredIntegrity === 'registry-on-acquisition' ? 'registry-on-acquisition' : 'lock' } })), archives: request.archives.map(archive => ({ inputIndex: archive.inputIndex, name: archive.name, version: archive.version, declaredIntegrity: archive.declaredIntegrity, requestedUrl: archive.requestedUrl, resolvedUrl: archive.resolvedUrl, containerPath: archive.containerPath, sha256: archive.sha256, bytes: archive.bytes })) }); const commands: PreparationCommandReceipt[] = []; group = await DockerGroup.create(this.options.endpoint, `prep-o-${randomBytes(10).toString('hex')}`, dir, request.deadline, this.runtime.image, this.options.watchdogPath); const app = await group.createContainer({ role: 'app', image: this.runtime.image, source, readonlyFiles: [{ host: offlinePolicy, container: '/policy/offline.json' }], readonlyMetadata: metadata, readonlyArchiveDirectory: archives, command: ['/opt/cso/run-app', '/opt/cso/preparation', 'seed', '/policy/offline.json'] }); await group.start(app); let ready = false; for (let attempt = 0; attempt < 100 && !ready; attempt++) { const result = await this.execClean(group, app, ['/opt/cso/preparation', 'ready']); ready = result.code === 0; if (!ready) await new Promise(resolveWait => setTimeout(resolveWait, 25)); } if (!ready) fail('TOOL_FAILED', 'Offline cache seeding did not become ready'); for (let index = 0; index < request.commands.length; index++) { const command = request.commands[index], result = await this.execClean(group, app, [command.executable, ...command.args], { workdir: command.cwd, env: command.env }); commands.push(commandReceipt(command, index, result.code)); if (result.code !== 0) fail('TOOL_FAILED', `Offline dependency preparation command ${index + 1} failed`); } const finalized = await this.execClean(group, app, ['/opt/cso/preparation', 'finalize']); if (finalized.code !== 0) fail('TOOL_FAILED', 'Offline preparation scratch cleanup failed'); await group.assertOnlyInitProcess(app); const containerExport = `/work/.gstack-cso-export-${randomBytes(12).toString('hex')}`; const exported = await this.execClean(group, app, ['/opt/cso/preparation', 'export-prepared', '/work', containerExport, String(request.limits.writableBytes)]); if (exported.code !== 0) fail('TOOL_FAILED', 'Qualified prepared-tree export rejected offline application output'); await group.assertOnlyInitProcess(app); await group.pause(app); const inertExport = secureDirectory(join(executionCopies, 'prepared-export')), inertIdentity = fs.lstatSync(inertExport); await group.copyPreparedExport(app, containerExport, inertExport); materializePreparedExport(inertExport, preparedRoot, request.limits.writableBytes); this.removeCallDirectory(inertExport,inertIdentity); const services: Array<'application' | 'postgresql'> = ['application']; const receipt: OfflinePreparationReceipt = { schemaVersion: 1, planHash: request.planHash, runtimeId: request.runtime.id, runtimeImage: request.runtime.image, platform: request.runtime.platform, sourceHash: request.sourceHash, dependencyClosureHash: request.dependencyClosureHash, configurationHash: request.configurationHash, databaseHash: request.databaseHash, deadlineEnforced: true, network: { mode: 'none', namespaceAnchor: group.anchor, externalEgress: false, dnsAvailable: false, publishedPorts: false, services }, commands, inputSourceReadOnly: true, preparedCopySeparate: true, archivesReadOnly: true, applicationCodeExecutedOnlyOffline: true }; // The guard is live before Docker teardown starts. Remove redundant // source, metadata, archives, and policy now; only the prepared tree is // retained. Docker's independent watchdog keeps its parent control root. for(const current of [source,metadata,archives,policyDir])this.removeCallDirectory(current,fs.lstatSync(current)); const completedGroup=group;await completedGroup.cleanup();group=undefined; this.preparedRoots.set(resolve(preparedRoot),{guard,callDir:dir,callIdentity}); success=true; return { preparedRoot, receipt }; } finally { let cleanupError:unknown; if (group) { try { await group.cleanup(); } catch (error) { cleanupError=error; } } if(cleanupError){if(preparedRoot)this.preparedRoots.delete(resolve(preparedRoot));if(guard)try{await guard.dispose();}catch{}throw cleanupError;} if(!success){ if(preparedRoot)this.preparedRoots.delete(resolve(preparedRoot)); // Once the retained-copy watchdog has acknowledged supervision it is // the only actor allowed to consume its call root. Stop and // acknowledge it before removing the Docker call directory. if(guard)await guard.dispose(); this.removeCallDirectory(dir,callIdentity); } } } async disposePrepared(preparedRoot: string): Promise { const root = resolve(preparedRoot), owned = this.preparedRoots.get(root),guard=owned?.guard,call=guard?.callRoot; if (!guard || !call || !owned || root !== join(call, 'prepared') || !owned.callDir.startsWith(`${this.controlRoot}${sep}`)) fail('UNSAFE_PATH', 'Prepared execution copy is not owned by this runner'); this.preparedRoots.delete(root); await guard.dispose(); this.removeCallDirectory(owned.callDir,owned.callIdentity); } }