use anyhow::Result;
use std::process::{Command, Stdio};
use std::time::Duration;
#[derive(Debug, Clone)]
pub struct CommandResult {
pub stdout: String,
pub stderr: String,
pub exit_code: Option<i32>,
pub timed_out: bool,
pub duration: Duration,
}
impl CommandResult {
pub fn success(&self) -> bool {
self.exit_code == Some(0) && !self.timed_out
}
}
pub trait SandboxedCommand: Send + Sync {
fn execute(&self) -> Result<CommandResult>;
fn display(&self) -> String;
fn is_read_only(&self) -> bool;
}
pub struct BasicSandbox {
program: String,
args: Vec<String>,
working_dir: Option<String>,
timeout: Option<Duration>,
}
impl BasicSandbox {
pub fn new(program: String, args: Vec<String>) -> Self {
Self {
program,
args,
working_dir: None,
timeout: Some(Duration::from_secs(60)), }
}
pub fn with_working_dir(mut self, dir: String) -> Self {
self.working_dir = Some(dir);
self
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
pub fn from_command_string(cmd: &str) -> Result<Self> {
let parts = shell_words::split(cmd)?;
if parts.is_empty() {
anyhow::bail!("Empty command");
}
Ok(Self::new(parts[0].clone(), parts[1..].to_vec()))
}
}
impl SandboxedCommand for BasicSandbox {
fn execute(&self) -> Result<CommandResult> {
let start = std::time::Instant::now();
let mut cmd = Command::new(&self.program);
cmd.args(&self.args)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(ref dir) = self.working_dir {
cmd.current_dir(dir);
}
let mut child = cmd.spawn()?;
if let Some(timeout) = self.timeout {
let deadline = start + timeout;
loop {
match child.try_wait() {
Ok(Some(_status)) => {
break;
}
Ok(None) => {
if std::time::Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait(); let duration = start.elapsed();
return Ok(CommandResult {
stdout: String::new(),
stderr: format!(
"Process killed after {}s timeout",
timeout.as_secs()
),
exit_code: None,
timed_out: true,
duration,
});
}
std::thread::sleep(Duration::from_millis(50));
}
Err(e) => {
return Err(e.into());
}
}
}
}
let output = child.wait_with_output()?;
let duration = start.elapsed();
Ok(CommandResult {
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
exit_code: output.status.code(),
timed_out: false,
duration,
})
}
fn display(&self) -> String {
if self.args.is_empty() {
self.program.clone()
} else {
format!("{} {}", self.program, self.args.join(" "))
}
}
fn is_read_only(&self) -> bool {
let read_only_programs = [
"ls",
"cat",
"head",
"tail",
"grep",
"find",
"which",
"echo",
"pwd",
"whoami",
"date",
"env",
"printenv",
"file",
"stat",
"cargo check",
"cargo build",
"cargo test",
"cargo clippy",
"git status",
"git log",
"git diff",
"git show",
];
let full_cmd = self.display();
read_only_programs.iter().any(|p| full_cmd.starts_with(p))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_sandbox_echo() {
let sandbox = BasicSandbox::new("echo".to_string(), vec!["hello".to_string()]);
let result = sandbox.execute().unwrap();
assert!(result.success());
assert_eq!(result.stdout.trim(), "hello");
}
#[test]
fn test_from_command_string() {
let sandbox = BasicSandbox::from_command_string("ls -la /tmp").unwrap();
assert_eq!(sandbox.program, "ls");
assert_eq!(sandbox.args, vec!["-la", "/tmp"]);
}
#[test]
fn test_display() {
let sandbox = BasicSandbox::new(
"cargo".to_string(),
vec!["build".to_string(), "--release".to_string()],
);
assert_eq!(sandbox.display(), "cargo build --release");
}
#[test]
fn test_is_read_only() {
let sandbox = BasicSandbox::new("ls".to_string(), vec!["-la".to_string()]);
assert!(sandbox.is_read_only());
let sandbox = BasicSandbox::new("rm".to_string(), vec!["file.txt".to_string()]);
assert!(!sandbox.is_read_only());
}
#[cfg(unix)]
#[test]
fn test_basic_sandbox_with_working_dir() {
let temp = std::env::temp_dir();
let sandbox = BasicSandbox::new("pwd".to_string(), vec![])
.with_working_dir(temp.to_string_lossy().to_string());
let result = sandbox.execute().unwrap();
assert!(result.success());
let output_path = std::path::PathBuf::from(result.stdout.trim());
let expected = std::fs::canonicalize(&temp).unwrap();
let actual = std::fs::canonicalize(&output_path).unwrap();
assert_eq!(
actual, expected,
"pwd should match the specified working dir"
);
}
#[cfg(windows)]
#[test]
fn test_basic_sandbox_with_working_dir() {
let unique = format!("perspt_test_{}", std::process::id());
let dir = std::env::temp_dir().join(&unique);
std::fs::create_dir_all(&dir).unwrap();
let sandbox =
BasicSandbox::new("cmd".to_string(), vec!["/C".to_string(), "cd".to_string()])
.with_working_dir(dir.to_string_lossy().to_string());
let result = sandbox.execute().unwrap();
let _ = std::fs::remove_dir(&dir);
assert!(result.success(), "cmd /C cd should succeed");
let output = result.stdout.trim();
assert!(
output.ends_with(&unique),
"working dir output should end with our unique dir name, got: {output}"
);
}
#[test]
fn test_basic_sandbox_captures_stderr() {
let sandbox = BasicSandbox::new(
"sh".to_string(),
vec!["-c".to_string(), "echo err >&2".to_string()],
);
let result = sandbox.execute().unwrap();
assert!(result.success());
assert!(
result.stderr.contains("err"),
"stderr should capture error output"
);
}
#[test]
fn test_basic_sandbox_nonzero_exit() {
let sandbox = BasicSandbox::new("false".to_string(), vec![]);
let result = sandbox.execute().unwrap();
assert!(!result.success());
assert_eq!(result.exit_code, Some(1));
assert!(!result.timed_out);
}
#[test]
fn test_basic_sandbox_timeout_fast_command_succeeds() {
let sandbox = BasicSandbox::new("echo".to_string(), vec!["fast".to_string()])
.with_timeout(Duration::from_secs(60));
let result = sandbox.execute().unwrap();
assert!(!result.timed_out);
assert!(result.success());
}
#[test]
fn test_from_command_string_empty_rejected() {
let result = BasicSandbox::from_command_string("");
assert!(result.is_err(), "Empty command should be rejected");
}
#[test]
fn test_from_command_string_with_quotes() {
let sandbox = BasicSandbox::from_command_string(r#"echo "hello world""#).unwrap();
assert_eq!(sandbox.program, "echo");
assert_eq!(sandbox.args, vec!["hello world"]);
}
#[test]
fn test_display_no_args() {
let sandbox = BasicSandbox::new("pwd".to_string(), vec![]);
assert_eq!(sandbox.display(), "pwd");
}
#[test]
fn test_is_read_only_compound_commands() {
let sandbox = BasicSandbox::new("cargo".to_string(), vec!["check".to_string()]);
assert!(sandbox.is_read_only());
let sandbox = BasicSandbox::new("cargo".to_string(), vec!["test".to_string()]);
assert!(sandbox.is_read_only());
let sandbox = BasicSandbox::new("git".to_string(), vec!["status".to_string()]);
assert!(sandbox.is_read_only());
let sandbox = BasicSandbox::new("git".to_string(), vec!["push".to_string()]);
assert!(!sandbox.is_read_only());
}
#[test]
fn test_command_result_duration_nonzero() {
let sandbox = BasicSandbox::new("echo".to_string(), vec!["hi".to_string()]);
let result = sandbox.execute().unwrap();
assert!(result.duration.as_nanos() > 0);
}
#[test]
fn test_active_timeout_kills_process() {
let sandbox = BasicSandbox::new("sleep".to_string(), vec!["30".to_string()])
.with_timeout(Duration::from_millis(200));
let result = sandbox.execute().unwrap();
assert!(
result.timed_out,
"Process should have been killed by timeout"
);
assert!(!result.success());
assert!(
result.duration < Duration::from_secs(5),
"Should return quickly after kill, not wait 30s"
);
}
}