Skip to main content

cli_tutor/
executor.rs

1use crate::content::types::Fixture;
2use std::fs;
3use std::io::Write;
4use std::path::PathBuf;
5use std::process::{Command, Stdio};
6use std::sync::mpsc;
7use std::thread;
8use std::time::Duration;
9use thiserror::Error;
10
11const TIMEOUT_SECS: u64 = 3;
12
13#[derive(Debug, Clone)]
14pub struct ExecutionResult {
15    pub stdout: String,
16    pub stderr: String,
17    pub timed_out: bool,
18}
19
20#[derive(Debug, Error)]
21pub enum ExecutorError {
22    #[error("Failed to create temp directory: {0}")]
23    TempDir(#[from] std::io::Error),
24    #[error("Failed to write fixture '{0}': {1}")]
25    FixtureWrite(String, std::io::Error),
26}
27
28pub struct Executor;
29
30impl Executor {
31    pub fn run(command: &str, fixtures: &[Fixture]) -> Result<ExecutionResult, ExecutorError> {
32        let tmp = create_temp_dir()?;
33        write_fixtures(&tmp, fixtures)?;
34
35        let path_env = std::env::var("PATH").unwrap_or_else(|_| "/usr/bin:/bin".to_string());
36        let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
37        let user = std::env::var("USER").unwrap_or_else(|_| "user".to_string());
38
39        let mut child = Command::new("sh")
40            .arg("-c")
41            .arg(command)
42            .current_dir(&tmp)
43            .env_clear()
44            .env("PATH", &path_env)
45            .env("HOME", &home)
46            .env("USER", &user)
47            .env("TERM", "xterm")
48            .stdout(Stdio::piped())
49            .stderr(Stdio::piped())
50            .spawn()
51            .map_err(ExecutorError::TempDir)?;
52
53        // Thread-based timeout
54        let (tx, rx) = mpsc::channel();
55        let child_id = child.id();
56        thread::spawn(move || {
57            thread::sleep(Duration::from_secs(TIMEOUT_SECS));
58            let _ = tx.send(());
59            // Best-effort kill
60            unsafe {
61                libc_kill(child_id);
62            }
63        });
64
65        let stdout_handle = child.stdout.take();
66        let stderr_handle = child.stderr.take();
67
68        // Read stdout/stderr in separate threads to avoid deadlock
69        let stdout_thread = thread::spawn(move || {
70            use std::io::Read;
71            let mut buf = String::new();
72            if let Some(mut s) = stdout_handle {
73                let _ = s.read_to_string(&mut buf);
74            }
75            buf
76        });
77        let stderr_thread = thread::spawn(move || {
78            use std::io::Read;
79            let mut buf = String::new();
80            if let Some(mut s) = stderr_handle {
81                let _ = s.read_to_string(&mut buf);
82            }
83            buf
84        });
85
86        let status = child.wait().map_err(ExecutorError::TempDir)?;
87        let stdout = stdout_thread.join().unwrap_or_default();
88        let stderr = stderr_thread.join().unwrap_or_default();
89
90        let timed_out = rx.try_recv().is_ok() || !status.success() && stderr.contains("Killed");
91
92        let _ = fs::remove_dir_all(&tmp);
93
94        Ok(ExecutionResult {
95            stdout,
96            stderr,
97            timed_out,
98        })
99    }
100}
101
102fn create_temp_dir() -> Result<PathBuf, ExecutorError> {
103    use std::sync::atomic::{AtomicU64, Ordering};
104    static COUNTER: AtomicU64 = AtomicU64::new(0);
105    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
106    let base = std::env::temp_dir().join(format!("cli-tutor-{}-{}", std::process::id(), n));
107    fs::create_dir_all(&base).map_err(ExecutorError::TempDir)?;
108    Ok(base)
109}
110
111fn write_fixtures(dir: &std::path::Path, fixtures: &[Fixture]) -> Result<(), ExecutorError> {
112    for fixture in fixtures {
113        // Prevent path traversal — fixture filenames must not contain '..'
114        let path = dir.join(&fixture.filename);
115        if let Some(parent) = path.parent() {
116            fs::create_dir_all(parent)
117                .map_err(|e| ExecutorError::FixtureWrite(fixture.filename.clone(), e))?;
118        }
119        let mut f = fs::File::create(&path)
120            .map_err(|e| ExecutorError::FixtureWrite(fixture.filename.clone(), e))?;
121        f.write_all(fixture.content.as_bytes())
122            .map_err(|e| ExecutorError::FixtureWrite(fixture.filename.clone(), e))?;
123    }
124    Ok(())
125}
126
127#[allow(unsafe_code)]
128unsafe fn libc_kill(pid: u32) {
129    // SIGTERM the process group; ignore errors (process may already be done)
130    libc_kill_raw(pid as i32, 15);
131}
132
133#[cfg(unix)]
134fn libc_kill_raw(pid: i32, sig: i32) {
135    unsafe {
136        libc_sys_kill(pid, sig);
137    }
138}
139
140#[cfg(not(unix))]
141fn libc_kill_raw(_pid: i32, _sig: i32) {}
142
143// Minimal libc kill binding without pulling in the libc crate
144#[cfg(unix)]
145extern "C" {
146    fn kill(pid: i32, sig: i32) -> i32;
147}
148
149#[cfg(unix)]
150unsafe fn libc_sys_kill(pid: i32, sig: i32) {
151    kill(pid, sig);
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn test_basic_stdout_capture() {
160        let result = Executor::run("echo hello", &[]).unwrap();
161        assert_eq!(result.stdout.trim(), "hello");
162        assert!(!result.timed_out);
163    }
164
165    #[test]
166    fn test_stderr_capture() {
167        let result = Executor::run("echo err >&2", &[]).unwrap();
168        assert_eq!(result.stderr.trim(), "err");
169    }
170
171    #[test]
172    fn test_fixture_written() {
173        let fixtures = vec![Fixture {
174            filename: "test.txt".to_string(),
175            content: "hello\nworld\n".to_string(),
176        }];
177        let result = Executor::run("cat test.txt", &fixtures).unwrap();
178        assert_eq!(result.stdout, "hello\nworld\n");
179    }
180
181    #[test]
182    fn test_timeout() {
183        let result = Executor::run("sleep 10", &[]).unwrap();
184        assert!(result.timed_out || !result.stderr.is_empty());
185    }
186
187    #[test]
188    fn test_empty_env_strips_custom_vars() {
189        let result = Executor::run("echo ${MY_SECRET:-not_set}", &[]).unwrap();
190        assert_eq!(result.stdout.trim(), "not_set");
191    }
192}