precious_helpers/
exec.rs

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    // We are canonicalizing this primarily for the benefit of our debugging
100    // output, because otherwise we might see the current dir as just `.`,
101    // which is not helpful.
102    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    // Anything that does pushd must be run serially or else chaos ensues.
266    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        // On windows the path we get from `pwd` is a Windows path (C:\...)
588        // but `td.path()` contains a Unix path (/tmp/...). Very confusing.
589        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    // The temp directory on macOS in GitHub Actions appears to be a symlink, but
626    // canonicalizing on Windows breaks tests for some reason.
627    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}