use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use thiserror::Error;
use tracing::info;
#[allow(dead_code)]
#[derive(Error, Debug)]
pub enum SandboxError {
#[error("Path '{0}' is outside the sandbox")]
PathOutsideSandbox(String),
#[error("Sandbox not initialized: {0}")]
NotInitialized(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Resource limit exceeded: {0}")]
ResourceLimit(String),
#[error("Network access denied by sandbox")]
NetworkDenied,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SandboxConfig {
#[serde(default = "default_workdir")]
pub workdir: String,
#[serde(default = "default_timeout")]
pub timeout_secs: u64,
#[serde(default = "default_max_output")]
pub max_output_bytes: usize,
#[serde(default = "default_max_write")]
pub max_write_bytes: usize,
#[serde(default)]
pub allow_network: bool,
#[serde(default)]
pub allowed_env_prefixes: Vec<String>,
#[serde(default = "default_true")]
pub create_workdir: bool,
}
impl Default for SandboxConfig {
fn default() -> Self {
Self {
workdir: default_workdir(),
timeout_secs: default_timeout(),
max_output_bytes: default_max_output(),
max_write_bytes: default_max_write(),
allow_network: false,
allowed_env_prefixes: vec![
"PATH".to_string(),
"HOME".to_string(),
"USER".to_string(),
"TMPDIR".to_string(),
"RAVENCLAWS_".to_string(),
],
create_workdir: true,
}
}
}
fn default_workdir() -> String {
"/tmp/ravenclaws-sandbox".to_string()
}
fn default_timeout() -> u64 {
30
}
fn default_max_output() -> usize {
65536
}
fn default_max_write() -> usize {
1048576
}
fn default_true() -> bool {
true
}
#[allow(dead_code)]
pub struct Sandbox {
config: SandboxConfig,
workdir: PathBuf,
initialized: bool,
}
#[allow(dead_code)]
impl Sandbox {
pub fn new(config: SandboxConfig) -> Self {
let workdir = PathBuf::from(&config.workdir);
Self {
config,
workdir,
initialized: false,
}
}
pub fn new_default() -> Self {
Self::new(SandboxConfig::default())
}
}
impl Default for Sandbox {
fn default() -> Self {
Self::new(SandboxConfig::default())
}
}
#[allow(dead_code)]
impl Sandbox {
pub async fn init(&mut self) -> Result<(), SandboxError> {
if self.config.create_workdir {
tokio::fs::create_dir_all(&self.workdir).await?;
info!(workdir = %self.workdir.display(), "Sandbox initialized");
}
self.initialized = true;
Ok(())
}
pub fn is_initialized(&self) -> bool {
self.initialized
}
pub fn workdir(&self) -> &Path {
&self.workdir
}
#[allow(dead_code)]
pub fn config(&self) -> &SandboxConfig {
&self.config
}
pub fn resolve_path(&self, path: &str) -> Result<PathBuf, SandboxError> {
if !self.initialized {
return Err(SandboxError::NotInitialized(
"Sandbox must be initialized before resolving paths".to_string(),
));
}
let requested = Path::new(path);
let resolved = if requested.is_absolute() {
match requested.canonicalize() {
Ok(p) => p,
Err(_) => {
let components: Vec<_> = requested.components().collect();
let mut p = PathBuf::new();
for c in components {
p.push(c);
}
p
}
}
} else {
self.workdir.join(requested)
};
if !resolved.starts_with(&self.workdir) {
let temp_dirs = [
std::env::temp_dir(),
PathBuf::from("/tmp"),
PathBuf::from("/var/tmp"),
];
let in_temp = temp_dirs.iter().any(|d| resolved.starts_with(d));
if !in_temp {
return Err(SandboxError::PathOutsideSandbox(
resolved.to_string_lossy().to_string(),
));
}
}
Ok(resolved)
}
pub fn check_read_path(&self, path: &str) -> Result<PathBuf, SandboxError> {
let resolved = self.resolve_path(path)?;
if !resolved.exists() {
return Err(SandboxError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("Path does not exist: {}", resolved.display()),
)));
}
Ok(resolved)
}
pub fn check_write_path(&self, path: &str) -> Result<PathBuf, SandboxError> {
let resolved = self.resolve_path(path)?;
Ok(resolved)
}
pub fn check_network(&self) -> Result<(), SandboxError> {
if !self.config.allow_network {
return Err(SandboxError::NetworkDenied);
}
Ok(())
}
pub fn filtered_env(&self) -> Vec<(String, String)> {
std::env::vars()
.filter(|(key, _)| {
self.config
.allowed_env_prefixes
.iter()
.any(|prefix| key.starts_with(prefix))
})
.collect()
}
pub async fn cleanup(&mut self) -> Result<(), SandboxError> {
if self.initialized && self.workdir.exists() {
tokio::fs::remove_dir_all(&self.workdir).await?;
info!(workdir = %self.workdir.display(), "Sandbox cleaned up");
}
self.initialized = false;
Ok(())
}
pub async fn create_temp_file(
&self,
prefix: &str,
suffix: &str,
) -> Result<PathBuf, SandboxError> {
if !self.initialized {
return Err(SandboxError::NotInitialized(
"Sandbox must be initialized before creating temp files".to_string(),
));
}
let uuid = uuid::Uuid::new_v4();
let filename = format!("{}_{}{}", prefix, uuid, suffix);
let path = self.workdir.join(&filename);
tokio::fs::write(&path, "").await?;
Ok(path)
}
}
impl Drop for Sandbox {
fn drop(&mut self) {
if self.initialized && self.workdir.exists() {
let _ = std::fs::remove_dir_all(&self.workdir);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_sandbox_init() {
let dir =
std::env::temp_dir().join(format!("ravenclaws_sandbox_init_{}", std::process::id()));
let config = SandboxConfig {
workdir: dir.to_string_lossy().to_string(),
..SandboxConfig::default()
};
let mut sandbox = Sandbox::new(config);
assert!(!sandbox.is_initialized());
sandbox.init().await.unwrap();
assert!(sandbox.is_initialized());
assert!(sandbox.workdir().exists());
sandbox.cleanup().await.unwrap();
assert!(!sandbox.is_initialized());
}
#[tokio::test]
async fn test_sandbox_resolve_relative_path() {
let dir =
std::env::temp_dir().join(format!("ravenclaws_sandbox_rel_{}", std::process::id()));
let config = SandboxConfig {
workdir: dir.to_string_lossy().to_string(),
create_workdir: true,
..SandboxConfig::default()
};
let mut sandbox = Sandbox::new(config);
sandbox.init().await.unwrap();
let resolved = sandbox.resolve_path("test.txt").unwrap();
assert!(resolved.starts_with(&dir));
assert!(resolved.ends_with("test.txt"));
sandbox.cleanup().await.unwrap();
}
#[tokio::test]
async fn test_sandbox_resolve_absolute_path_in_sandbox() {
let dir =
std::env::temp_dir().join(format!("ravenclaws_sandbox_abs_{}", std::process::id()));
let config = SandboxConfig {
workdir: dir.to_string_lossy().to_string(),
create_workdir: true,
..SandboxConfig::default()
};
let mut sandbox = Sandbox::new(config);
sandbox.init().await.unwrap();
let test_path = dir.join("subdir").join("file.txt");
let resolved = sandbox
.resolve_path(test_path.to_string_lossy().as_ref())
.unwrap();
assert!(resolved.starts_with(&dir));
sandbox.cleanup().await.unwrap();
}
#[tokio::test]
async fn test_sandbox_resolve_path_outside() {
let dir =
std::env::temp_dir().join(format!("ravenclaws_sandbox_outside_{}", std::process::id()));
let config = SandboxConfig {
workdir: dir.to_string_lossy().to_string(),
create_workdir: true,
..SandboxConfig::default()
};
let mut sandbox = Sandbox::new(config);
sandbox.init().await.unwrap();
let result = sandbox.resolve_path("/etc/passwd");
if let Err(e) = result {
assert!(matches!(e, SandboxError::PathOutsideSandbox(_)));
}
sandbox.cleanup().await.unwrap();
}
#[tokio::test]
async fn test_sandbox_not_initialized() {
let config = SandboxConfig::default();
let sandbox = Sandbox::new(config);
let result = sandbox.resolve_path("test.txt");
assert!(matches!(
result.unwrap_err(),
SandboxError::NotInitialized(_)
));
}
#[tokio::test]
async fn test_sandbox_check_read_path_not_found() {
let dir =
std::env::temp_dir().join(format!("ravenclaws_sandbox_read_{}", std::process::id()));
let config = SandboxConfig {
workdir: dir.to_string_lossy().to_string(),
create_workdir: true,
..SandboxConfig::default()
};
let mut sandbox = Sandbox::new(config);
sandbox.init().await.unwrap();
let result = sandbox.check_read_path("nonexistent_file.txt");
assert!(result.is_err());
sandbox.cleanup().await.unwrap();
}
#[tokio::test]
async fn test_sandbox_check_network_allowed() {
let config = SandboxConfig {
allow_network: true,
..SandboxConfig::default()
};
let sandbox = Sandbox::new(config);
assert!(sandbox.check_network().is_ok());
}
#[tokio::test]
async fn test_sandbox_check_network_denied() {
let config = SandboxConfig {
allow_network: false,
..SandboxConfig::default()
};
let sandbox = Sandbox::new(config);
let result = sandbox.check_network();
assert!(matches!(result.unwrap_err(), SandboxError::NetworkDenied));
}
#[tokio::test]
async fn test_sandbox_filtered_env() {
let config = SandboxConfig::default();
let sandbox = Sandbox::new(config);
let env = sandbox.filtered_env();
assert!(env.iter().any(|(k, _)| k == "PATH"));
assert!(!env.iter().any(|(k, _)| k == "AWS_SECRET_ACCESS_KEY"));
}
#[tokio::test]
async fn test_sandbox_create_temp_file() {
let dir =
std::env::temp_dir().join(format!("ravenclaws_sandbox_temp_{}", std::process::id()));
let config = SandboxConfig {
workdir: dir.to_string_lossy().to_string(),
create_workdir: true,
..SandboxConfig::default()
};
let mut sandbox = Sandbox::new(config);
sandbox.init().await.unwrap();
let path = sandbox.create_temp_file("test", ".txt").await.unwrap();
assert!(path.exists());
assert!(path.starts_with(&dir));
tokio::fs::remove_file(&path).await.unwrap();
sandbox.cleanup().await.unwrap();
}
#[tokio::test]
async fn test_sandbox_create_temp_file_not_initialized() {
let config = SandboxConfig::default();
let sandbox = Sandbox::new(config);
let result = sandbox.create_temp_file("test", ".txt").await;
assert!(matches!(
result.unwrap_err(),
SandboxError::NotInitialized(_)
));
}
#[test]
fn test_sandbox_config_default() {
let config = SandboxConfig::default();
assert_eq!(config.workdir, "/tmp/ravenclaws-sandbox");
assert_eq!(config.timeout_secs, 30);
assert_eq!(config.max_output_bytes, 65536);
assert!(!config.allow_network);
assert!(config.create_workdir);
}
#[test]
fn test_sandbox_error_path_outside() {
let err = SandboxError::PathOutsideSandbox("/etc".to_string());
assert_eq!(format!("{}", err), "Path '/etc' is outside the sandbox");
}
#[test]
fn test_sandbox_error_not_initialized() {
let err = SandboxError::NotInitialized("not ready".to_string());
assert_eq!(format!("{}", err), "Sandbox not initialized: not ready");
}
#[test]
fn test_sandbox_error_resource_limit() {
let err = SandboxError::ResourceLimit("too big".to_string());
assert_eq!(format!("{}", err), "Resource limit exceeded: too big");
}
#[test]
fn test_sandbox_error_network_denied() {
let err = SandboxError::NetworkDenied;
assert_eq!(format!("{}", err), "Network access denied by sandbox");
}
#[test]
fn test_sandbox_drop_cleanup() {
let dir = std::env::temp_dir().join(format!(
"ravenclaws_sandbox_drop_test_{}",
std::process::id()
));
let config = SandboxConfig {
workdir: dir.to_string_lossy().to_string(),
create_workdir: true,
..SandboxConfig::default()
};
{
let mut sandbox = Sandbox::new(config);
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(sandbox.init()).unwrap();
assert!(dir.exists());
}
}
#[tokio::test]
async fn test_sandbox_check_write_path() {
let dir =
std::env::temp_dir().join(format!("ravenclaws_sandbox_write_{}", std::process::id()));
let config = SandboxConfig {
workdir: dir.to_string_lossy().to_string(),
create_workdir: true,
..SandboxConfig::default()
};
let mut sandbox = Sandbox::new(config);
sandbox.init().await.unwrap();
let result = sandbox.check_write_path("new_file.txt");
assert!(result.is_ok());
sandbox.cleanup().await.unwrap();
}
#[test]
fn test_sandbox_config_serialization() {
let config = SandboxConfig::default();
let json = serde_json::to_string(&config).unwrap();
assert!(json.contains("/tmp/ravenclaws-sandbox"));
assert!(json.contains("30"));
}
#[test]
fn test_sandbox_config_deserialization() {
let json = r#"{
"workdir": "/custom/sandbox",
"timeout_secs": 60,
"allow_network": true
}"#;
let config: SandboxConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.workdir, "/custom/sandbox");
assert_eq!(config.timeout_secs, 60);
assert!(config.allow_network);
}
}