nomoreide_core/
exec_file.rs1use std::process::Stdio;
15use std::time::Duration;
16use tokio::process::Command;
17
18pub struct ExecOptions<'a> {
19 pub timeout: Duration,
20 pub max_buffer: usize,
25 pub cwd: Option<&'a str>,
26}
27
28#[derive(Debug)]
29pub struct ExecOutput {
30 pub stdout: Vec<u8>,
31 pub stderr: Vec<u8>,
32}
33
34pub struct ExecAttempt {
38 pub output: ExecOutput,
39 pub failure: Option<String>,
40}
41
42pub async fn exec_file_capturing(
49 argv: &[String],
50 options: &ExecOptions<'_>,
51) -> Result<ExecAttempt, String> {
52 let (program, args) = argv.split_first().ok_or("no command")?;
53 let mut command = Command::new(program);
54 command
55 .args(args)
56 .stdin(Stdio::null())
57 .stdout(Stdio::piped())
58 .stderr(Stdio::piped());
59 if let Some(cwd) = options.cwd.filter(|value| !value.is_empty()) {
60 command.current_dir(cwd);
61 }
62 let child = command
63 .spawn()
64 .map_err(|error| format!("spawn {program} {}", errno_name(&error)))?;
65 let output = tokio::time::timeout(options.timeout, child.wait_with_output())
66 .await
67 .map_err(|_| format!("Command failed: {}", argv.join(" ")))?
68 .map_err(|error| error.to_string())?;
69
70 if output.stdout.len() > options.max_buffer || output.stderr.len() > options.max_buffer {
71 return Err("stdout maxBuffer length exceeded".to_string());
72 }
73 let failure = (!output.status.success()).then(|| {
74 format!(
75 "Command failed: {}\n{}",
76 argv.join(" "),
77 String::from_utf8_lossy(&output.stderr)
78 )
79 });
80 Ok(ExecAttempt {
81 output: ExecOutput {
82 stdout: output.stdout,
83 stderr: output.stderr,
84 },
85 failure,
86 })
87}
88
89pub async fn exec_file(argv: &[String], options: &ExecOptions<'_>) -> Result<ExecOutput, String> {
91 let attempt = exec_file_capturing(argv, options).await?;
92 match attempt.failure {
93 Some(failure) => Err(failure),
94 None => Ok(attempt.output),
95 }
96}
97
98fn errno_name(error: &std::io::Error) -> &'static str {
101 match error.kind() {
102 std::io::ErrorKind::NotFound => "ENOENT",
103 std::io::ErrorKind::PermissionDenied => "EACCES",
104 _ => "EIO",
105 }
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111
112 fn options() -> ExecOptions<'static> {
113 ExecOptions {
114 timeout: Duration::from_secs(10),
115 max_buffer: 1024 * 1024,
116 cwd: None,
117 }
118 }
119
120 #[tokio::test]
121 async fn a_failure_quotes_the_whole_command_and_then_stderr() {
122 let argv = vec![
123 "sh".to_string(),
124 "-c".to_string(),
125 "printf 'went wrong\n' >&2; exit 3".to_string(),
126 ];
127 let failure = exec_file(&argv, &options()).await.unwrap_err();
128 assert_eq!(
129 failure,
130 "Command failed: sh -c printf 'went wrong\n' >&2; exit 3\nwent wrong\n"
131 );
132 }
133
134 #[tokio::test]
135 async fn output_comes_back_as_bytes() {
136 let argv = vec![
137 "sh".to_string(),
138 "-c".to_string(),
139 r"printf 'a\0b'".to_string(),
140 ];
141 let output = exec_file(&argv, &options()).await.unwrap();
142 assert_eq!(output.stdout, vec![b'a', 0, b'b']);
143 }
144}