use std::path::PathBuf;
use std::time::{Duration, Instant};
use tokio::process::Command;
#[derive(Debug, Clone)]
pub struct DockerConfig {
pub image: String,
pub memory_limit: u64,
pub cpu_limit: f64,
pub network_disabled: bool,
pub timeout: Duration,
pub mount_workdir: bool,
pub workdir: Option<PathBuf>,
}
impl Default for DockerConfig {
fn default() -> Self {
Self {
image: "brainwires/sandbox:latest".to_string(),
memory_limit: 256 * 1024 * 1024, cpu_limit: 1.0,
network_disabled: true,
timeout: Duration::from_secs(30),
mount_workdir: false,
workdir: None,
}
}
}
#[derive(Debug, Clone)]
pub struct DockerExecutionResult {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
pub duration: Duration,
pub timed_out: bool,
}
pub struct DockerExecutor {
config: DockerConfig,
}
impl DockerExecutor {
pub fn new(config: DockerConfig) -> Self {
Self { config }
}
pub async fn execute(
&self,
language: &str,
code: &str,
) -> anyhow::Result<DockerExecutionResult> {
let ext = language_extension(language)?;
let tmp_dir = tempfile::tempdir()?;
let code_filename = format!("code.{ext}");
let host_path = tmp_dir.path().join(&code_filename);
std::fs::write(&host_path, code)?;
let container_path = format!("/sandbox/{code_filename}");
let args = self.build_docker_args(&host_path, &container_path, language)?;
let start = Instant::now();
let result = tokio::time::timeout(self.config.timeout, async {
Command::new("docker").args(&args).output().await
})
.await;
let duration = start.elapsed();
match result {
Ok(Ok(output)) => Ok(DockerExecutionResult {
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
exit_code: output.status.code().unwrap_or(-1),
duration,
timed_out: false,
}),
Ok(Err(e)) => Err(anyhow::anyhow!("Failed to run docker: {e}")),
Err(_) => {
Ok(DockerExecutionResult {
stdout: String::new(),
stderr: "Execution timed out".to_string(),
exit_code: -1,
duration,
timed_out: true,
})
}
}
}
pub async fn is_available() -> bool {
Command::new("docker")
.arg("--version")
.output()
.await
.is_ok_and(|o| o.status.success())
}
pub async fn pull_image(&self) -> anyhow::Result<()> {
let output = Command::new("docker")
.args(["pull", &self.config.image])
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("docker pull failed: {stderr}");
}
Ok(())
}
pub async fn list_images() -> anyhow::Result<Vec<String>> {
let output = Command::new("docker")
.args(["images", "--format", "{{.Repository}}:{{.Tag}}"])
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("docker images failed: {stderr}");
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout.lines().map(String::from).collect())
}
pub(crate) fn build_docker_args(
&self,
host_code_path: &std::path::Path,
container_code_path: &str,
language: &str,
) -> anyhow::Result<Vec<String>> {
let mut args: Vec<String> = vec!["run".into(), "--rm".into()];
args.push("--memory".into());
args.push(self.config.memory_limit.to_string());
args.push("--cpus".into());
args.push(format!("{:.1}", self.config.cpu_limit));
if self.config.network_disabled {
args.push("--network".into());
args.push("none".into());
}
args.push("--read-only".into());
args.push("-v".into());
args.push(format!(
"{}:{}:ro",
host_code_path.display(),
container_code_path
));
if self.config.mount_workdir {
if let Some(ref workdir) = self.config.workdir {
args.push("-v".into());
args.push(format!("{}:/sandbox/work", workdir.display()));
}
}
args.push("--tmpfs".into());
args.push("/tmp:rw,noexec,nosuid,size=64m".into());
args.push(self.config.image.clone());
let cmd = language_command(language, container_code_path)?;
args.extend(cmd);
Ok(args)
}
}
fn language_extension(language: &str) -> anyhow::Result<&'static str> {
match language {
"python" | "py" => Ok("py"),
"javascript" | "js" | "node" => Ok("js"),
"lua" => Ok("lua"),
"ruby" | "rb" => Ok("rb"),
"bash" | "sh" => Ok("sh"),
_ => Err(anyhow::anyhow!(
"Unsupported language for Docker execution: {language}"
)),
}
}
fn language_command(language: &str, file_path: &str) -> anyhow::Result<Vec<String>> {
match language {
"python" | "py" => Ok(vec!["python3".into(), file_path.into()]),
"javascript" | "js" | "node" => Ok(vec!["node".into(), file_path.into()]),
"lua" => Ok(vec!["lua".into(), file_path.into()]),
"ruby" | "rb" => Ok(vec!["ruby".into(), file_path.into()]),
"bash" | "sh" => Ok(vec!["bash".into(), file_path.into()]),
_ => Err(anyhow::anyhow!(
"Unsupported language for Docker execution: {language}"
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let cfg = DockerConfig::default();
assert_eq!(cfg.image, "brainwires/sandbox:latest");
assert_eq!(cfg.memory_limit, 256 * 1024 * 1024);
assert!((cfg.cpu_limit - 1.0).abs() < f64::EPSILON);
assert!(cfg.network_disabled);
assert_eq!(cfg.timeout, Duration::from_secs(30));
assert!(!cfg.mount_workdir);
assert!(cfg.workdir.is_none());
}
#[test]
fn test_language_command_python() {
let cmd = language_command("python", "/sandbox/code.py").unwrap();
assert_eq!(cmd, vec!["python3", "/sandbox/code.py"]);
}
#[test]
fn test_language_command_py_alias() {
let cmd = language_command("py", "/sandbox/code.py").unwrap();
assert_eq!(cmd, vec!["python3", "/sandbox/code.py"]);
}
#[test]
fn test_language_command_javascript() {
let cmd = language_command("javascript", "/sandbox/code.js").unwrap();
assert_eq!(cmd, vec!["node", "/sandbox/code.js"]);
}
#[test]
fn test_language_command_js_alias() {
let cmd = language_command("js", "/sandbox/code.js").unwrap();
assert_eq!(cmd, vec!["node", "/sandbox/code.js"]);
}
#[test]
fn test_language_command_node_alias() {
let cmd = language_command("node", "/sandbox/code.js").unwrap();
assert_eq!(cmd, vec!["node", "/sandbox/code.js"]);
}
#[test]
fn test_language_command_lua() {
let cmd = language_command("lua", "/sandbox/code.lua").unwrap();
assert_eq!(cmd, vec!["lua", "/sandbox/code.lua"]);
}
#[test]
fn test_language_command_ruby() {
let cmd = language_command("ruby", "/sandbox/code.rb").unwrap();
assert_eq!(cmd, vec!["ruby", "/sandbox/code.rb"]);
}
#[test]
fn test_language_command_bash() {
let cmd = language_command("bash", "/sandbox/code.sh").unwrap();
assert_eq!(cmd, vec!["bash", "/sandbox/code.sh"]);
}
#[test]
fn test_language_command_sh_alias() {
let cmd = language_command("sh", "/sandbox/code.sh").unwrap();
assert_eq!(cmd, vec!["bash", "/sandbox/code.sh"]);
}
#[test]
fn test_language_command_unsupported() {
let result = language_command("cobol", "/sandbox/code.cob");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("Unsupported language"));
assert!(err.contains("cobol"));
}
#[test]
fn test_language_extension() {
assert_eq!(language_extension("python").unwrap(), "py");
assert_eq!(language_extension("py").unwrap(), "py");
assert_eq!(language_extension("javascript").unwrap(), "js");
assert_eq!(language_extension("js").unwrap(), "js");
assert_eq!(language_extension("lua").unwrap(), "lua");
assert_eq!(language_extension("ruby").unwrap(), "rb");
assert_eq!(language_extension("bash").unwrap(), "sh");
assert!(language_extension("fortran").is_err());
}
#[test]
fn test_build_docker_args_default_config() {
let executor = DockerExecutor::new(DockerConfig::default());
let host_path = std::path::Path::new("/tmp/code.py");
let container_path = "/sandbox/code.py";
let args = executor
.build_docker_args(host_path, container_path, "python")
.unwrap();
assert_eq!(args[0], "run");
assert_eq!(args[1], "--rm");
let mem_idx = args.iter().position(|a| a == "--memory").unwrap();
assert_eq!(args[mem_idx + 1], (256u64 * 1024 * 1024).to_string());
let cpu_idx = args.iter().position(|a| a == "--cpus").unwrap();
assert_eq!(args[cpu_idx + 1], "1.0");
let net_idx = args.iter().position(|a| a == "--network").unwrap();
assert_eq!(args[net_idx + 1], "none");
assert!(args.contains(&"--read-only".to_string()));
let vol_idx = args.iter().position(|a| a == "-v").unwrap();
assert_eq!(args[vol_idx + 1], "/tmp/code.py:/sandbox/code.py:ro");
let tmpfs_idx = args.iter().position(|a| a == "--tmpfs").unwrap();
assert!(args[tmpfs_idx + 1].starts_with("/tmp:"));
assert!(args.contains(&"brainwires/sandbox:latest".to_string()));
let last_two = &args[args.len() - 2..];
assert_eq!(last_two, &["python3", "/sandbox/code.py"]);
}
#[test]
fn test_build_docker_args_network_enabled() {
let config = DockerConfig {
network_disabled: false,
..DockerConfig::default()
};
let executor = DockerExecutor::new(config);
let host_path = std::path::Path::new("/tmp/code.js");
let args = executor
.build_docker_args(host_path, "/sandbox/code.js", "js")
.unwrap();
assert!(!args.contains(&"--network".to_string()));
}
#[test]
fn test_build_docker_args_with_workdir() {
let config = DockerConfig {
mount_workdir: true,
workdir: Some(PathBuf::from("/home/user/project")),
..DockerConfig::default()
};
let executor = DockerExecutor::new(config);
let host_path = std::path::Path::new("/tmp/code.lua");
let args = executor
.build_docker_args(host_path, "/sandbox/code.lua", "lua")
.unwrap();
let vol_indices: Vec<_> = args
.iter()
.enumerate()
.filter(|(_, a)| *a == "-v")
.map(|(i, _)| i)
.collect();
assert_eq!(vol_indices.len(), 2);
assert_eq!(args[vol_indices[1] + 1], "/home/user/project:/sandbox/work");
}
#[test]
fn test_build_docker_args_custom_image() {
let config = DockerConfig {
image: "my-custom-image:v2".to_string(),
..DockerConfig::default()
};
let executor = DockerExecutor::new(config);
let host_path = std::path::Path::new("/tmp/code.rb");
let args = executor
.build_docker_args(host_path, "/sandbox/code.rb", "ruby")
.unwrap();
assert!(args.contains(&"my-custom-image:v2".to_string()));
let last_two = &args[args.len() - 2..];
assert_eq!(last_two, &["ruby", "/sandbox/code.rb"]);
}
#[test]
fn test_build_docker_args_unsupported_language() {
let executor = DockerExecutor::new(DockerConfig::default());
let host_path = std::path::Path::new("/tmp/code.cob");
let result = executor.build_docker_args(host_path, "/sandbox/code.cob", "cobol");
assert!(result.is_err());
}
}