#![allow(dead_code)]
#[cfg(target_os = "macos")]
pub mod apple;
pub mod docker;
pub mod firecracker;
pub mod hyperlight;
#[cfg(feature = "kubernetes")]
pub mod kubernetes;
#[cfg(feature = "kubernetes")]
pub mod kubernetes_operator;
#[cfg(feature = "kubernetes")]
pub mod kubernetes_pool;
#[cfg(feature = "nomad")]
pub mod nomad;
#[cfg(feature = "nomad")]
pub mod nomad_pool;
use anyhow::Result;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::fmt;
use crate::ssh::SshConfig;
#[cfg(target_os = "macos")]
pub use apple::AppleSandbox;
pub use docker::{ContainerRuntime, DockerSandbox};
pub use firecracker::FirecrackerSandbox;
pub use hyperlight::HyperlightSandbox;
#[cfg(feature = "kubernetes")]
pub use kubernetes::KubernetesSandbox;
#[cfg(feature = "nomad")]
pub use nomad::NomadSandbox;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BackendType {
Docker,
Podman,
Firecracker,
Apple,
Hyperlight,
Kubernetes,
Nomad,
}
impl fmt::Display for BackendType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
BackendType::Docker => write!(f, "docker"),
BackendType::Podman => write!(f, "podman"),
BackendType::Firecracker => write!(f, "firecracker"),
BackendType::Apple => write!(f, "apple"),
BackendType::Hyperlight => write!(f, "hyperlight"),
BackendType::Kubernetes => write!(f, "kubernetes"),
BackendType::Nomad => write!(f, "nomad"),
}
}
}
impl std::str::FromStr for BackendType {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"docker" => Ok(BackendType::Docker),
"podman" => Ok(BackendType::Podman),
"firecracker" => Ok(BackendType::Firecracker),
"apple" => Ok(BackendType::Apple),
"hyperlight" => Ok(BackendType::Hyperlight),
"kubernetes" | "k8s" => Ok(BackendType::Kubernetes),
"nomad" => Ok(BackendType::Nomad),
_ => Err(format!(
"Unknown backend '{}'. Valid options: docker, podman, firecracker, apple, hyperlight, kubernetes, nomad",
s
)),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PortProtocol {
#[default]
Tcp,
Udp,
}
impl fmt::Display for PortProtocol {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PortProtocol::Tcp => write!(f, "tcp"),
PortProtocol::Udp => write!(f, "udp"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortMapping {
pub host_port: Option<u16>,
pub container_port: u16,
#[serde(default)]
pub protocol: PortProtocol,
}
impl PortMapping {
pub fn parse(s: &str) -> anyhow::Result<Self> {
let (port_part, protocol) = if let Some(stripped) = s.strip_suffix("/udp") {
(stripped, PortProtocol::Udp)
} else if let Some(stripped) = s.strip_suffix("/tcp") {
(stripped, PortProtocol::Tcp)
} else {
(s, PortProtocol::Tcp)
};
if let Some((host, container)) = port_part.split_once(':') {
let host_port: u16 = host
.parse()
.map_err(|_| anyhow::anyhow!("Invalid host port '{}' in '{}'", host, s))?;
let container_port: u16 = container.parse().map_err(|_| {
anyhow::anyhow!("Invalid container port '{}' in '{}'", container, s)
})?;
Ok(PortMapping {
host_port: Some(host_port),
container_port,
protocol,
})
} else {
let container_port: u16 = port_part
.parse()
.map_err(|_| anyhow::anyhow!("Invalid port '{}' in '{}'", port_part, s))?;
Ok(PortMapping {
host_port: None,
container_port,
protocol,
})
}
}
}
impl fmt::Display for PortMapping {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.host_port {
Some(hp) => write!(f, "{}:{}", hp, self.container_port)?,
None => write!(f, "{}", self.container_port)?,
}
if self.protocol == PortProtocol::Udp {
write!(f, "/udp")?;
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct FileInjection {
pub content: Vec<u8>,
pub dest: String,
}
#[derive(Debug, Clone)]
pub struct SandboxConfig {
pub image: String,
pub vcpus: u32,
pub memory_mb: u64,
pub mount_cwd: bool,
pub work_dir: Option<String>,
pub env: Vec<(String, String)>,
pub network: bool,
pub read_only: bool,
pub mount_home: bool,
pub files: Vec<FileInjection>,
pub ports: Vec<PortMapping>,
pub ssh: Option<SshConfig>,
pub volumes: Vec<String>,
}
impl Default for SandboxConfig {
fn default() -> Self {
Self {
image: "alpine:3.20".to_string(),
vcpus: 1,
memory_mb: 512,
mount_cwd: false,
work_dir: None,
env: Vec::new(),
network: true,
read_only: false,
mount_home: false,
files: Vec::new(),
ports: Vec::new(),
ssh: None,
volumes: Vec::new(),
}
}
}
impl SandboxConfig {
pub fn with_image(image: &str) -> Self {
Self {
image: image.to_string(),
..Default::default()
}
}
pub fn with_resources(mut self, vcpus: u32, memory_mb: u64) -> Self {
self.vcpus = vcpus;
self.memory_mb = memory_mb;
self
}
pub fn with_network(mut self, network: bool) -> Self {
self.network = network;
self
}
pub fn with_mount_cwd(mut self, mount: bool, work_dir: Option<String>) -> Self {
self.mount_cwd = mount;
self.work_dir = work_dir;
self
}
pub fn with_env(mut self, env: Vec<(String, String)>) -> Self {
self.env = env;
self
}
pub fn with_files(mut self, files: Vec<FileInjection>) -> Self {
self.files = files;
self
}
pub fn with_ports(mut self, ports: Vec<PortMapping>) -> Self {
self.ports = ports;
self
}
pub fn with_ssh(mut self, ssh: Option<SshConfig>) -> Self {
self.ssh = ssh;
self
}
}
#[derive(Debug, Clone)]
pub struct ExecResult {
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
}
impl ExecResult {
pub fn success(stdout: String) -> Self {
Self {
exit_code: 0,
stdout,
stderr: String::new(),
}
}
pub fn failure(exit_code: i32, stderr: String) -> Self {
Self {
exit_code,
stdout: String::new(),
stderr,
}
}
pub fn is_success(&self) -> bool {
self.exit_code == 0
}
pub fn output(&self) -> String {
if self.stderr.is_empty() {
self.stdout.clone()
} else if self.stdout.is_empty() {
self.stderr.clone()
} else {
format!("{}\n{}", self.stdout, self.stderr)
}
}
}
#[derive(Debug, Default, Clone)]
pub struct ExecOptions {
pub env: Vec<String>,
pub workdir: Option<String>,
pub user: Option<String>,
}
#[async_trait]
pub trait Sandbox: Send + Sync {
async fn start(&mut self, config: &SandboxConfig) -> Result<()>;
async fn exec(&mut self, cmd: &[&str]) -> Result<ExecResult>;
async fn exec_with_env(&mut self, cmd: &[&str], env: &[String]) -> Result<ExecResult> {
if !env.is_empty() {
eprintln!(
"Warning: This backend doesn't support environment variables, ignoring {} var(s)",
env.len()
);
}
self.exec(cmd).await
}
async fn exec_with_options(&mut self, cmd: &[&str], opts: &ExecOptions) -> Result<ExecResult> {
if opts.workdir.is_some() || opts.user.is_some() {
eprintln!("Warning: This backend doesn't support workdir/user options, ignoring");
}
self.exec_with_env(cmd, &opts.env).await
}
async fn stop(&mut self) -> Result<()>;
async fn resize(&mut self, _vcpus: u32, _memory_mb: u64) -> Result<bool> {
Ok(false)
}
fn name(&self) -> &str;
fn backend_type(&self) -> BackendType;
fn is_running(&self) -> bool;
async fn write_file(&mut self, path: &str, content: &[u8]) -> Result<()> {
validate_sandbox_path(path)?;
self.write_file_unchecked(path, content).await
}
async fn write_file_unchecked(&mut self, path: &str, content: &[u8]) -> Result<()>;
async fn read_file(&mut self, path: &str) -> Result<Vec<u8>> {
validate_sandbox_path(path)?;
self.read_file_unchecked(path).await
}
async fn read_file_unchecked(&mut self, path: &str) -> Result<Vec<u8>>;
async fn remove_file(&mut self, path: &str) -> Result<()> {
validate_sandbox_path(path)?;
self.remove_file_unchecked(path).await
}
async fn remove_file_unchecked(&mut self, path: &str) -> Result<()>;
async fn mkdir(&mut self, path: &str, recursive: bool) -> Result<()> {
validate_sandbox_path(path)?;
self.mkdir_unchecked(path, recursive).await
}
async fn mkdir_unchecked(&mut self, path: &str, recursive: bool) -> Result<()>;
async fn inject_files(&mut self, files: &[FileInjection]) -> Result<()> {
for file in files {
if let Some(parent) = std::path::Path::new(&file.dest).parent() {
let parent_str = parent.to_string_lossy();
if parent_str != "/" {
self.mkdir(&parent_str, true).await?;
}
}
self.write_file(&file.dest, &file.content).await?;
}
Ok(())
}
async fn attach(&mut self, shell: Option<&str>) -> Result<i32> {
let _ = shell;
anyhow::bail!("Interactive shell not supported by this backend")
}
async fn attach_with_env(&mut self, shell: Option<&str>, env: &[String]) -> Result<i32> {
if !env.is_empty() {
eprintln!(
"Warning: This backend doesn't support environment variables, ignoring {} var(s)",
env.len()
);
}
self.attach(shell).await
}
}
pub fn validate_sandbox_path(path: &str) -> Result<()> {
use anyhow::bail;
if !path.starts_with('/') {
bail!("Sandbox path must be absolute, got: {}", path);
}
if path.contains("..") {
bail!("Path traversal not allowed: {}", path);
}
const BLOCKED_PATHS: &[&str] = &[
"/proc",
"/sys",
"/dev",
"/etc/passwd",
"/etc/shadow",
"/etc/sudoers",
"/root/.ssh",
];
for blocked in BLOCKED_PATHS {
if path.starts_with(blocked) {
bail!("Cannot access system path: {}", path);
}
}
Ok(())
}
pub fn detect_best_backend() -> Option<BackendType> {
#[cfg(target_os = "linux")]
{
if std::path::Path::new("/dev/kvm").exists() {
if firecracker::firecracker_available() {
return Some(BackendType::Firecracker);
}
}
}
#[cfg(target_os = "macos")]
{
if apple::apple_containers_available() {
return Some(BackendType::Apple);
}
}
if docker::podman_available() {
return Some(BackendType::Podman);
}
if docker::docker_available() {
return Some(BackendType::Docker);
}
None
}
pub fn backend_available(backend: BackendType) -> bool {
match backend {
BackendType::Docker => docker::docker_available(),
BackendType::Podman => docker::podman_available(),
BackendType::Firecracker => firecracker::firecracker_available(),
#[cfg(target_os = "macos")]
BackendType::Apple => apple::apple_containers_available(),
#[cfg(not(target_os = "macos"))]
BackendType::Apple => false,
BackendType::Hyperlight => hyperlight::hyperlight_available(),
#[cfg(feature = "kubernetes")]
BackendType::Kubernetes => true,
#[cfg(not(feature = "kubernetes"))]
BackendType::Kubernetes => false,
#[cfg(feature = "nomad")]
BackendType::Nomad => true,
#[cfg(not(feature = "nomad"))]
BackendType::Nomad => false,
}
}
pub fn create_sandbox(backend: BackendType, name: &str) -> Result<Box<dyn Sandbox>> {
create_sandbox_with_config(backend, name, &crate::config::OrchestratorConfig::default())
}
pub fn create_sandbox_with_config(
backend: BackendType,
name: &str,
#[allow(unused_variables)] orch_config: &crate::config::OrchestratorConfig,
) -> Result<Box<dyn Sandbox>> {
match backend {
BackendType::Docker => Ok(Box::new(DockerSandbox::new_persistent(
name,
ContainerRuntime::Docker,
))),
BackendType::Podman => Ok(Box::new(DockerSandbox::new_persistent(
name,
ContainerRuntime::Podman,
))),
BackendType::Firecracker => Ok(Box::new(FirecrackerSandbox::new(name)?)),
#[cfg(target_os = "macos")]
BackendType::Apple => Ok(Box::new(AppleSandbox::new_persistent(name))),
#[cfg(not(target_os = "macos"))]
BackendType::Apple => anyhow::bail!("Apple Containers only available on macOS"),
BackendType::Hyperlight => Ok(Box::new(HyperlightSandbox::new(name))),
#[cfg(feature = "kubernetes")]
BackendType::Kubernetes => Ok(Box::new(KubernetesSandbox::new(name, orch_config))),
#[cfg(not(feature = "kubernetes"))]
BackendType::Kubernetes => {
anyhow::bail!("Kubernetes backend not compiled. Rebuild with --features kubernetes")
}
#[cfg(feature = "nomad")]
BackendType::Nomad => Ok(Box::new(NomadSandbox::new(name, orch_config))),
#[cfg(not(feature = "nomad"))]
BackendType::Nomad => {
anyhow::bail!("Nomad backend not compiled. Rebuild with --features nomad")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_backend_type_display() {
assert_eq!(format!("{}", BackendType::Docker), "docker");
assert_eq!(format!("{}", BackendType::Podman), "podman");
assert_eq!(format!("{}", BackendType::Firecracker), "firecracker");
assert_eq!(format!("{}", BackendType::Apple), "apple");
assert_eq!(format!("{}", BackendType::Hyperlight), "hyperlight");
assert_eq!(format!("{}", BackendType::Kubernetes), "kubernetes");
assert_eq!(format!("{}", BackendType::Nomad), "nomad");
}
#[test]
fn test_backend_type_from_str() {
assert_eq!(
"docker".parse::<BackendType>().unwrap(),
BackendType::Docker
);
assert_eq!(
"podman".parse::<BackendType>().unwrap(),
BackendType::Podman
);
assert_eq!(
"firecracker".parse::<BackendType>().unwrap(),
BackendType::Firecracker
);
assert_eq!("apple".parse::<BackendType>().unwrap(), BackendType::Apple);
assert_eq!(
"hyperlight".parse::<BackendType>().unwrap(),
BackendType::Hyperlight
);
assert_eq!(
"kubernetes".parse::<BackendType>().unwrap(),
BackendType::Kubernetes
);
assert_eq!(
"k8s".parse::<BackendType>().unwrap(),
BackendType::Kubernetes
);
assert_eq!("nomad".parse::<BackendType>().unwrap(), BackendType::Nomad);
}
#[test]
fn test_backend_type_from_str_case_insensitive() {
assert_eq!(
"DOCKER".parse::<BackendType>().unwrap(),
BackendType::Docker
);
assert_eq!(
"Docker".parse::<BackendType>().unwrap(),
BackendType::Docker
);
assert_eq!(
"PODMAN".parse::<BackendType>().unwrap(),
BackendType::Podman
);
}
#[test]
fn test_backend_type_from_str_invalid() {
assert!("invalid".parse::<BackendType>().is_err());
assert!("".parse::<BackendType>().is_err());
assert!("dock".parse::<BackendType>().is_err());
}
#[test]
fn test_backend_type_serialize() {
let backend = BackendType::Docker;
let json = serde_json::to_string(&backend).unwrap();
assert_eq!(json, "\"Docker\"");
}
#[test]
fn test_backend_type_deserialize() {
let backend: BackendType = serde_json::from_str("\"Podman\"").unwrap();
assert_eq!(backend, BackendType::Podman);
}
#[test]
fn test_sandbox_config_default() {
let config = SandboxConfig::default();
assert_eq!(config.image, "alpine:3.20");
assert_eq!(config.vcpus, 1);
assert_eq!(config.memory_mb, 512);
assert!(!config.mount_cwd);
assert!(config.work_dir.is_none());
assert!(config.env.is_empty());
assert!(config.network);
assert!(!config.read_only);
assert!(!config.mount_home);
assert!(config.files.is_empty());
assert!(config.ports.is_empty());
assert!(config.ssh.is_none());
}
#[test]
fn test_sandbox_config_with_image() {
let config = SandboxConfig::with_image("python:3.12-alpine");
assert_eq!(config.image, "python:3.12-alpine");
assert_eq!(config.vcpus, 1);
assert_eq!(config.memory_mb, 512);
}
#[test]
fn test_sandbox_config_builder() {
let config = SandboxConfig::with_image("node:20")
.with_resources(4, 2048)
.with_network(false)
.with_mount_cwd(true, Some("/workspace".to_string()))
.with_env(vec![("NODE_ENV".to_string(), "production".to_string())]);
assert_eq!(config.image, "node:20");
assert_eq!(config.vcpus, 4);
assert_eq!(config.memory_mb, 2048);
assert!(!config.network);
assert!(config.mount_cwd);
assert_eq!(config.work_dir, Some("/workspace".to_string()));
assert_eq!(config.env.len(), 1);
assert_eq!(
config.env[0],
("NODE_ENV".to_string(), "production".to_string())
);
}
#[test]
fn test_exec_result_success() {
let result = ExecResult::success("hello world".to_string());
assert!(result.is_success());
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "hello world");
assert!(result.stderr.is_empty());
}
#[test]
fn test_exec_result_failure() {
let result = ExecResult::failure(1, "error message".to_string());
assert!(!result.is_success());
assert_eq!(result.exit_code, 1);
assert!(result.stdout.is_empty());
assert_eq!(result.stderr, "error message");
}
#[test]
fn test_exec_result_output_stdout_only() {
let result = ExecResult {
exit_code: 0,
stdout: "stdout output".to_string(),
stderr: String::new(),
};
assert_eq!(result.output(), "stdout output");
}
#[test]
fn test_exec_result_output_stderr_only() {
let result = ExecResult {
exit_code: 1,
stdout: String::new(),
stderr: "stderr output".to_string(),
};
assert_eq!(result.output(), "stderr output");
}
#[test]
fn test_exec_result_output_combined() {
let result = ExecResult {
exit_code: 0,
stdout: "stdout".to_string(),
stderr: "stderr".to_string(),
};
assert_eq!(result.output(), "stdout\nstderr");
}
#[test]
fn test_validate_sandbox_path_valid() {
assert!(validate_sandbox_path("/home/user/file.txt").is_ok());
assert!(validate_sandbox_path("/workspace/project/src/main.rs").is_ok());
assert!(validate_sandbox_path("/tmp/test").is_ok());
assert!(validate_sandbox_path("/app/data.json").is_ok());
}
#[test]
fn test_validate_sandbox_path_relative() {
assert!(validate_sandbox_path("relative/path").is_err());
assert!(validate_sandbox_path("./file.txt").is_err());
assert!(validate_sandbox_path("file.txt").is_err());
}
#[test]
fn test_validate_sandbox_path_traversal() {
assert!(validate_sandbox_path("/home/../etc/passwd").is_err());
assert!(validate_sandbox_path("/workspace/..").is_err());
assert!(validate_sandbox_path("/../root").is_err());
}
#[test]
fn test_validate_sandbox_path_blocked_paths() {
assert!(validate_sandbox_path("/proc/1/cmdline").is_err());
assert!(validate_sandbox_path("/sys/kernel").is_err());
assert!(validate_sandbox_path("/dev/null").is_err());
assert!(validate_sandbox_path("/etc/passwd").is_err());
assert!(validate_sandbox_path("/etc/shadow").is_err());
assert!(validate_sandbox_path("/etc/sudoers").is_err());
assert!(validate_sandbox_path("/root/.ssh/id_rsa").is_err());
}
#[test]
fn test_validate_sandbox_path_similar_but_allowed() {
assert!(validate_sandbox_path("/etc/hosts").is_ok());
assert!(validate_sandbox_path("/home/root/.ssh").is_ok());
assert!(validate_sandbox_path("/myproc/data").is_ok());
}
#[test]
fn test_file_injection_creation() {
let injection = FileInjection {
content: b"hello world".to_vec(),
dest: "/app/config.txt".to_string(),
};
assert_eq!(injection.content, b"hello world");
assert_eq!(injection.dest, "/app/config.txt");
}
#[test]
fn test_sandbox_config_with_files() {
let files = vec![
FileInjection {
content: b"content1".to_vec(),
dest: "/app/file1.txt".to_string(),
},
FileInjection {
content: b"content2".to_vec(),
dest: "/app/file2.txt".to_string(),
},
];
let config = SandboxConfig::default().with_files(files);
assert_eq!(config.files.len(), 2);
}
#[test]
fn test_port_mapping_parse_host_container() {
let pm = PortMapping::parse("8080:80").unwrap();
assert_eq!(pm.host_port, Some(8080));
assert_eq!(pm.container_port, 80);
assert_eq!(pm.protocol, PortProtocol::Tcp);
}
#[test]
fn test_port_mapping_parse_container_only() {
let pm = PortMapping::parse("3000").unwrap();
assert_eq!(pm.host_port, None);
assert_eq!(pm.container_port, 3000);
assert_eq!(pm.protocol, PortProtocol::Tcp);
}
#[test]
fn test_port_mapping_parse_udp() {
let pm = PortMapping::parse("5353:53/udp").unwrap();
assert_eq!(pm.host_port, Some(5353));
assert_eq!(pm.container_port, 53);
assert_eq!(pm.protocol, PortProtocol::Udp);
}
#[test]
fn test_port_mapping_parse_explicit_tcp() {
let pm = PortMapping::parse("8080:80/tcp").unwrap();
assert_eq!(pm.host_port, Some(8080));
assert_eq!(pm.container_port, 80);
assert_eq!(pm.protocol, PortProtocol::Tcp);
}
#[test]
fn test_port_mapping_parse_invalid_host() {
assert!(PortMapping::parse("abc:80").is_err());
}
#[test]
fn test_port_mapping_parse_invalid_container() {
assert!(PortMapping::parse("8080:abc").is_err());
}
#[test]
fn test_port_mapping_parse_invalid_single() {
assert!(PortMapping::parse("not-a-port").is_err());
}
#[test]
fn test_port_mapping_display() {
assert_eq!(
format!(
"{}",
PortMapping {
host_port: Some(8080),
container_port: 80,
protocol: PortProtocol::Tcp
}
),
"8080:80"
);
assert_eq!(
format!(
"{}",
PortMapping {
host_port: None,
container_port: 3000,
protocol: PortProtocol::Tcp
}
),
"3000"
);
assert_eq!(
format!(
"{}",
PortMapping {
host_port: Some(5353),
container_port: 53,
protocol: PortProtocol::Udp
}
),
"5353:53/udp"
);
}
#[test]
fn test_port_mapping_serialize_roundtrip() {
let pm = PortMapping::parse("8080:80").unwrap();
let json = serde_json::to_string(&pm).unwrap();
let pm2: PortMapping = serde_json::from_str(&json).unwrap();
assert_eq!(pm, pm2);
}
#[test]
fn test_sandbox_config_with_ports() {
let ports = vec![
PortMapping::parse("8080:80").unwrap(),
PortMapping::parse("3000").unwrap(),
];
let config = SandboxConfig::default().with_ports(ports);
assert_eq!(config.ports.len(), 2);
assert_eq!(config.ports[0].container_port, 80);
assert_eq!(config.ports[1].container_port, 3000);
}
}