precious_exec/
lib.rs

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