perspt-sandbox 0.6.2

Sandboxed command execution for Perspt
Documentation
//! Sandboxed Command Execution
//!
//! Provides a trait and implementation for executing commands with sandboxing.

use anyhow::Result;
use std::process::{Command, Stdio};
use std::time::Duration;

/// Result of a sandboxed command execution
#[derive(Debug, Clone)]
pub struct CommandResult {
    /// Standard output
    pub stdout: String,
    /// Standard error output
    pub stderr: String,
    /// Exit status
    pub exit_code: Option<i32>,
    /// Whether the command timed out
    pub timed_out: bool,
    /// Execution duration
    pub duration: Duration,
}

impl CommandResult {
    /// Check if the command succeeded
    pub fn success(&self) -> bool {
        self.exit_code == Some(0) && !self.timed_out
    }
}

/// Trait for sandboxed command execution
///
/// This trait abstracts command execution to allow different sandboxing
/// implementations (basic, Docker, Landlock, etc.)
pub trait SandboxedCommand: Send + Sync {
    /// Execute the command and return the result
    fn execute(&self) -> Result<CommandResult>;

    /// Get the command string for display
    fn display(&self) -> String;

    /// Check if the command is read-only (no side effects)
    fn is_read_only(&self) -> bool;
}

/// Basic sandboxed command wrapper
///
/// Phase 1 implementation: Executes commands directly but with
/// output capture and timeout support.
pub struct BasicSandbox {
    /// The program to execute
    program: String,
    /// Command arguments
    args: Vec<String>,
    /// Working directory
    working_dir: Option<String>,
    /// Timeout for execution
    timeout: Option<Duration>,
}

impl BasicSandbox {
    /// Create a new basic sandbox
    pub fn new(program: String, args: Vec<String>) -> Self {
        Self {
            program,
            args,
            working_dir: None,
            timeout: Some(Duration::from_secs(60)), // Default 60s timeout
        }
    }

    /// Set the working directory
    pub fn with_working_dir(mut self, dir: String) -> Self {
        self.working_dir = Some(dir);
        self
    }

    /// Set the timeout
    pub fn with_timeout(mut self, timeout: Duration) -> Self {
        self.timeout = Some(timeout);
        self
    }

    /// Parse a command string into program and args
    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()?;

        // Active timeout: poll child with a deadline, kill if exceeded
        if let Some(timeout) = self.timeout {
            let deadline = start + timeout;
            loop {
                match child.try_wait() {
                    Ok(Some(_status)) => {
                        // Process exited normally
                        break;
                    }
                    Ok(None) => {
                        // Still running — check deadline
                        if std::time::Instant::now() >= deadline {
                            // Kill the process
                            let _ = child.kill();
                            let _ = child.wait(); // reap zombie
                            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,
                            });
                        }
                        // Brief sleep to avoid busy-waiting
                        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 {
        // Commands that are generally read-only
        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());
    }

    // =========================================================================
    // Baseline regression tests — freeze pre-refactor behavior
    // =========================================================================

    #[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());
        // The working_dir setting should be respected; the pwd output
        // should resolve to the same directory we specified.
        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() {
        // Use a uniquely-named subdirectory so we can verify the working dir
        // by name alone, avoiding junction/symlink resolution mismatches
        // (e.g. C:\Users\...\Temp junction → D:\tmp on CI runners).
        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() {
        // A fast command with a generous timeout should complete normally.
        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() {
        // cargo check should be read-only
        let sandbox = BasicSandbox::new("cargo".to_string(), vec!["check".to_string()]);
        assert!(sandbox.is_read_only());

        // cargo test should be read-only
        let sandbox = BasicSandbox::new("cargo".to_string(), vec!["test".to_string()]);
        assert!(sandbox.is_read_only());

        // git status should be read-only
        let sandbox = BasicSandbox::new("git".to_string(), vec!["status".to_string()]);
        assert!(sandbox.is_read_only());

        // git push should NOT be 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();
        // Duration should be non-zero (process was actually spawned)
        assert!(result.duration.as_nanos() > 0);
    }

    #[test]
    fn test_active_timeout_kills_process() {
        // Start a long-running sleep and verify the sandbox kills it
        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"
        );
    }
}