1use crate::error::Result;
4use std::collections::HashMap;
5use std::process::Stdio;
6use tokio::io::{AsyncBufReadExt, BufReader};
7use tokio::process::{Child, Command};
8use tokio::time::{timeout, Duration, Instant};
9
10#[derive(Debug, Clone)]
12pub struct CommandOptions {
13 pub timeout_seconds: Option<u64>,
14 pub truncate_after: Option<usize>,
15 pub working_directory: Option<String>,
16 pub environment: HashMap<String, String>,
17 pub capture_stderr: bool,
18 pub shell: Option<String>,
19}
20
21impl Default for CommandOptions {
22 fn default() -> Self {
23 Self {
24 timeout_seconds: Some(120),
25 truncate_after: Some(16000),
26 working_directory: None,
27 environment: HashMap::new(),
28 capture_stderr: true,
29 shell: Some("/bin/bash".to_string()),
30 }
31 }
32}
33
34#[derive(Debug, Clone)]
36pub struct CommandResult {
37 pub exit_code: i32,
38 pub stdout: String,
39 pub stderr: String,
40 pub duration_ms: u64,
41 pub timed_out: bool,
42 pub truncated: bool,
43}
44
45pub async fn execute_command(command: &str, options: CommandOptions) -> Result<CommandResult> {
47 let start_time = Instant::now();
48
49 let mut cmd = if let Some(shell) = &options.shell {
50 let mut cmd = Command::new(shell);
51 cmd.arg("-c").arg(command);
52 cmd
53 } else {
54 let parts: Vec<&str> = command.split_whitespace().collect();
56 if parts.is_empty() {
57 return Err("Empty command".into());
58 }
59
60 let mut cmd = Command::new(parts[0]);
61 if parts.len() > 1 {
62 cmd.args(&parts[1..]);
63 }
64 cmd
65 };
66
67 if let Some(working_dir) = &options.working_directory {
69 cmd.current_dir(working_dir);
70 }
71
72 for (key, value) in &options.environment {
74 cmd.env(key, value);
75 }
76
77 cmd.stdin(Stdio::piped()).stdout(Stdio::piped());
79
80 if options.capture_stderr {
81 cmd.stderr(Stdio::piped());
82 } else {
83 cmd.stderr(Stdio::inherit());
84 }
85
86 let mut child = cmd.spawn()?;
87
88 let timeout_duration = Duration::from_secs(options.timeout_seconds.unwrap_or(120));
90 let result = timeout(timeout_duration, async {
91 execute_child(&mut child, options.capture_stderr).await
92 })
93 .await;
94
95 let duration = start_time.elapsed();
96
97 match result {
98 Ok(Ok((exit_code, stdout, stderr))) => {
99 let truncate_limit = options.truncate_after.unwrap_or(16000);
100 let (stdout_truncated, stdout_final) = truncate_output(&stdout, truncate_limit);
101 let (stderr_truncated, stderr_final) = truncate_output(&stderr, truncate_limit);
102
103 Ok(CommandResult {
104 exit_code,
105 stdout: stdout_final,
106 stderr: stderr_final,
107 duration_ms: duration.as_millis() as u64,
108 timed_out: false,
109 truncated: stdout_truncated || stderr_truncated,
110 })
111 }
112 Ok(Err(e)) => Err(e),
113 Err(_) => {
114 let _ = child.kill().await;
116
117 Ok(CommandResult {
118 exit_code: -1,
119 stdout: String::new(),
120 stderr: format!(
121 "Command timed out after {} seconds",
122 timeout_duration.as_secs()
123 ),
124 duration_ms: duration.as_millis() as u64,
125 timed_out: true,
126 truncated: false,
127 })
128 }
129 }
130}
131
132async fn execute_child(child: &mut Child, capture_stderr: bool) -> Result<(i32, String, String)> {
134 let stdout = child.stdout.take().ok_or("Failed to capture stdout")?;
135 let stderr = if capture_stderr {
136 child.stderr.take()
137 } else {
138 None
139 };
140
141 let mut stdout_reader = BufReader::new(stdout);
142 let mut stdout_lines = Vec::new();
143 let mut stderr_lines = Vec::new();
144
145 let stdout_task = async {
147 let mut line = String::new();
148 while stdout_reader.read_line(&mut line).await? > 0 {
149 stdout_lines.push(line.clone());
150 line.clear();
151 }
152 Ok::<(), std::io::Error>(())
153 };
154
155 let stderr_task = async {
157 if let Some(stderr) = stderr {
158 let mut stderr_reader = BufReader::new(stderr);
159 let mut line = String::new();
160 while stderr_reader.read_line(&mut line).await? > 0 {
161 stderr_lines.push(line.clone());
162 line.clear();
163 }
164 }
165 Ok::<(), std::io::Error>(())
166 };
167
168 let (stdout_result, stderr_result) = tokio::join!(stdout_task, stderr_task);
170 stdout_result?;
171 stderr_result?;
172
173 let status = child.wait().await?;
175 let exit_code = status.code().unwrap_or(-1);
176
177 let stdout_output = stdout_lines.join("");
178 let stderr_output = stderr_lines.join("");
179
180 Ok((exit_code, stdout_output, stderr_output))
181}
182
183fn truncate_output(output: &str, limit: usize) -> (bool, String) {
185 if output.len() <= limit {
186 (false, output.to_string())
187 } else {
188 let truncated = format!(
189 "{}\n\n<output truncated after {} characters>\n\
190 <NOTE>To see the full output, increase the truncate_after limit or \
191 redirect output to a file.</NOTE>",
192 &output[..limit],
193 limit
194 );
195 (true, truncated)
196 }
197}
198
199pub async fn stream_command(
201 command: &str,
202 options: CommandOptions,
203 mut output_handler: impl FnMut(&str) -> Result<()>,
204) -> Result<CommandResult> {
205 let start_time = Instant::now();
206
207 let mut cmd = if let Some(shell) = &options.shell {
208 let mut cmd = Command::new(shell);
209 cmd.arg("-c").arg(command);
210 cmd
211 } else {
212 let parts: Vec<&str> = command.split_whitespace().collect();
213 if parts.is_empty() {
214 return Err("Empty command".into());
215 }
216
217 let mut cmd = Command::new(parts[0]);
218 if parts.len() > 1 {
219 cmd.args(&parts[1..]);
220 }
221 cmd
222 };
223
224 if let Some(working_dir) = &options.working_directory {
225 cmd.current_dir(working_dir);
226 }
227
228 for (key, value) in &options.environment {
229 cmd.env(key, value);
230 }
231
232 cmd.stdin(Stdio::piped())
233 .stdout(Stdio::piped())
234 .stderr(Stdio::piped());
235
236 let mut child = cmd.spawn()?;
237
238 let stdout = child.stdout.take().ok_or("Failed to capture stdout")?;
239 let stderr = child.stderr.take().ok_or("Failed to capture stderr")?;
240
241 let mut stdout_reader = BufReader::new(stdout);
242 let mut stderr_reader = BufReader::new(stderr);
243
244 let mut all_output = String::new();
245 let mut exit_code = 0;
246 let mut timed_out = false;
247
248 let timeout_duration = Duration::from_secs(options.timeout_seconds.unwrap_or(120));
249 let result = timeout(timeout_duration, async {
250 let mut stdout_line = String::new();
251 let mut stderr_line = String::new();
252
253 loop {
254 tokio::select! {
255 result = stdout_reader.read_line(&mut stdout_line) => {
256 match result {
257 Ok(0) => break, Ok(_) => {
259 output_handler(&stdout_line)?;
260 all_output.push_str(&stdout_line);
261 stdout_line.clear();
262 }
263 Err(e) => return Err(e.into()),
264 }
265 }
266 result = stderr_reader.read_line(&mut stderr_line) => {
267 match result {
268 Ok(0) => {}, Ok(_) => {
270 output_handler(&stderr_line)?;
271 all_output.push_str(&stderr_line);
272 stderr_line.clear();
273 }
274 Err(e) => return Err(e.into()),
275 }
276 }
277 status = child.wait() => {
278 exit_code = status?.code().unwrap_or(-1);
279 break;
280 }
281 }
282 }
283
284 Ok::<(), crate::error::Error>(())
285 })
286 .await;
287
288 let duration = start_time.elapsed();
289
290 match result {
291 Ok(Ok(())) => {}
292 Ok(Err(e)) => return Err(e),
293 Err(_) => {
294 let _ = child.kill().await;
295 timed_out = true;
296 exit_code = -1;
297 }
298 }
299
300 let truncate_limit = options.truncate_after.unwrap_or(16000);
301 let (truncated, final_output) = truncate_output(&all_output, truncate_limit);
302
303 Ok(CommandResult {
304 exit_code,
305 stdout: final_output,
306 stderr: String::new(), duration_ms: duration.as_millis() as u64,
308 timed_out,
309 truncated,
310 })
311}
312
313pub fn validate_command_safety(command: &str) -> Result<()> {
315 let dangerous_patterns = [
316 "rm -rf /",
317 ":(){ :|:& };:", "dd if=/dev/zero",
319 "mkfs.",
320 "format ",
321 "> /dev/",
322 "chmod 777 /",
323 "chown root /",
324 ];
325
326 let command_lower = command.to_lowercase();
327 for pattern in &dangerous_patterns {
328 if command_lower.contains(pattern) {
329 return Err(format!("Potentially dangerous command detected: {}", pattern).into());
330 }
331 }
332
333 Ok(())
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339
340 #[tokio::test]
341 async fn test_simple_command() {
342 let options = CommandOptions::default();
343 let result = execute_command("echo 'Hello, World!'", options)
344 .await
345 .unwrap();
346
347 assert_eq!(result.exit_code, 0);
348 assert!(result.stdout.contains("Hello, World!"));
349 assert!(!result.timed_out);
350 }
351
352 #[tokio::test]
353 async fn test_command_timeout() {
354 let options = CommandOptions {
355 timeout_seconds: Some(1),
356 ..Default::default()
357 };
358
359 let result = execute_command("sleep 5", options).await.unwrap();
360
361 assert!(result.timed_out);
362 assert_eq!(result.exit_code, -1);
363 }
364
365 #[test]
366 fn test_output_truncation() {
367 let long_output = "a".repeat(20000);
368 let (truncated, output) = truncate_output(&long_output, 1000);
369
370 assert!(truncated);
371 assert!(output.len() > 1000); assert!(output.contains("output truncated"));
373 }
374
375 #[test]
376 fn test_command_safety_validation() {
377 assert!(validate_command_safety("echo hello").is_ok());
378 assert!(validate_command_safety("rm -rf /").is_err());
379 assert!(validate_command_safety("dd if=/dev/zero of=/dev/sda").is_err());
380 }
381}