1use 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 mut child = cmd.spawn()?;
109
110 if let Some(timeout) = self.timeout {
112 let deadline = start + timeout;
113 loop {
114 match child.try_wait() {
115 Ok(Some(_status)) => {
116 break;
118 }
119 Ok(None) => {
120 if std::time::Instant::now() >= deadline {
122 let _ = child.kill();
124 let _ = child.wait(); let duration = start.elapsed();
126 return Ok(CommandResult {
127 stdout: String::new(),
128 stderr: format!(
129 "Process killed after {}s timeout",
130 timeout.as_secs()
131 ),
132 exit_code: None,
133 timed_out: true,
134 duration,
135 });
136 }
137 std::thread::sleep(Duration::from_millis(50));
139 }
140 Err(e) => {
141 return Err(e.into());
142 }
143 }
144 }
145 }
146
147 let output = child.wait_with_output()?;
148 let duration = start.elapsed();
149
150 Ok(CommandResult {
151 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
152 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
153 exit_code: output.status.code(),
154 timed_out: false,
155 duration,
156 })
157 }
158
159 fn display(&self) -> String {
160 if self.args.is_empty() {
161 self.program.clone()
162 } else {
163 format!("{} {}", self.program, self.args.join(" "))
164 }
165 }
166
167 fn is_read_only(&self) -> bool {
168 let read_only_programs = [
170 "ls",
171 "cat",
172 "head",
173 "tail",
174 "grep",
175 "find",
176 "which",
177 "echo",
178 "pwd",
179 "whoami",
180 "date",
181 "env",
182 "printenv",
183 "file",
184 "stat",
185 "cargo check",
186 "cargo build",
187 "cargo test",
188 "cargo clippy",
189 "git status",
190 "git log",
191 "git diff",
192 "git show",
193 ];
194
195 let full_cmd = self.display();
196 read_only_programs.iter().any(|p| full_cmd.starts_with(p))
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203
204 #[test]
205 fn test_basic_sandbox_echo() {
206 let sandbox = BasicSandbox::new("echo".to_string(), vec!["hello".to_string()]);
207 let result = sandbox.execute().unwrap();
208 assert!(result.success());
209 assert_eq!(result.stdout.trim(), "hello");
210 }
211
212 #[test]
213 fn test_from_command_string() {
214 let sandbox = BasicSandbox::from_command_string("ls -la /tmp").unwrap();
215 assert_eq!(sandbox.program, "ls");
216 assert_eq!(sandbox.args, vec!["-la", "/tmp"]);
217 }
218
219 #[test]
220 fn test_display() {
221 let sandbox = BasicSandbox::new(
222 "cargo".to_string(),
223 vec!["build".to_string(), "--release".to_string()],
224 );
225 assert_eq!(sandbox.display(), "cargo build --release");
226 }
227
228 #[test]
229 fn test_is_read_only() {
230 let sandbox = BasicSandbox::new("ls".to_string(), vec!["-la".to_string()]);
231 assert!(sandbox.is_read_only());
232
233 let sandbox = BasicSandbox::new("rm".to_string(), vec!["file.txt".to_string()]);
234 assert!(!sandbox.is_read_only());
235 }
236
237 #[cfg(unix)]
242 #[test]
243 fn test_basic_sandbox_with_working_dir() {
244 let temp = std::env::temp_dir();
245 let sandbox = BasicSandbox::new("pwd".to_string(), vec![])
246 .with_working_dir(temp.to_string_lossy().to_string());
247 let result = sandbox.execute().unwrap();
248 assert!(result.success());
249 let output_path = std::path::PathBuf::from(result.stdout.trim());
252 let expected = std::fs::canonicalize(&temp).unwrap();
253 let actual = std::fs::canonicalize(&output_path).unwrap();
254 assert_eq!(
255 actual, expected,
256 "pwd should match the specified working dir"
257 );
258 }
259
260 #[cfg(windows)]
261 #[test]
262 fn test_basic_sandbox_with_working_dir() {
263 let unique = format!("perspt_test_{}", std::process::id());
267 let dir = std::env::temp_dir().join(&unique);
268 std::fs::create_dir_all(&dir).unwrap();
269 let sandbox =
270 BasicSandbox::new("cmd".to_string(), vec!["/C".to_string(), "cd".to_string()])
271 .with_working_dir(dir.to_string_lossy().to_string());
272 let result = sandbox.execute().unwrap();
273 let _ = std::fs::remove_dir(&dir);
274 assert!(result.success(), "cmd /C cd should succeed");
275 let output = result.stdout.trim();
276 assert!(
277 output.ends_with(&unique),
278 "working dir output should end with our unique dir name, got: {output}"
279 );
280 }
281
282 #[test]
283 fn test_basic_sandbox_captures_stderr() {
284 let sandbox = BasicSandbox::new(
285 "sh".to_string(),
286 vec!["-c".to_string(), "echo err >&2".to_string()],
287 );
288 let result = sandbox.execute().unwrap();
289 assert!(result.success());
290 assert!(
291 result.stderr.contains("err"),
292 "stderr should capture error output"
293 );
294 }
295
296 #[test]
297 fn test_basic_sandbox_nonzero_exit() {
298 let sandbox = BasicSandbox::new("false".to_string(), vec![]);
299 let result = sandbox.execute().unwrap();
300 assert!(!result.success());
301 assert_eq!(result.exit_code, Some(1));
302 assert!(!result.timed_out);
303 }
304
305 #[test]
306 fn test_basic_sandbox_timeout_fast_command_succeeds() {
307 let sandbox = BasicSandbox::new("echo".to_string(), vec!["fast".to_string()])
309 .with_timeout(Duration::from_secs(60));
310 let result = sandbox.execute().unwrap();
311 assert!(!result.timed_out);
312 assert!(result.success());
313 }
314
315 #[test]
316 fn test_from_command_string_empty_rejected() {
317 let result = BasicSandbox::from_command_string("");
318 assert!(result.is_err(), "Empty command should be rejected");
319 }
320
321 #[test]
322 fn test_from_command_string_with_quotes() {
323 let sandbox = BasicSandbox::from_command_string(r#"echo "hello world""#).unwrap();
324 assert_eq!(sandbox.program, "echo");
325 assert_eq!(sandbox.args, vec!["hello world"]);
326 }
327
328 #[test]
329 fn test_display_no_args() {
330 let sandbox = BasicSandbox::new("pwd".to_string(), vec![]);
331 assert_eq!(sandbox.display(), "pwd");
332 }
333
334 #[test]
335 fn test_is_read_only_compound_commands() {
336 let sandbox = BasicSandbox::new("cargo".to_string(), vec!["check".to_string()]);
338 assert!(sandbox.is_read_only());
339
340 let sandbox = BasicSandbox::new("cargo".to_string(), vec!["test".to_string()]);
342 assert!(sandbox.is_read_only());
343
344 let sandbox = BasicSandbox::new("git".to_string(), vec!["status".to_string()]);
346 assert!(sandbox.is_read_only());
347
348 let sandbox = BasicSandbox::new("git".to_string(), vec!["push".to_string()]);
350 assert!(!sandbox.is_read_only());
351 }
352
353 #[test]
354 fn test_command_result_duration_nonzero() {
355 let sandbox = BasicSandbox::new("echo".to_string(), vec!["hi".to_string()]);
356 let result = sandbox.execute().unwrap();
357 assert!(result.duration.as_nanos() > 0);
359 }
360
361 #[test]
362 fn test_active_timeout_kills_process() {
363 let sandbox = BasicSandbox::new("sleep".to_string(), vec!["30".to_string()])
365 .with_timeout(Duration::from_millis(200));
366
367 let result = sandbox.execute().unwrap();
368 assert!(
369 result.timed_out,
370 "Process should have been killed by timeout"
371 );
372 assert!(!result.success());
373 assert!(
374 result.duration < Duration::from_secs(5),
375 "Should return quickly after kill, not wait 30s"
376 );
377 }
378}