use super::{
CommandKind, ExecutionResult, IsolationLevel, ResourceLimits, SENSITIVE_MOUNT_PATHS,
SandboxCommand, SandboxExecutor, select_image_for_command,
};
use echo_core::error::Result;
use echo_core::error::SandboxError;
use futures::future::BoxFuture;
use serde::{Deserialize, Serialize};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
const SANDBOX_LABEL: &str = "echo-sandbox=true";
const DOCKER_CACHE_TTL: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DockerConfig {
pub default_image: String,
pub language_images: std::collections::HashMap<String, String>,
pub auto_remove: bool,
pub disable_network: bool,
pub memory_limit: Option<u64>,
pub cpu_quota: Option<u64>,
pub read_only_rootfs: bool,
pub extra_args: Vec<String>,
}
static DOCKER_CHECK_CACHE: OnceLock<Mutex<Option<(Instant, bool)>>> = OnceLock::new();
const DANGEROUS_DOCKER_ARGS: &[&str] = &[
"--privileged",
"--pid=host",
"--network=host",
"--ipc=host",
"--uts=host",
"--cap-add",
"--security-opt=seccomp=unconfined",
"--security-opt=apparmor=unconfined",
"--userns=host",
"--device=",
"--volume=/",
"-v=/etc",
"-v=/proc",
"-v=/sys",
"-v=/",
];
impl Default for DockerConfig {
fn default() -> Self {
let mut language_images = std::collections::HashMap::new();
language_images.insert("python".to_string(), "python:3.12-slim".to_string());
language_images.insert("python3".to_string(), "python:3.12-slim".to_string());
language_images.insert("node".to_string(), "node:20-slim".to_string());
language_images.insert("javascript".to_string(), "node:20-slim".to_string());
language_images.insert("ruby".to_string(), "ruby:3.3-slim".to_string());
language_images.insert("go".to_string(), "golang:1.22-alpine".to_string());
language_images.insert("rust".to_string(), "rust:1.77-slim".to_string());
Self {
default_image: "ubuntu:22.04".to_string(),
language_images,
auto_remove: true,
disable_network: true,
memory_limit: Some(256 * 1024 * 1024), cpu_quota: Some(50_000),
read_only_rootfs: true,
extra_args: vec![],
}
}
}
#[derive(Debug, Clone)]
pub struct DockerSandbox {
config: DockerConfig,
}
impl DockerSandbox {
pub fn new(config: DockerConfig) -> Self {
Self { config }
}
async fn check_docker() -> bool {
let cache = DOCKER_CHECK_CACHE.get_or_init(|| Mutex::new(None));
if let Ok(guard) = cache.lock()
&& let Some((checked_at, available)) = *guard
&& checked_at.elapsed() < DOCKER_CACHE_TTL
{
return available;
}
let available = Command::new("docker")
.arg("info")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.await
.map(|s| s.success())
.unwrap_or(false);
if let Ok(mut guard) = cache.lock() {
*guard = Some((Instant::now(), available));
}
available
}
fn select_image(&self, command: &SandboxCommand) -> String {
select_image_for_command(
command,
&self.config.language_images,
&self.config.default_image,
)
}
fn build_docker_create_args(
&self,
command: &SandboxCommand,
limits: Option<&ResourceLimits>,
) -> Result<Vec<String>> {
let mut args = vec!["create".to_string()];
args.push("--label".to_string());
args.push(SANDBOX_LABEL.to_string());
let network_allowed = limits
.map(|l| l.network)
.unwrap_or(!self.config.disable_network);
if !network_allowed {
args.push("--network=none".to_string());
}
let mem = limits
.and_then(|l| l.memory_bytes)
.or(self.config.memory_limit);
if let Some(mem) = mem {
args.push(format!("--memory={mem}"));
args.push(format!("--memory-swap={mem}")); }
if let Some(quota) = self.config.cpu_quota {
args.push(format!("--cpu-quota={quota}"));
}
if let Some(limits) = limits
&& let Some(max_procs) = limits.max_processes
{
args.push(format!("--pids-limit={max_procs}"));
}
if self.config.read_only_rootfs {
args.push("--read-only".to_string());
args.push("--tmpfs=/tmp:rw,noexec,nosuid,size=64m".to_string());
}
args.push("--cap-drop=ALL".to_string());
args.push("--security-opt=no-new-privileges".to_string());
args.push("--init".to_string());
args.push("--restart=no".to_string());
args.push("--ulimit=nofile=256:512".to_string());
args.push("--ulimit=nproc=64:128".to_string());
for (k, v) in &command.env {
args.push("-e".to_string());
args.push(format!("{k}={v}"));
}
if let Some(limits) = limits {
for path in &limits.read_only_paths {
Self::validate_mount_paths(std::slice::from_ref(path)).map_err(|e| {
echo_core::error::ReactError::Sandbox(SandboxError::PermissionDenied(e))
})?;
args.push("-v".to_string());
args.push(format!("{}:{}:ro", path.display(), path.display()));
}
for path in &limits.writable_paths {
Self::validate_mount_paths(std::slice::from_ref(path)).map_err(|e| {
echo_core::error::ReactError::Sandbox(SandboxError::PermissionDenied(e))
})?;
args.push("-v".to_string());
args.push(format!("{}:{}", path.display(), path.display()));
}
}
if let Some(ref dir) = command.working_dir {
args.push("-w".to_string());
args.push(dir.display().to_string());
}
for arg in &self.config.extra_args {
if !DANGEROUS_DOCKER_ARGS.iter().any(|d| arg.starts_with(d)) {
args.push(arg.clone());
}
}
args.push(self.select_image(command));
args.extend(Self::build_inner_command(command));
Ok(args)
}
fn validate_mount_paths(paths: &[std::path::PathBuf]) -> std::result::Result<(), String> {
for path in paths {
let path_str = path.display().to_string();
for sensitive in SENSITIVE_MOUNT_PATHS.iter() {
if path_str == *sensitive || path_str.starts_with(&format!("{sensitive}/")) {
return Err(format!(
"Mount path '{}' accesses sensitive directory '{}'",
path_str, sensitive
));
}
}
}
Ok(())
}
fn build_inner_command(command: &SandboxCommand) -> Vec<String> {
match &command.kind {
CommandKind::Shell(cmd) => vec!["sh".to_string(), "-c".to_string(), cmd.clone()],
CommandKind::Program { program, args } => {
let mut v = vec![program.clone()];
v.extend(args.clone());
v
}
CommandKind::Code { language, code } => {
let (interpreter, flag) = match language.as_str() {
"python" | "python3" => ("python3", "-c"),
"node" | "javascript" | "js" => ("node", "-e"),
"ruby" => ("ruby", "-e"),
"perl" => ("perl", "-e"),
"php" => ("php", "-r"),
_ => ("sh", "-c"),
};
vec![interpreter.to_string(), flag.to_string(), code.clone()]
}
}
}
pub async fn cleanup_sandbox_containers() -> Result<()> {
let output = Command::new("docker")
.args(["ps", "-aq", "--filter", &format!("label={SANDBOX_LABEL}")])
.output()
.await
.map_err(|e| {
echo_core::error::ReactError::Sandbox(SandboxError::IoError(format!(
"Failed to list sandbox containers: {e}"
)))
})?;
let containers = String::from_utf8_lossy(&output.stdout);
if !containers.trim().is_empty() {
for container_id in containers.lines() {
let _ = Command::new("docker")
.args(["rm", "-f", container_id])
.output()
.await;
}
}
Ok(())
}
async fn remove_container(container_id: &str) {
let _ = Command::new("docker")
.args(["rm", "-f", container_id])
.output()
.await;
}
}
impl SandboxExecutor for DockerSandbox {
fn name(&self) -> &str {
"docker"
}
fn isolation_level(&self) -> IsolationLevel {
IsolationLevel::Container
}
fn is_available(&self) -> BoxFuture<'_, bool> {
Box::pin(Self::check_docker())
}
fn cleanup(&self) -> BoxFuture<'_, Result<()>> {
Box::pin(Self::cleanup_sandbox_containers())
}
fn execute(&self, command: SandboxCommand) -> BoxFuture<'_, Result<ExecutionResult>> {
Box::pin(async move {
if !Self::check_docker().await {
return Err(echo_core::error::ReactError::Sandbox(
SandboxError::Unavailable("Docker is not available".to_string()),
));
}
let timeout = command.timeout;
let docker_args = self.build_docker_create_args(&command, None)?;
let start = Instant::now();
let output = Command::new("docker")
.args(&docker_args)
.output()
.await
.map_err(|e| {
echo_core::error::ReactError::Sandbox(SandboxError::StartFailed(format!(
"Failed to create docker container: {e}"
)))
})?;
let container_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
if container_id.is_empty() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(echo_core::error::ReactError::Sandbox(
SandboxError::StartFailed(format!("Failed to get container ID: {stderr}")),
));
}
let mut start_cmd = Command::new("docker");
let attach_flag = if command.stdin.is_some() { "-ai" } else { "-a" };
start_cmd
.args(["start", attach_flag, &container_id])
.stdin(if command.stdin.is_some() {
std::process::Stdio::piped()
} else {
std::process::Stdio::null()
})
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let mut child = start_cmd.spawn().map_err(|e| {
echo_core::error::ReactError::Sandbox(SandboxError::StartFailed(format!(
"Failed to start docker container: {e}"
)))
})?;
if let Some(input) = command.stdin.as_deref()
&& let Some(mut stdin) = child.stdin.take()
{
stdin.write_all(input.as_bytes()).await.map_err(|e| {
echo_core::error::ReactError::Sandbox(SandboxError::IoError(format!(
"Failed to write docker stdin: {e}"
)))
})?;
}
match tokio::time::timeout(timeout, child.wait_with_output()).await {
Ok(Ok(output)) => {
Self::remove_container(&container_id).await;
Ok(ExecutionResult {
exit_code: output.status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
duration: start.elapsed(),
sandbox_type: "docker".to_string(),
timed_out: false,
})
}
Ok(Err(e)) => {
Self::remove_container(&container_id).await;
Err(echo_core::error::ReactError::Sandbox(
SandboxError::IoError(format!("Docker IO error: {e}")),
))
}
Err(_) => {
let _ = Command::new("docker")
.args(["kill", &container_id])
.output()
.await;
Self::remove_container(&container_id).await;
Ok(ExecutionResult {
exit_code: -1,
stdout: String::new(),
stderr: format!("Docker execution timed out after {}s", timeout.as_secs()),
duration: start.elapsed(),
sandbox_type: "docker".to_string(),
timed_out: true,
})
}
}
})
}
fn execute_with_limits(
&self,
command: SandboxCommand,
limits: ResourceLimits,
) -> BoxFuture<'_, Result<ExecutionResult>> {
Box::pin(async move {
if !Self::check_docker().await {
return Err(echo_core::error::ReactError::Sandbox(
SandboxError::Unavailable("Docker is not available".to_string()),
));
}
let timeout = limits
.cpu_time_secs
.map(std::time::Duration::from_secs)
.unwrap_or(command.timeout);
let docker_args = self.build_docker_create_args(&command, Some(&limits))?;
let start = Instant::now();
let output = Command::new("docker")
.args(&docker_args)
.output()
.await
.map_err(|e| {
echo_core::error::ReactError::Sandbox(SandboxError::StartFailed(format!(
"Failed to create docker container: {e}"
)))
})?;
let container_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
if container_id.is_empty() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(echo_core::error::ReactError::Sandbox(
SandboxError::StartFailed(format!("Failed to get container ID: {stderr}")),
));
}
let mut start_cmd = Command::new("docker");
let attach_flag = if command.stdin.is_some() { "-ai" } else { "-a" };
start_cmd
.args(["start", attach_flag, &container_id])
.stdin(if command.stdin.is_some() {
std::process::Stdio::piped()
} else {
std::process::Stdio::null()
})
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let mut child = start_cmd.spawn().map_err(|e| {
echo_core::error::ReactError::Sandbox(SandboxError::StartFailed(format!(
"Failed to start docker container: {e}"
)))
})?;
if let Some(input) = command.stdin.as_deref()
&& let Some(mut stdin) = child.stdin.take()
{
stdin.write_all(input.as_bytes()).await.map_err(|e| {
echo_core::error::ReactError::Sandbox(SandboxError::IoError(format!(
"Failed to write docker stdin: {e}"
)))
})?;
}
match tokio::time::timeout(timeout, child.wait_with_output()).await {
Ok(Ok(output)) => {
Self::remove_container(&container_id).await;
let mut stdout = String::from_utf8_lossy(&output.stdout).to_string();
let mut stderr = String::from_utf8_lossy(&output.stderr).to_string();
if let Some(max) = limits.max_output_bytes {
let max = max as usize;
if stdout.len() > max {
stdout.truncate(max);
stdout.push_str("\n... [output truncated]");
}
if stderr.len() > max {
stderr.truncate(max);
stderr.push_str("\n... [output truncated]");
}
}
Ok(ExecutionResult {
exit_code: output.status.code().unwrap_or(-1),
stdout,
stderr,
duration: start.elapsed(),
sandbox_type: "docker".to_string(),
timed_out: false,
})
}
Ok(Err(e)) => {
Self::remove_container(&container_id).await;
Err(echo_core::error::ReactError::Sandbox(
SandboxError::IoError(format!("Docker IO error: {e}")),
))
}
Err(_) => {
let _ = Command::new("docker")
.args(["kill", &container_id])
.output()
.await;
Self::remove_container(&container_id).await;
Ok(ExecutionResult {
exit_code: -1,
stdout: String::new(),
stderr: format!("Docker execution timed out after {}s", timeout.as_secs()),
duration: start.elapsed(),
sandbox_type: "docker".to_string(),
timed_out: true,
})
}
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_docker_config_default() {
let config = DockerConfig::default();
assert_eq!(config.default_image, "ubuntu:22.04");
assert!(config.auto_remove);
assert!(config.disable_network);
}
#[test]
fn test_select_image_python() {
let sandbox = DockerSandbox::new(DockerConfig::default());
let cmd = SandboxCommand::code("python", "print(1)");
let image = sandbox.select_image(&cmd);
assert_eq!(image, "python:3.12-slim");
}
#[test]
fn test_select_image_fallback() {
let sandbox = DockerSandbox::new(DockerConfig::default());
let cmd = SandboxCommand::shell("echo hello");
let image = sandbox.select_image(&cmd);
assert_eq!(image, "ubuntu:22.04");
}
#[test]
fn test_docker_args_security() {
let sandbox = DockerSandbox::new(DockerConfig::default());
let cmd = SandboxCommand::shell("echo test");
let args = sandbox.build_docker_create_args(&cmd, None).unwrap();
assert_eq!(args.first().map(String::as_str), Some("create"));
assert!(args.contains(&"--label".to_string()));
assert!(args.contains(&SANDBOX_LABEL.to_string()));
assert!(args.contains(&"--cap-drop=ALL".to_string()));
assert!(args.contains(&"--security-opt=no-new-privileges".to_string()));
assert!(args.contains(&"--network=none".to_string()));
assert!(args.contains(&"--init".to_string()));
assert!(args.contains(&"--restart=no".to_string()));
assert!(args.contains(&"--read-only".to_string()));
assert!(args.contains(&"--ulimit=nofile=256:512".to_string()));
assert!(args.contains(&"--ulimit=nproc=64:128".to_string()));
}
#[test]
fn test_docker_args_with_limits() {
let sandbox = DockerSandbox::new(DockerConfig::default());
let cmd = SandboxCommand::shell("echo test");
let limits = ResourceLimits {
max_processes: Some(16),
network: true,
..Default::default()
};
let args = sandbox
.build_docker_create_args(&cmd, Some(&limits))
.unwrap();
assert!(args.contains(&"--pids-limit=16".to_string()));
assert!(!args.contains(&"--network=none".to_string()));
}
#[test]
fn test_inner_command_shell() {
let cmd = SandboxCommand::shell("ls -la");
let inner = DockerSandbox::build_inner_command(&cmd);
assert_eq!(inner, vec!["sh", "-c", "ls -la"]);
}
#[test]
fn test_inner_command_code() {
let cmd = SandboxCommand::code("python", "print('hi')");
let inner = DockerSandbox::build_inner_command(&cmd);
assert_eq!(inner, vec!["python3", "-c", "print('hi')"]);
}
#[test]
fn test_docker_args_include_full_command() {
let sandbox = DockerSandbox::new(DockerConfig::default());
let cmd = SandboxCommand::program("python3", vec!["-V".to_string()]);
let args = sandbox.build_docker_create_args(&cmd, None).unwrap();
assert_eq!(args.first().map(String::as_str), Some("create"));
assert!(args.contains(&"python3".to_string()));
assert!(args.contains(&"-V".to_string()));
assert!(!args.contains(&"run".to_string()));
}
}