vfa_tui/subprocess/
executor.rs1use 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
14const MAX_LINE_LENGTH: usize = 4096;
16
17pub struct SubprocessExecutor;
19
20impl SubprocessExecutor {
21 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 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 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 if stdout_tx.try_send(output_line).is_err() {
88 break;
89 }
90 }
91 });
92
93 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 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 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 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}