perspt_sandbox/
command.rs1use anyhow::Result;
6use std::process::{Command, Stdio};
7use std::time::Duration;
8
9#[derive(Debug, Clone)]
11pub struct CommandResult {
12 pub stdout: String,
14 pub stderr: String,
16 pub exit_code: Option<i32>,
18 pub timed_out: bool,
20 pub duration: Duration,
22}
23
24impl CommandResult {
25 pub fn success(&self) -> bool {
27 self.exit_code == Some(0) && !self.timed_out
28 }
29}
30
31pub trait SandboxedCommand: Send + Sync {
36 fn execute(&self) -> Result<CommandResult>;
38
39 fn display(&self) -> String;
41
42 fn is_read_only(&self) -> bool;
44}
45
46pub struct BasicSandbox {
51 program: String,
53 args: Vec<String>,
55 working_dir: Option<String>,
57 timeout: Option<Duration>,
59}
60
61impl BasicSandbox {
62 pub fn new(program: String, args: Vec<String>) -> Self {
64 Self {
65 program,
66 args,
67 working_dir: None,
68 timeout: Some(Duration::from_secs(60)), }
70 }
71
72 pub fn with_working_dir(mut self, dir: String) -> Self {
74 self.working_dir = Some(dir);
75 self
76 }
77
78 pub fn with_timeout(mut self, timeout: Duration) -> Self {
80 self.timeout = Some(timeout);
81 self
82 }
83
84 pub fn from_command_string(cmd: &str) -> Result<Self> {
86 let parts = shell_words::split(cmd)?;
87 if parts.is_empty() {
88 anyhow::bail!("Empty command");
89 }
90
91 Ok(Self::new(parts[0].clone(), parts[1..].to_vec()))
92 }
93}
94
95impl SandboxedCommand for BasicSandbox {
96 fn execute(&self) -> Result<CommandResult> {
97 let start = std::time::Instant::now();
98
99 let mut cmd = Command::new(&self.program);
100 cmd.args(&self.args)
101 .stdout(Stdio::piped())
102 .stderr(Stdio::piped());
103
104 if let Some(ref dir) = self.working_dir {
105 cmd.current_dir(dir);
106 }
107
108 let output = cmd.output()?;
109 let duration = start.elapsed();
110
111 let timed_out = self.timeout.is_some_and(|t| duration > t);
113
114 Ok(CommandResult {
115 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
116 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
117 exit_code: output.status.code(),
118 timed_out,
119 duration,
120 })
121 }
122
123 fn display(&self) -> String {
124 if self.args.is_empty() {
125 self.program.clone()
126 } else {
127 format!("{} {}", self.program, self.args.join(" "))
128 }
129 }
130
131 fn is_read_only(&self) -> bool {
132 let read_only_programs = [
134 "ls",
135 "cat",
136 "head",
137 "tail",
138 "grep",
139 "find",
140 "which",
141 "echo",
142 "pwd",
143 "whoami",
144 "date",
145 "env",
146 "printenv",
147 "file",
148 "stat",
149 "cargo check",
150 "cargo build",
151 "cargo test",
152 "cargo clippy",
153 "git status",
154 "git log",
155 "git diff",
156 "git show",
157 ];
158
159 let full_cmd = self.display();
160 read_only_programs.iter().any(|p| full_cmd.starts_with(p))
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 #[test]
169 fn test_basic_sandbox_echo() {
170 let sandbox = BasicSandbox::new("echo".to_string(), vec!["hello".to_string()]);
171 let result = sandbox.execute().unwrap();
172 assert!(result.success());
173 assert_eq!(result.stdout.trim(), "hello");
174 }
175
176 #[test]
177 fn test_from_command_string() {
178 let sandbox = BasicSandbox::from_command_string("ls -la /tmp").unwrap();
179 assert_eq!(sandbox.program, "ls");
180 assert_eq!(sandbox.args, vec!["-la", "/tmp"]);
181 }
182
183 #[test]
184 fn test_display() {
185 let sandbox = BasicSandbox::new(
186 "cargo".to_string(),
187 vec!["build".to_string(), "--release".to_string()],
188 );
189 assert_eq!(sandbox.display(), "cargo build --release");
190 }
191
192 #[test]
193 fn test_is_read_only() {
194 let sandbox = BasicSandbox::new("ls".to_string(), vec!["-la".to_string()]);
195 assert!(sandbox.is_read_only());
196
197 let sandbox = BasicSandbox::new("rm".to_string(), vec!["file.txt".to_string()]);
198 assert!(!sandbox.is_read_only());
199 }
200}