Skip to main content

vfa_tui/subprocess/
executor.rs

1use std::path::Path;
2use std::process::Stdio;
3use std::time::{Duration, Instant};
4
5use tokio::io::{AsyncBufReadExt, BufReader};
6use tokio::process::Command;
7use tokio::sync::mpsc;
8
9use crate::security::redact::sanitized_child_env;
10
11use super::stream::{OutputLine, OutputStream};
12use super::SubprocessHandle;
13
14/// Maximum length of a single output line in bytes. Lines exceeding this are truncated.
15const MAX_LINE_LENGTH: usize = 4096;
16
17/// Spawns subprocesses without using a shell.
18pub struct SubprocessExecutor;
19
20impl SubprocessExecutor {
21    /// Spawn a subprocess with the given command, arguments, working directory, and timeout.
22    ///
23    /// The subprocess inherits a sanitized environment (secrets stripped) and captures
24    /// both stdout and stderr as line-by-line streams.
25    ///
26    /// Note: Shell injection is prevented by using `Command::new` (no shell).
27    /// User-provided arguments should be validated via `ExportCommand::validate()`
28    /// before reaching this layer.
29    pub async fn spawn(
30        command: &str,
31        args: &[String],
32        working_dir: &Path,
33        timeout: Duration,
34    ) -> anyhow::Result<SubprocessHandle> {
35        let env = sanitized_child_env();
36
37        let mut cmd = Command::new(command);
38        cmd.args(args)
39            .current_dir(working_dir)
40            .env_clear()
41            .envs(env)
42            .kill_on_drop(true)
43            .stdout(Stdio::piped())
44            .stderr(Stdio::piped());
45
46        #[cfg(unix)]
47        cmd.process_group(0);
48
49        let mut child = cmd.spawn()?;
50
51        let stdout = child
52            .stdout
53            .take()
54            .ok_or_else(|| anyhow::anyhow!("stdout not captured from subprocess"))?;
55        let stderr = child
56            .stderr
57            .take()
58            .ok_or_else(|| anyhow::anyhow!("stderr not captured from subprocess"))?;
59
60        let (stdout_tx, stdout_rx) = mpsc::channel(10_000);
61        let (stderr_tx, stderr_rx) = mpsc::channel(10_000);
62
63        // Spawn stdout reader task
64        tokio::spawn(async move {
65            let reader = BufReader::new(stdout);
66            let mut lines = reader.lines();
67            while let Ok(Some(line)) = lines.next_line().await {
68                let content = if line.len() > MAX_LINE_LENGTH {
69                    // Find a valid UTF-8 boundary at or before MAX_LINE_LENGTH
70                    let mut end = MAX_LINE_LENGTH;
71                    while end > 0 && !line.is_char_boundary(end) {
72                        end -= 1;
73                    }
74                    let mut truncated = line[..end].to_string();
75                    truncated.push_str(" [truncated]");
76                    truncated
77                } else {
78                    line
79                };
80                let output_line = OutputLine {
81                    content,
82                    timestamp: Instant::now(),
83                    stream: OutputStream::Stdout,
84                };
85                // Use try_send to apply backpressure without blocking;
86                // if the buffer is full, drop the line (acceptable for TUI display).
87                if stdout_tx.try_send(output_line).is_err() {
88                    break;
89                }
90            }
91        });
92
93        // Spawn stderr reader task
94        tokio::spawn(async move {
95            let reader = BufReader::new(stderr);
96            let mut lines = reader.lines();
97            while let Ok(Some(line)) = lines.next_line().await {
98                let content = if line.len() > MAX_LINE_LENGTH {
99                    // Find a valid UTF-8 boundary at or before MAX_LINE_LENGTH
100                    let mut end = MAX_LINE_LENGTH;
101                    while end > 0 && !line.is_char_boundary(end) {
102                        end -= 1;
103                    }
104                    let mut truncated = line[..end].to_string();
105                    truncated.push_str(" [truncated]");
106                    truncated
107                } else {
108                    line
109                };
110                let output_line = OutputLine {
111                    content,
112                    timestamp: Instant::now(),
113                    stream: OutputStream::Stderr,
114                };
115                // Use try_send to apply backpressure without blocking;
116                // if the buffer is full, drop the line (acceptable for TUI display).
117                if stderr_tx.try_send(output_line).is_err() {
118                    break;
119                }
120            }
121        });
122
123        Ok(SubprocessHandle::new(child, stdout_rx, stderr_rx, timeout))
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use tempfile::TempDir;
131
132    #[tokio::test]
133    async fn spawn_echo_captures_stdout() {
134        let tmp = TempDir::new().unwrap();
135        let mut handle = SubprocessExecutor::spawn(
136            "echo",
137            &["hello world".to_string()],
138            tmp.path(),
139            Duration::from_secs(10),
140        )
141        .await
142        .unwrap();
143
144        let code = handle.wait().await.unwrap();
145        assert_eq!(code, 0);
146
147        // Give the reader a moment to deliver
148        tokio::time::sleep(Duration::from_millis(50)).await;
149
150        let mut lines = Vec::new();
151        while let Some(line) = handle.try_recv_stdout() {
152            lines.push(line.content);
153        }
154        assert!(lines.contains(&"hello world".to_string()));
155    }
156
157    #[tokio::test]
158    async fn spawn_false_returns_nonzero() {
159        let tmp = TempDir::new().unwrap();
160        let mut handle =
161            SubprocessExecutor::spawn("false", &[], tmp.path(), Duration::from_secs(10))
162                .await
163                .unwrap();
164
165        let code = handle.wait().await.unwrap();
166        assert_ne!(code, 0);
167    }
168
169    #[tokio::test]
170    async fn spawn_captures_stderr() {
171        let tmp = TempDir::new().unwrap();
172        let mut handle = SubprocessExecutor::spawn(
173            "sh",
174            &["-c".to_string(), "echo error >&2".to_string()],
175            tmp.path(),
176            Duration::from_secs(10),
177        )
178        .await
179        .unwrap();
180
181        let code = handle.wait().await.unwrap();
182        assert_eq!(code, 0);
183
184        tokio::time::sleep(Duration::from_millis(50)).await;
185
186        let mut lines = Vec::new();
187        while let Some(line) = handle.try_recv_stderr() {
188            lines.push(line.content);
189        }
190        assert!(lines.contains(&"error".to_string()));
191    }
192}