/** * @fileoverview Docker case export / import: move a container (toolchain + any * in-image changes) PLUS its workspace to another machine as one portable * `.codeman-container.tgz`, and restore it. * * A full-image export = `docker commit` the running container to an image -> * `docker save` that image -> tar the bind-mounted workspace -> a manifest, all * bundled into one gzip tarball. A workspace-only export skips the image (fast, * files-only). Import validates the manifest + per-member checksums, extracts the * workspace with a path-traversal guard, `docker load`s the image and RE-TAGS it * into a quarantined namespace (never overwriting a local tag), and hands the * caller enough to recreate a hardened case on the destination. * * Safety (all from the design critic): pause the container spanning the workspace * tar AND the commit so the two artifacts are mutually consistent; a free-space * precheck (a full docker graph wedges EVERY session on the host); `docker rmi` * the intermediate image in a finally; sealed containers refuse a full-image * export (an in-container login would ride the committed layer); import rejects * absolute / `..` tar members and checksum mismatches. Bounded by * runWithConversionLimit so N exports cannot fork-bomb the host. * * @module docker-export */ import { createReadStream, createWriteStream, existsSync, mkdirSync } from 'node:fs'; import fs from 'node:fs/promises'; import { join, basename } from 'node:path'; import { createHash } from 'node:crypto'; import { spawn } from 'node:child_process'; import { pipeline } from 'node:stream/promises'; import type { DockerEngine, SessionDocker } from './types.js'; import { runWithConversionLimit } from './document-conversion-limiter.js'; const IS_TEST_MODE = !!process.env.VITEST; /** Refuse to export when the target filesystem has less than this free (a full graph wedges the daemon). */ export const DOCKER_EXPORT_MIN_FREE_BYTES = 2 * 1024 * 1024 * 1024; // 2 GiB /** Manifest schema version (bump on any breaking field change). */ export const DOCKER_EXPORT_SCHEMA = 1; export type DockerExportMode = 'full' | 'workspace'; export interface DockerExportManifest { schemaVersion: number; caseName: string; mode: DockerExportMode; engine: DockerEngine; image: string; containerWorkdir: string; network: string; createdAt: number; codemanVersion: string; mountCredentials: boolean; /** True when the bundle provably carries no credentials (convenient-mode workspace, or a full image whose creds were bind-mounted and thus never committed). */ secretFree: boolean; /** sha256 of each bundle member that is present. */ checksums: { image?: string; workspace?: string }; } // ========== Pure helpers (unit-tested) ========== /** Raw argv prefix for the engine (NO shell escaping — used with spawn). */ export function dockerArgv(docker: Pick): string[] { const argv: string[] = [docker.engine === 'podman' ? 'podman' : 'docker']; if (docker.context) argv.push('--context', docker.context); if (docker.daemonHost) argv.push('-H', docker.daemonHost); return argv; } /** Portable bundle filename for a case export. */ export function exportBundleName(caseName: string, timestamp: number, mode: DockerExportMode): string { const suffix = mode === 'workspace' ? 'workspace' : 'container'; return `${caseName}-${timestamp}.codeman-${suffix}.tgz`; } /** Quarantined image tag for an imported bundle (never overwrites a local tag). */ export function importedImageTag(caseName: string, timestamp: number): string { return `codeman/imported-${caseName}:${timestamp}`; } /** Intermediate commit tag for a full-image export (unique per export, rmi'd in finally). */ export function exportImageTag(caseName: string, timestamp: number): string { return `codeman/export-${caseName}:${timestamp}`; } /** * Reject a tar member path that would escape the extraction root (absolute path * or a `..` component). The import-side traversal guard. */ export function isSafeTarMember(member: string): boolean { const trimmed = member.trim(); if (!trimmed || trimmed === './') return true; if (trimmed.startsWith('/')) return false; // Normalize separators and check each component. return !trimmed.split('/').some((part) => part === '..'); } /** Parse the image id/ref from `docker load` output ("Loaded image: x" / "Loaded image ID: sha256:..."). */ export function parseLoadedImageRef(loadOutput: string): string | null { const idMatch = loadOutput.match(/Loaded image ID:\s*(sha256:[0-9a-f]+)/i); if (idMatch) return idMatch[1]; const refMatch = loadOutput.match(/Loaded image:\s*(\S+)/i); if (refMatch) return refMatch[1]; return null; } /** * Validate an imported bundle's manifest BEFORE any of its fields are trusted. * A bundle is cross-machine input (potentially authored by someone else), and its * fields flow into stored host/case config that the schema layer never sees: * `engine` becomes the probe/launch binary selector, `image`/`containerWorkdir` * reach the shellescaped launch string, `network` is a create arg. Mirror the * DockerHostSchema/DockerCaseLinkSchema constraints here (throwing, since this is * not a web-layer module). Exported for unit tests. */ export function validateImportManifest(manifest: DockerExportManifest): void { const fail = (msg: string): never => { throw new Error(`invalid bundle manifest: ${msg}`); }; if (manifest.schemaVersion !== DOCKER_EXPORT_SCHEMA) { fail(`unsupported export schema version ${manifest.schemaVersion} (expected ${DOCKER_EXPORT_SCHEMA})`); } if (manifest.mode !== 'full' && manifest.mode !== 'workspace') fail(`unknown mode ${String(manifest.mode)}`); if (manifest.engine !== 'docker' && manifest.engine !== 'podman') fail(`unknown engine ${String(manifest.engine)}`); if (typeof manifest.caseName !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(manifest.caseName)) fail('bad caseName'); if ( typeof manifest.image !== 'string' || manifest.image.length > 512 || !/^[a-zA-Z0-9][\w./:@-]*$/.test(manifest.image) ) { fail('bad image reference'); } if ( typeof manifest.containerWorkdir !== 'string' || manifest.containerWorkdir.length > 2000 || !manifest.containerWorkdir.startsWith('/') || // comma: --mount specs are comma-delimited CSV; shell escaping cannot protect it /[`$\\"'\n\r;&|<>,]/.test(manifest.containerWorkdir) ) { fail('bad containerWorkdir'); } if (!['bridge', 'none', 'custom'].includes(manifest.network)) fail(`unknown network ${String(manifest.network)}`); if (typeof manifest.checksums !== 'object' || manifest.checksums === null) fail('missing checksums'); } // ========== IO helpers ========== function run( cmd: string, args: string[], opts: { timeout?: number } = {} ): Promise<{ stdout: string; stderr: string }> { return new Promise((resolve, reject) => { const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; let timer: NodeJS.Timeout | undefined; if (opts.timeout) { timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error(`${cmd} timed out after ${opts.timeout}ms`)); }, opts.timeout); } child.stdout.on('data', (d) => (stdout += d)); child.stderr.on('data', (d) => (stderr += d)); child.on('error', (err) => { if (timer) clearTimeout(timer); reject(err); }); child.on('close', (code) => { if (timer) clearTimeout(timer); if (code === 0) resolve({ stdout, stderr }); else reject(new Error(`${cmd} ${args.join(' ')} exited ${code}: ${stderr.trim()}`)); }); }); } /** * Stream `docker save ` stdout to a raw tar file (no shell, no double-gzip). * Uses stream `pipeline` so completion means the write stream is FULLY flushed to * disk (a naive child 'close' resolves before the last chunks land, truncating the * file — a real bug caught in end-to-end testing), AND waits for a clean exit code. */ async function saveImageToTar(argv: string[], tag: string, outPath: string): Promise { const child = spawn(argv[0], [...argv.slice(1), 'save', tag], { stdio: ['ignore', 'pipe', 'pipe'] }); let stderr = ''; child.stderr.on('data', (d) => (stderr += d)); const exited = new Promise((resolve, reject) => { child.on('error', reject); child.on('close', (code) => code === 0 ? resolve() : reject(new Error(`docker save exited ${code}: ${stderr.trim()}`)) ); }); // pipeline resolves only after the destination has fully flushed. await Promise.all([pipeline(child.stdout, createWriteStream(outPath)), exited]); } async function sha256File(path: string): Promise { return new Promise((resolve, reject) => { const hash = createHash('sha256'); const stream = createReadStream(path); stream.on('data', (d) => hash.update(d)); stream.on('error', reject); stream.on('end', () => resolve(hash.digest('hex'))); }); } async function freeBytes(path: string): Promise { try { const stat = await fs.statfs(path); return Number(stat.bavail) * Number(stat.bsize); } catch { return Number.POSITIVE_INFINITY; // statfs unsupported — don't block } } async function isContainerRunning(argv: string[], container: string): Promise { try { const { stdout } = await run(argv[0], [...argv.slice(1), 'inspect', '-f', '{{.State.Running}}', container], { timeout: 15_000, }); return stdout.trim() === 'true'; } catch { return false; } } export interface ExportResult { bundlePath: string; manifest: DockerExportManifest; sizeBytes: number; } /** * Export a docker case to a portable bundle. Bounded by runWithConversionLimit. * `full` mode commits + saves the image AND tars the workspace; `workspace` mode * tars just the workspace. The container is paused across the artifact capture so * image and workspace are mutually consistent. */ export async function exportDockerCase(params: { docker: SessionDocker; caseName: string; timestamp: number; exportsDir: string; mode: DockerExportMode; codemanVersion: string; }): Promise { const { docker, caseName, timestamp, exportsDir, mode, codemanVersion } = params; if (mode === 'full' && !docker.mountCredentials) { throw new Error( 'full-image export is refused for a sealed (mountCredentials:false) container: an in-container login would ride the committed image layer. Use a workspace-only export.' ); } if (IS_TEST_MODE) { // No real docker/tar under vitest — return a deterministic stub. const manifest: DockerExportManifest = { schemaVersion: DOCKER_EXPORT_SCHEMA, caseName, mode, engine: docker.engine, image: docker.image, containerWorkdir: docker.containerWorkdir, network: docker.network, createdAt: timestamp, codemanVersion, mountCredentials: docker.mountCredentials, secretFree: true, checksums: {}, }; return { bundlePath: join(exportsDir, exportBundleName(caseName, timestamp, mode)), manifest, sizeBytes: 0 }; } return runWithConversionLimit(async () => { if (!existsSync(exportsDir)) mkdirSync(exportsDir, { recursive: true }); const free = await freeBytes(exportsDir); if (free < DOCKER_EXPORT_MIN_FREE_BYTES) { throw new Error( `not enough free space to export (need >= ${Math.round(DOCKER_EXPORT_MIN_FREE_BYTES / 1e9)}GB, have ${Math.round(free / 1e9)}GB). A full docker graph wedges every session on the host.` ); } const argv = dockerArgv(docker); const bundlePath = join(exportsDir, exportBundleName(caseName, timestamp, mode)); const stageDir = join(exportsDir, `.stage-${caseName}-${timestamp}`); mkdirSync(stageDir, { recursive: true }); const wasRunning = await isContainerRunning(argv, docker.containerName); let commitTag: string | undefined; try { if (wasRunning) { await run(argv[0], [...argv.slice(1), 'pause', docker.containerName], { timeout: 30_000 }).catch(() => {}); } const checksums: DockerExportManifest['checksums'] = {}; if (mode === 'full') { commitTag = exportImageTag(caseName, timestamp); // Blank instance-specific committed env so the image carries no stale host refs. await run( argv[0], [ ...argv.slice(1), 'commit', '-c', 'ENV CODEMAN_API_URL=', '-c', 'ENV CODEMAN_HOOK_SECRET_FILE=', docker.containerName, commitTag, ], { timeout: 300_000 } ); const imageTar = join(stageDir, 'image.tar'); await saveImageToTar(argv, commitTag, imageTar); checksums.image = await sha256File(imageTar); } const workspaceTar = join(stageDir, 'workspace.tar'); await run('tar', ['-cf', workspaceTar, '-C', docker.hostWorkspacePath, '.'], { timeout: 300_000 }); checksums.workspace = await sha256File(workspaceTar); const manifest: DockerExportManifest = { schemaVersion: DOCKER_EXPORT_SCHEMA, caseName, mode, engine: docker.engine, image: docker.image, containerWorkdir: docker.containerWorkdir, network: docker.network, createdAt: timestamp, codemanVersion, mountCredentials: docker.mountCredentials, // Convenient mode keeps creds on bind mounts (never committed), so the bundle is secret-free. secretFree: docker.mountCredentials, checksums, }; await fs.writeFile(join(stageDir, 'manifest.json'), JSON.stringify(manifest, null, 2)); const members = mode === 'full' ? ['manifest.json', 'image.tar', 'workspace.tar'] : ['manifest.json', 'workspace.tar']; await run('tar', ['-czf', bundlePath, '-C', stageDir, ...members], { timeout: 300_000 }); const stat = await fs.stat(bundlePath); return { bundlePath, manifest, sizeBytes: stat.size }; } finally { // Always remove the intermediate image + stage dir, and unpause. if (commitTag) { await run(argv[0], [...argv.slice(1), 'rmi', commitTag], { timeout: 60_000 }).catch(() => {}); } await fs.rm(stageDir, { recursive: true, force: true }).catch(() => {}); if (wasRunning) { await run(argv[0], [...argv.slice(1), 'unpause', docker.containerName], { timeout: 30_000 }).catch(() => {}); } } }); } export interface ImportResult { manifest: DockerExportManifest; /** Quarantined image ref the destination case should use (full mode only). */ importedImage?: string; /** Directory the workspace was extracted into. */ workspacePath: string; } /** * Import a bundle produced by exportDockerCase: validate the manifest + per-member * checksums, extract the workspace (traversal-guarded) into destWorkspace, and, in * full mode, `docker load` the image and re-tag it into a quarantined namespace. */ export async function importDockerBundle(params: { bundlePath: string; destWorkspace: string; engine: DockerEngine; timestamp: number; /** Schema-validated destination case name; the quarantine tag derives from THIS, * never from the (attacker-authored) manifest.caseName. */ newCaseName: string; }): Promise { const { bundlePath, destWorkspace, engine, timestamp, newCaseName } = params; const argv: string[] = [engine === 'podman' ? 'podman' : 'docker']; if (IS_TEST_MODE) { const raw = await fs.readFile(bundlePath, 'utf-8').catch(() => '{}'); const manifest = JSON.parse(raw) as DockerExportManifest; validateImportManifest(manifest); return { manifest, workspacePath: destWorkspace }; } const stageDir = `${destWorkspace}.import-stage-${timestamp}`; mkdirSync(stageDir, { recursive: true }); try { // Outer-bundle traversal guard (defense in depth: GNU/bsd tar already refuse // `..`/absolute members by default, but the bundle is cross-machine input). const { stdout: bundleMembers } = await run('tar', ['-tzf', bundlePath], { timeout: 60_000 }); for (const member of bundleMembers.split('\n').filter(Boolean)) { if (!isSafeTarMember(member)) throw new Error(`unsafe path in bundle archive: ${member}`); } await run('tar', ['--no-same-owner', '-xzf', bundlePath, '-C', stageDir], { timeout: 300_000 }); const manifestRaw = await fs.readFile(join(stageDir, 'manifest.json'), 'utf-8'); const manifest = JSON.parse(manifestRaw) as DockerExportManifest; validateImportManifest(manifest); // Integrity: verify checksums before trusting any member. const workspaceTar = join(stageDir, 'workspace.tar'); if (manifest.checksums.workspace) { const actual = await sha256File(workspaceTar); if (actual !== manifest.checksums.workspace) throw new Error('workspace checksum mismatch (corrupt or tampered bundle)'); } // Traversal guard: reject absolute / `..` members before extraction. const { stdout: memberList } = await run('tar', ['-tf', workspaceTar], { timeout: 60_000 }); for (const member of memberList.split('\n').filter(Boolean)) { if (!isSafeTarMember(member)) throw new Error(`unsafe path in workspace archive: ${member}`); } mkdirSync(destWorkspace, { recursive: true }); await run('tar', ['--no-same-owner', '-xf', workspaceTar, '-C', destWorkspace], { timeout: 300_000 }); let importedImage: string | undefined; if (manifest.mode === 'full') { const imageTar = join(stageDir, 'image.tar'); if (manifest.checksums.image) { const actual = await sha256File(imageTar); if (actual !== manifest.checksums.image) throw new Error('image checksum mismatch (corrupt or tampered bundle)'); } const { stdout } = await run(argv[0], [...argv.slice(1), 'load', '-i', imageTar], { timeout: 300_000 }); const loadedRef = parseLoadedImageRef(stdout); if (!loadedRef) throw new Error('could not determine loaded image ref'); // Quarantine: re-tag by the loaded ref/id, never trusting the bundle's original // tag; the tag name derives from the caller's schema-validated newCaseName. importedImage = importedImageTag(newCaseName, timestamp); await run(argv[0], [...argv.slice(1), 'tag', loadedRef, importedImage], { timeout: 60_000 }); } return { manifest, importedImage, workspacePath: destWorkspace }; } finally { await fs.rm(stageDir, { recursive: true, force: true }).catch(() => {}); } } /** List export bundles in the exports dir (newest first), with size + mtime. */ export async function listDockerExports( exportsDir: string ): Promise> { if (!existsSync(exportsDir)) return []; const entries = await fs.readdir(exportsDir).catch(() => [] as string[]); const out: Array<{ name: string; sizeBytes: number; mtimeMs: number }> = []; for (const name of entries) { if (!name.endsWith('.tgz')) continue; try { const stat = await fs.stat(join(exportsDir, name)); out.push({ name: basename(name), sizeBytes: stat.size, mtimeMs: stat.mtimeMs }); } catch { /* skip */ } } return out.sort((a, b) => b.mtimeMs - a.mtimeMs); }