use std::{ io::Read, process::{Command, Stdio}, thread, time::{Duration, Instant}, }; const COMMAND_DEADLINE: Duration = Duration::from_secs(15); const OUTPUT_LIMIT: usize = 4096; pub fn docker_text(arguments: &[&str]) -> Result { docker_text_with_timeout(arguments, COMMAND_DEADLINE) } pub fn docker_text_with_timeout( arguments: &[&str], command_deadline: Duration, ) -> Result { docker_text_with_timeout_and_limits(arguments, command_deadline, OUTPUT_LIMIT, OUTPUT_LIMIT) } pub fn docker_text_with_timeout_and_limits( arguments: &[&str], command_deadline: Duration, stdout_limit: usize, stderr_limit: usize, ) -> Result { let mut child = Command::new("docker") .args(arguments) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .map_err(|_| "Docker CLI could not be started")?; let stdout = child.stdout.take().ok_or("Docker stdout unavailable")?; let stderr = child.stderr.take().ok_or("Docker stderr unavailable")?; let stdout_reader = thread::spawn(move || read_capped(stdout, stdout_limit)); let stderr_reader = thread::spawn(move || read_capped(stderr, stderr_limit)); let deadline = Instant::now() + command_deadline; let status = loop { match child.try_wait().map_err(|_| "Docker command wait failed")? { Some(status) => break status, None if Instant::now() >= deadline => { let _ = child.kill(); let _ = child.wait(); let _ = stdout_reader.join(); let _ = stderr_reader.join(); return Err("Docker command timed out"); } None => thread::sleep(Duration::from_millis(20)), } }; let (stdout, stdout_exceeded) = stdout_reader .join() .map_err(|_| "Docker stdout reader failed")? .map_err(|_| "Docker stdout read failed")?; let (stderr, stderr_exceeded) = stderr_reader .join() .map_err(|_| "Docker stderr reader failed")? .map_err(|_| "Docker stderr read failed")?; if !status.success() { return Err(classify_docker_failure(status.code(), &stderr)); } if stdout_exceeded || stderr_exceeded { return Err("Docker command output exceeded its limit"); } String::from_utf8(stdout).map_err(|_| "Docker returned non-UTF-8 output") } fn classify_docker_error(stderr: &[u8]) -> &'static str { let diagnostic = String::from_utf8_lossy(stderr).to_ascii_lowercase(); if diagnostic.contains("table") && diagnostic.contains("does not exist") || diagnostic.contains("no chain/target/match") { return "Docker host lacks required bridge networking support"; } if diagnostic.contains("overlap") { return "Docker rejected a test subnet because it overlaps an existing network"; } if diagnostic.contains("subnet") || diagnostic.contains("ipv4") { return "Docker rejected the IPv4 network parameters"; } if diagnostic.contains("pool") || diagnostic.contains("address space") { return "Docker rejected the test network address pool"; } if diagnostic.contains("already exists") { return "Docker reported a conflicting owned resource name"; } if diagnostic.contains("no such container") || diagnostic.contains("no such network") || diagnostic.contains("no such object") || diagnostic.contains(" not found") { return "Docker named resource was not found"; } if diagnostic.contains("permission denied") || diagnostic.contains("forbidden") || diagnostic.contains("operation not permitted") { return "Docker API denied the requested operation"; } "Docker command returned a nonzero status" } fn classify_docker_failure(status_code: Option, stderr: &[u8]) -> &'static str { if matches!(status_code, Some(124 | 137 | 143)) { return "In-container fixture command timed out"; } classify_docker_error(stderr) } fn read_capped(mut reader: impl Read, limit: usize) -> std::io::Result<(Vec, bool)> { let mut captured = Vec::new(); let mut buffer = [0_u8; 1024]; let mut exceeded = false; loop { let count = reader.read(&mut buffer)?; if count == 0 { return Ok((captured, exceeded)); } let remaining = limit.saturating_sub(captured.len()); captured.extend_from_slice(&buffer[..count.min(remaining)]); exceeded |= count > remaining; } } #[cfg(test)] mod tests { use std::io::Cursor; use super::{OUTPUT_LIMIT, classify_docker_error, classify_docker_failure, read_capped}; #[test] fn output_capture_is_capped_while_the_pipe_is_drained() { let input = vec![b'x'; OUTPUT_LIMIT + 17]; let (result, exceeded) = read_capped(Cursor::new(input), OUTPUT_LIMIT).expect("read test bytes"); assert_eq!(result.len(), OUTPUT_LIMIT); assert!(exceeded); } #[test] fn docker_errors_are_reported_as_fixed_redacted_categories() { assert_eq!( classify_docker_error(b"iptables: Table does not exist; /etc/wireguard/private.conf"), "Docker host lacks required bridge networking support" ); assert_eq!( classify_docker_error(b"Pool overlaps with another subnet"), "Docker rejected a test subnet because it overlaps an existing network" ); assert_eq!( classify_docker_error(b"Error: No such object: wga-m83-net-test"), "Docker named resource was not found" ); assert_eq!( classify_docker_error(b"network wga-m83-net-test not found"), "Docker named resource was not found" ); } #[test] fn busybox_timeout_exit_status_is_classified_without_stderr() { for status_code in [124, 137, 143] { assert_eq!( classify_docker_failure(Some(status_code), b""), "In-container fixture command timed out" ); } assert_eq!( classify_docker_failure(Some(2), b""), "Docker command returned a nonzero status" ); } }