1use anyhow::{Context, Result};
2use itertools::Itertools;
3use log::{
4 Level::{Debug, Info},
5 {debug, error, info, log_enabled, warn},
6};
7use regex::Regex;
8use std::{
9 collections::HashMap,
10 env, fs,
11 path::Path,
12 process,
13 sync::mpsc::{self, RecvTimeoutError},
14 thread::{self, JoinHandle},
15 time::Duration,
16};
17use thiserror::Error;
18use which::which;
19
20#[cfg(target_family = "unix")]
21use std::os::unix::prelude::*;
22
23#[derive(Debug, Error)]
24pub enum Error {
25 #[error(r#"Could not find "{exe:}" in your path ({path:}"#)]
26 ExecutableNotInPath { exe: String, path: String },
27
28 #[error(
29 "Got unexpected exit code {code:} from `{cmd:}`.{}",
30 exec_output_summary(stdout, stderr)
31 )]
32 UnexpectedExitCode {
33 cmd: String,
34 code: i32,
35 stdout: String,
36 stderr: String,
37 },
38
39 #[error("Ran `{cmd:}` and it was killed by signal {signal:}")]
40 ProcessKilledBySignal { cmd: String, signal: i32 },
41
42 #[error("Got unexpected stderr output from `{cmd:}` with exit code {code:}:\n{stderr:}")]
43 UnexpectedStderr {
44 cmd: String,
45 code: i32,
46 stderr: String,
47 },
48}
49
50fn exec_output_summary(stdout: &str, stderr: &str) -> String {
51 let mut output = if stdout.is_empty() {
52 String::from("\nStdout was empty.")
53 } else {
54 format!("\nStdout:\n{stdout}")
55 };
56 if stderr.is_empty() {
57 output.push_str("\nStderr was empty.");
58 } else {
59 output.push_str("\nStderr:\n");
60 output.push_str(stderr);
61 }
62 output.push('\n');
63 output
64}
65
66#[derive(Debug)]
67pub struct Output {
68 pub exit_code: i32,
69 pub stdout: Option<String>,
70 pub stderr: Option<String>,
71}
72
73#[allow(clippy::implicit_hasher, clippy::missing_errors_doc)]
74pub fn run(
75 exe: &str,
76 args: &[&str],
77 env: &HashMap<String, String>,
78 ok_exit_codes: &[i32],
79 ignore_stderr: Option<&[Regex]>,
80 in_dir: Option<&Path>,
81) -> Result<Output> {
82 if which(exe).is_err() {
83 let path = match env::var("PATH") {
84 Ok(p) => p,
85 Err(e) => format!("<could not get PATH environment variable: {e}>"),
86 };
87 return Err(Error::ExecutableNotInPath {
88 exe: exe.to_string(),
89 path,
90 }
91 .into());
92 }
93
94 let mut c = process::Command::new(exe);
95 for a in args {
96 c.arg(a);
97 }
98
99 let cwd = if let Some(d) = in_dir {
103 fs::canonicalize(d)?
104 } else {
105 fs::canonicalize(env::current_dir()?)?
106 };
107 c.current_dir(cwd.clone());
108
109 c.envs(env);
110
111 if log_enabled!(Debug) {
112 debug!(
113 "Running command [{}] with cwd = {}",
114 exec_string(exe, args),
115 cwd.display()
116 );
117 for k in env.keys().sorted() {
118 debug!(
119 r#" with env: {k} = "{}""#,
120 env.get(k).unwrap_or(&"<not UTF-8>".to_string()),
121 );
122 }
123 }
124
125 let output = output_from_command(c, ok_exit_codes, exe, args)
126 .with_context(|| format!(r"Failed to execute command `{}`", exec_string(exe, args)))?;
127
128 if log_enabled!(Debug) && !output.stdout.is_empty() {
129 debug!("Stdout was:\n{}", String::from_utf8(output.stdout.clone())?);
130 }
131
132 let code = output.status.code().unwrap_or(-1);
133 if !output.stderr.is_empty() {
134 let stderr = String::from_utf8(output.stderr.clone())?;
135 if log_enabled!(Debug) {
136 debug!("Stderr was:\n{stderr}");
137 }
138
139 let ok = if let Some(ignore) = ignore_stderr {
140 ignore.iter().any(|i| i.is_match(&stderr))
141 } else {
142 false
143 };
144 if !ok {
145 return Err(Error::UnexpectedStderr {
146 cmd: exec_string(exe, args),
147 code,
148 stderr,
149 }
150 .into());
151 }
152 }
153
154 Ok(Output {
155 exit_code: code,
156 stdout: to_option_string(&output.stdout),
157 stderr: to_option_string(&output.stderr),
158 })
159}
160
161fn output_from_command(
162 mut c: process::Command,
163 ok_exit_codes: &[i32],
164 exe: &str,
165 args: &[&str],
166) -> Result<process::Output> {
167 let estr = exec_string(exe, args);
168 let status = maybe_spawn_status_thread(estr.clone());
169
170 let output = c.output()?;
171 if let Some(code) = output.status.code() {
172 if let Some((sender, thread)) = status {
173 if let Err(err) = sender.send(ThreadMessage::Terminate) {
174 warn!("Error terminating background status thread: {err}");
175 }
176 if let Err(err) = thread.join() {
177 warn!("Error joining background status thread: {err:?}");
178 }
179 }
180
181 debug!("Ran [{}] and got exit code of {}", estr, code);
182 if !ok_exit_codes.contains(&code) {
183 return Err(Error::UnexpectedExitCode {
184 cmd: estr,
185 code,
186 stdout: String::from_utf8(output.stdout)?,
187 stderr: String::from_utf8(output.stderr)?,
188 }
189 .into());
190 }
191 } else if output.status.success() {
192 error!("Ran {} successfully but it had no exit code", estr);
193 } else {
194 let signal = signal_from_status(output.status);
195 debug!("Ran {} which exited because of signal {}", estr, signal);
196 return Err(Error::ProcessKilledBySignal { cmd: estr, signal }.into());
197 }
198
199 Ok(output)
200}
201
202enum ThreadMessage {
203 Terminate,
204}
205
206fn maybe_spawn_status_thread(
207 estr: String,
208) -> Option<(mpsc::Sender<ThreadMessage>, JoinHandle<()>)> {
209 if !log_enabled!(Info) {
210 return None;
211 }
212
213 let (sender, receiver) = mpsc::channel();
214 let handle = thread::spawn(move || loop {
215 match receiver.recv_timeout(Duration::from_secs(5)) {
216 Ok(ThreadMessage::Terminate) => {
217 break;
218 }
219 Err(RecvTimeoutError::Timeout) => {
220 info!("Still running [{estr}]");
221 }
222 Err(RecvTimeoutError::Disconnected) => {
223 warn!("Got a disconnected error receiving message from main thread");
224 break;
225 }
226 }
227 });
228
229 Some((sender, handle))
230}
231
232fn exec_string(exe: &str, args: &[&str]) -> String {
233 let mut estr = exe.to_string();
234 if !args.is_empty() {
235 estr.push(' ');
236 estr.push_str(args.join(" ").as_str());
237 }
238 estr
239}
240
241fn to_option_string(v: &[u8]) -> Option<String> {
242 if v.is_empty() {
243 None
244 } else {
245 Some(String::from_utf8_lossy(v).into_owned())
246 }
247}
248
249#[cfg(target_family = "unix")]
250fn signal_from_status(status: process::ExitStatus) -> i32 {
251 status.signal().unwrap_or(0)
252}
253
254#[cfg(target_family = "windows")]
255fn signal_from_status(_: process::ExitStatus) -> i32 {
256 0
257}
258
259#[cfg(test)]
260mod tests {
261 use super::Error;
262 use anyhow::{format_err, Result};
263 use pretty_assertions::assert_eq;
264 use regex::Regex;
265 use serial_test::{parallel, serial};
267 use std::{
268 collections::HashMap,
269 env, fs,
270 path::{Path, PathBuf},
271 };
272 use tempfile::tempdir;
273
274 #[test]
275 #[parallel]
276 fn exec_string() {
277 assert_eq!(
278 super::exec_string("foo", &[]),
279 String::from("foo"),
280 "command without args",
281 );
282 assert_eq!(
283 super::exec_string("foo", &["bar"],),
284 String::from("foo bar"),
285 "command with one arg"
286 );
287 assert_eq!(
288 super::exec_string("foo", &["--bar", "baz"],),
289 String::from("foo --bar baz"),
290 "command with multiple args",
291 );
292 }
293
294 #[test]
295 #[parallel]
296 fn run_exit_0() -> Result<()> {
297 let res = super::run("echo", &["foo"], &HashMap::new(), &[0], None, None)?;
298 assert_eq!(res.exit_code, 0, "process exits 0");
299
300 Ok(())
301 }
302
303 #[test]
304 #[parallel]
305 fn run_exit_0_with_unexpected_stderr() -> Result<()> {
306 let args = ["-c", "echo 'some stderr output' 1>&2"];
307 let res = super::run("sh", &args, &HashMap::new(), &[0], None, None);
308 assert!(res.is_err(), "run returned Err");
309 match error_from_run(res)? {
310 Error::UnexpectedStderr {
311 cmd: _,
312 code,
313 stderr,
314 } => {
315 assert_eq!(code, 0, "process exited 0");
316 assert_eq!(stderr, "some stderr output\n", "process had no stderr");
317 }
318 e => return Err(e.into()),
319 }
320 Ok(())
321 }
322
323 #[test]
324 #[parallel]
325 fn run_exit_0_with_matching_ignore_stderr() -> Result<()> {
326 let args = ["-c", "echo 'some stderr output' 1>&2"];
327 let res = super::run(
328 "sh",
329 &args,
330 &HashMap::new(),
331 &[0],
332 Some(&[Regex::new("some.+output").unwrap()]),
333 None,
334 )?;
335 assert_eq!(res.exit_code, 0, "process exits 0");
336 assert!(res.stdout.is_none(), "process has no stdout output");
337 assert_eq!(
338 res.stderr.unwrap(),
339 "some stderr output\n",
340 "process has stderr output",
341 );
342 Ok(())
343 }
344
345 #[test]
346 #[parallel]
347 fn run_exit_0_with_non_matching_ignore_stderr() -> Result<()> {
348 let args = ["-c", "echo 'some stderr output' 1>&2"];
349 let res = super::run(
350 "sh",
351 &args,
352 &HashMap::new(),
353 &[0],
354 Some(&[Regex::new("some.+output is ok").unwrap()]),
355 None,
356 );
357 assert!(res.is_err(), "run returned Err");
358 match error_from_run(res)? {
359 Error::UnexpectedStderr {
360 cmd: _,
361 code,
362 stderr,
363 } => {
364 assert_eq!(code, 0, "process exited 0");
365 assert_eq!(stderr, "some stderr output\n", "process had no stderr");
366 }
367 e => return Err(e.into()),
368 }
369 Ok(())
370 }
371
372 #[test]
373 #[parallel]
374 fn run_exit_0_with_multiple_ignore_stderr() -> Result<()> {
375 let args = ["-c", "echo 'some stderr output' 1>&2"];
376 let res = super::run(
377 "sh",
378 &args,
379 &HashMap::new(),
380 &[0],
381 Some(&[
382 Regex::new("will not match").unwrap(),
383 Regex::new("some.+output is ok").unwrap(),
384 ]),
385 None,
386 );
387 assert!(res.is_err(), "run returned Err");
388 match error_from_run(res)? {
389 Error::UnexpectedStderr {
390 cmd: _,
391 code,
392 stderr,
393 } => {
394 assert_eq!(code, 0, "process exited 0");
395 assert_eq!(stderr, "some stderr output\n", "process had no stderr");
396 }
397 e => return Err(e.into()),
398 }
399 Ok(())
400 }
401
402 #[test]
403 #[parallel]
404 fn run_with_env() -> Result<()> {
405 let env_key = "PRECIOUS_ENV_TEST";
406 let mut env = HashMap::new();
407 env.insert(String::from(env_key), String::from("foo"));
408 let res = super::run(
409 "sh",
410 &["-c", &format!("echo ${env_key}")],
411 &env,
412 &[0],
413 None,
414 None,
415 )?;
416 assert_eq!(res.exit_code, 0, "process exits 0");
417 assert!(res.stdout.is_some(), "process has stdout output");
418 assert_eq!(
419 res.stdout.unwrap(),
420 String::from("foo\n"),
421 "{} env var was set when process was run",
422 env_key,
423 );
424 let val = env::var(env_key);
425 assert_eq!(
426 val.err().unwrap(),
427 std::env::VarError::NotPresent,
428 "{} env var is not set after process was run",
429 env_key,
430 );
431
432 Ok(())
433 }
434
435 #[test]
436 #[parallel]
437 fn run_exit_32() -> Result<()> {
438 let res = super::run("sh", &["-c", "exit 32"], &HashMap::new(), &[0], None, None);
439 assert!(res.is_err(), "process exits non-zero");
440 match error_from_run(res)? {
441 Error::UnexpectedExitCode {
442 cmd: _,
443 code,
444 stdout,
445 stderr,
446 } => {
447 assert_eq!(code, 32, "process unexpectedly exits 32");
448 assert_eq!(stdout, "", "process had no stdout");
449 assert_eq!(stderr, "", "process had no stderr");
450 }
451 e => return Err(e.into()),
452 }
453
454 Ok(())
455 }
456
457 #[test]
458 #[parallel]
459 fn run_exit_32_with_stdout() -> Result<()> {
460 let res = super::run(
461 "sh",
462 &["-c", r#"echo "STDOUT" && exit 32"#],
463 &HashMap::new(),
464 &[0],
465 None,
466 None,
467 );
468 assert!(res.is_err(), "process exits non-zero");
469 let e = error_from_run(res)?;
470 let expect = r#"Got unexpected exit code 32 from `sh -c echo "STDOUT" && exit 32`.
471Stdout:
472STDOUT
473
474Stderr was empty.
475"#;
476 assert_eq!(format!("{e}"), expect, "error display output");
477
478 match e {
479 Error::UnexpectedExitCode {
480 cmd: _,
481 code,
482 stdout,
483 stderr,
484 } => {
485 assert_eq!(code, 32, "process unexpectedly exits 32");
486 assert_eq!(stdout, "STDOUT\n", "stdout was captured");
487 assert_eq!(stderr, "", "stderr was empty");
488 }
489 e => return Err(e.into()),
490 }
491
492 Ok(())
493 }
494
495 #[test]
496 #[parallel]
497 fn run_exit_32_with_stderr() -> Result<()> {
498 let res = super::run(
499 "sh",
500 &["-c", r#"echo "STDERR" 1>&2 && exit 32"#],
501 &HashMap::new(),
502 &[0],
503 None,
504 None,
505 );
506 assert!(res.is_err(), "process exits non-zero");
507 let e = error_from_run(res)?;
508 let expect = r#"Got unexpected exit code 32 from `sh -c echo "STDERR" 1>&2 && exit 32`.
509Stdout was empty.
510Stderr:
511STDERR
512
513"#;
514 assert_eq!(format!("{e}"), expect, "error display output");
515
516 match e {
517 Error::UnexpectedExitCode {
518 cmd: _,
519 code,
520 stdout,
521 stderr,
522 } => {
523 assert_eq!(
524 code, 32,
525 "process unexpectedly
526 exits 32"
527 );
528 assert_eq!(stdout, "", "stdout was empty");
529 assert_eq!(stderr, "STDERR\n", "stderr was captured");
530 }
531 e => return Err(e.into()),
532 }
533
534 Ok(())
535 }
536
537 #[test]
538 #[parallel]
539 fn run_exit_32_with_stdout_and_stderr() -> Result<()> {
540 let res = super::run(
541 "sh",
542 &["-c", r#"echo "STDOUT" && echo "STDERR" 1>&2 && exit 32"#],
543 &HashMap::new(),
544 &[0],
545 None,
546 None,
547 );
548 assert!(res.is_err(), "process exits non-zero");
549
550 let e = error_from_run(res)?;
551 let expect = r#"Got unexpected exit code 32 from `sh -c echo "STDOUT" && echo "STDERR" 1>&2 && exit 32`.
552Stdout:
553STDOUT
554
555Stderr:
556STDERR
557
558"#;
559 assert_eq!(format!("{e}"), expect, "error display output");
560 match e {
561 Error::UnexpectedExitCode {
562 cmd: _,
563 code,
564 stdout,
565 stderr,
566 } => {
567 assert_eq!(code, 32, "process unexpectedly exits 32");
568 assert_eq!(stdout, "STDOUT\n", "stdout was captured");
569 assert_eq!(stderr, "STDERR\n", "stderr was captured");
570 }
571 e => return Err(e.into()),
572 }
573
574 Ok(())
575 }
576
577 fn error_from_run(result: Result<super::Output>) -> Result<Error> {
578 match result {
579 Ok(_) => Err(format_err!("did not get an error in the returned Result")),
580 Err(e) => e.downcast::<super::Error>(),
581 }
582 }
583
584 #[test]
585 #[serial]
586 fn run_in_dir() -> Result<()> {
587 if cfg!(windows) {
590 return Ok(());
591 }
592
593 let td = tempdir()?;
594 let td_path = maybe_canonicalize(td.path())?;
595
596 let res = super::run("pwd", &[], &HashMap::new(), &[0], None, Some(&td_path))?;
597 assert_eq!(res.exit_code, 0, "process exits 0");
598 assert!(res.stdout.is_some(), "process produced stdout output");
599
600 let stdout = res.stdout.unwrap();
601 let stdout_trimmed = stdout.trim_end();
602 assert_eq!(
603 stdout_trimmed,
604 td_path.to_string_lossy(),
605 "process runs in another dir",
606 );
607
608 Ok(())
609 }
610
611 #[test]
612 #[parallel]
613 fn executable_does_not_exist() {
614 let exe = "I hope this binary does not exist on any system!";
615 let args = ["--arg", "42"];
616 let res = super::run(exe, &args, &HashMap::new(), &[0], None, None);
617 assert!(res.is_err());
618 if let Err(e) = res {
619 assert!(e.to_string().contains(
620 r#"Could not find "I hope this binary does not exist on any system!" in your path"#,
621 ));
622 }
623 }
624
625 pub fn maybe_canonicalize(path: &Path) -> Result<PathBuf> {
628 if cfg!(windows) {
629 return Ok(path.to_owned());
630 }
631 Ok(fs::canonicalize(path)?)
632 }
633}