1pub mod context;
15pub mod mise_decorator;
16pub mod noop;
17pub mod repository;
18pub mod streamed;
19
20#[cfg(test)]
21pub(crate) mod mock;
22
23use std::io::Read;
24use std::path::PathBuf;
25use std::process::Stdio;
26use std::sync::{Arc, Mutex};
27use std::time::{Duration, Instant};
28
29use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
30use tokio::process::Command as TokioCommand;
31
32pub use context::{ErrRunnerMissing, from_context, with_context};
33pub use noop::NoopClient;
34pub use repository::{RepositoryExecutorDecorator, get_repository_decorators};
35pub use streamed::ExecStreamedOpts;
36
37#[derive(Debug, thiserror::Error)]
44pub enum ExecError {
45 #[error("command exited with non-zero status: {0}")]
47 CommandFailed(i32),
48 #[error("running command: {0}")]
52 RunFailed(String),
53 #[error("command cancelled")]
56 Cancelled,
57 #[error("looking up executable: {0}")]
59 LookPathFailed(String),
60}
61
62#[derive(Debug, Default)]
65pub struct ExecResult {
66 pub stdout: Vec<String>,
67 pub stderr: Vec<String>,
68 pub exit_code: i32,
69 pub error: Option<ExecError>,
70 pub duration: Duration,
71}
72
73impl ExecResult {
74 pub fn combined_output(&self) -> String {
76 let mut output = String::new();
77 for line in &self.stdout {
78 output.push_str(line);
79 output.push('\n');
80 }
81 for line in &self.stderr {
82 output.push_str(line);
83 output.push('\n');
84 }
85 output
86 }
87}
88
89pub type LogLevel = tracing::Level;
93
94#[derive(Default)]
120pub struct Options {
121 pub log_level: Option<LogLevel>,
122 pub stderr_log_level: Option<LogLevel>,
123 pub failure_log_level: Option<LogLevel>,
124 pub disable_streaming: bool,
125 pub stdin: Option<Box<dyn Read + Send>>,
126 pub env: Option<Vec<String>>,
127 pub timeout: Option<Duration>,
128 pub log_writer: Option<Arc<Mutex<Vec<u8>>>>,
129}
130
131fn log_completion(opts: &Options, name: &str, args: &[String], result: &ExecResult) {
137 let Some(writer) = &opts.log_writer else {
138 return;
139 };
140
141 let (level, message) = match &result.error {
142 Some(error) => (
143 opts.failure_log_level.unwrap_or(LogLevel::ERROR),
144 format!("command error: {error}"),
145 ),
146 None => (
147 opts.log_level.unwrap_or(LogLevel::DEBUG),
148 "command completed".to_string(),
149 ),
150 };
151
152 let line = format!(
153 "level={level} msg=\"{message}\" command={name} args={args:?} exit_code={exit_code}\n",
154 exit_code = result.exit_code,
155 );
156
157 if let Ok(mut buf) = writer.lock() {
158 let _ = std::io::Write::write_all(&mut *buf, line.as_bytes());
159 }
160}
161
162pub trait CommandRunner {
171 fn run(&self, name: &str, args: &[String], opts: Option<Options>) -> ExecResult;
172
173 fn read_stdout(&self, name: &str, args: &[String]) -> Result<String, ExecError>;
174
175 fn run_sudo(&self, name: &str, args: &[String]) -> Result<(), ExecError>;
176
177 fn check(&self, name: &str, args: &[String]) -> Result<bool, ExecError>;
178
179 fn exec_streamed_command(
180 &self,
181 opts: Option<ExecStreamedOpts>,
182 name: &str,
183 args: &[String],
184 ) -> Result<(), ExecError>;
185
186 fn look_path(&self, file: &str) -> Result<PathBuf, ExecError>;
187
188 fn run_in_repository(
189 &self,
190 repo_dir: &str,
191 name: &str,
192 args: &[String],
193 opts: Option<ExecStreamedOpts>,
194 ) -> Result<(), ExecError>;
195}
196
197pub(crate) fn runtime() -> &'static tokio::runtime::Runtime {
211 static RUNTIME: std::sync::OnceLock<tokio::runtime::Runtime> = std::sync::OnceLock::new();
212 RUNTIME.get_or_init(|| {
213 tokio::runtime::Runtime::new().expect("failed to start xianyun-executor background runtime")
214 })
215}
216
217pub struct Client;
219
220impl Client {
221 pub fn new() -> Self {
222 Client
223 }
224}
225
226impl Default for Client {
227 fn default() -> Self {
228 Self::new()
229 }
230}
231
232impl CommandRunner for Client {
233 fn run(&self, name: &str, args: &[String], opts: Option<Options>) -> ExecResult {
234 run(name, args, opts)
235 }
236
237 fn read_stdout(&self, name: &str, args: &[String]) -> Result<String, ExecError> {
238 read_stdout(name, args)
239 }
240
241 fn run_sudo(&self, name: &str, args: &[String]) -> Result<(), ExecError> {
242 run_sudo(name, args)
243 }
244
245 fn check(&self, name: &str, args: &[String]) -> Result<bool, ExecError> {
246 check(name, args)
247 }
248
249 fn exec_streamed_command(
250 &self,
251 opts: Option<ExecStreamedOpts>,
252 name: &str,
253 args: &[String],
254 ) -> Result<(), ExecError> {
255 streamed::exec_streamed_command(opts, name, args)
256 }
257
258 fn look_path(&self, file: &str) -> Result<PathBuf, ExecError> {
259 look_path(file)
260 }
261
262 fn run_in_repository(
263 &self,
264 repo_dir: &str,
265 name: &str,
266 args: &[String],
267 opts: Option<ExecStreamedOpts>,
268 ) -> Result<(), ExecError> {
269 let mut opts = opts.unwrap_or_default();
270 if opts.working_dir.as_deref().unwrap_or_default().is_empty() {
271 opts.working_dir = Some(repo_dir.to_string());
272 }
273
274 let mut command = repository::Command {
275 name: name.to_string(),
276 args: args.to_vec(),
277 env: opts.env.clone(),
278 dir: opts.working_dir.clone(),
279 };
280
281 for decorator in get_repository_decorators() {
282 command = decorator.wrap(command)?;
283 }
284
285 let mut decorated_opts = opts;
286 decorated_opts.env = command.env;
287 decorated_opts.working_dir = command.dir;
288
289 streamed::exec_streamed_command(Some(decorated_opts), &command.name, &command.args)
290 }
291}
292
293pub(crate) async fn drain_lines<R>(reader: R) -> Vec<String>
300where
301 R: tokio::io::AsyncRead + Unpin,
302{
303 let mut lines = Vec::new();
304 let mut reader = BufReader::new(reader).lines();
305 while let Ok(Some(line)) = reader.next_line().await {
306 lines.push(line);
307 }
308 lines
309}
310
311pub(crate) fn apply_env(cmd: &mut TokioCommand, env: &Option<Vec<String>>) {
314 if let Some(env) = env {
315 cmd.env_clear();
316 for kv in env {
317 if let Some((key, value)) = kv.split_once('=') {
318 cmd.env(key, value);
319 }
320 }
321 }
322}
323
324async fn run_async(name: &str, args: &[String], mut opts: Options) -> ExecResult {
331 let start = Instant::now();
332
333 let mut cmd = TokioCommand::new(name);
334 cmd.args(args);
335 apply_env(&mut cmd, &opts.env);
336 cmd.stdout(Stdio::piped());
337 cmd.stderr(Stdio::piped());
338 cmd.stdin(if opts.stdin.is_some() {
339 Stdio::piped()
340 } else {
341 Stdio::null()
342 });
343
344 let mut child = match cmd.spawn() {
345 Ok(child) => child,
346 Err(error) => {
347 let result = ExecResult {
348 stdout: Vec::new(),
349 stderr: Vec::new(),
350 exit_code: -1,
351 error: Some(ExecError::RunFailed(error.to_string())),
352 duration: start.elapsed(),
353 };
354 log_completion(&opts, name, args, &result);
355 return result;
356 }
357 };
358
359 if let Some(mut stdin_reader) = opts.stdin.take()
360 && let Some(mut child_stdin) = child.stdin.take()
361 {
362 let mut buf = Vec::new();
363 let _ = stdin_reader.read_to_end(&mut buf);
364 let _ = child_stdin.write_all(&buf).await;
365 drop(child_stdin);
366 }
367
368 let stdout = child.stdout.take().expect("stdout was piped");
369 let stderr = child.stderr.take().expect("stderr was piped");
370 let stdout_task = tokio::spawn(drain_lines(stdout));
371 let stderr_task = tokio::spawn(drain_lines(stderr));
372
373 let wait_result = match opts.timeout {
374 Some(timeout) => {
375 tokio::select! {
376 res = child.wait() => Some(res),
377 () = tokio::time::sleep(timeout) => None,
378 }
379 }
380 None => Some(child.wait().await),
381 };
382
383 let cancelled = wait_result.is_none();
384 let final_status = match wait_result {
385 Some(res) => res,
386 None => {
387 let _ = child.kill().await;
388 child.wait().await
389 }
390 };
391
392 let stdout_lines = stdout_task.await.unwrap_or_default();
393 let stderr_lines = stderr_task.await.unwrap_or_default();
394 let duration = start.elapsed();
395
396 let mut exit_code = -1;
397 let mut error = None;
398
399 match final_status {
400 Ok(status) => {
401 exit_code = status.code().unwrap_or(-1);
402 if !status.success() {
403 error = Some(ExecError::CommandFailed(exit_code));
404 }
405 }
406 Err(run_error) => {
407 error = Some(ExecError::RunFailed(run_error.to_string()));
408 }
409 }
410
411 if cancelled {
412 error = Some(ExecError::Cancelled);
413 }
414
415 let result = ExecResult {
416 stdout: stdout_lines,
417 stderr: stderr_lines,
418 exit_code,
419 error,
420 duration,
421 };
422 log_completion(&opts, name, args, &result);
423 result
424}
425
426pub fn run(name: &str, args: &[String], opts: Option<Options>) -> ExecResult {
428 runtime().block_on(run_async(name, args, opts.unwrap_or_default()))
429}
430
431pub fn read_stdout(name: &str, args: &[String]) -> Result<String, ExecError> {
433 let result = run(
434 name,
435 args,
436 Some(Options {
437 log_level: Some(LogLevel::DEBUG),
438 stderr_log_level: Some(LogLevel::DEBUG),
439 failure_log_level: Some(LogLevel::DEBUG),
440 disable_streaming: true,
441 ..Default::default()
442 }),
443 );
444
445 if let Some(error) = result.error {
446 return Err(error);
447 }
448
449 Ok(result.stdout.join("\n"))
450}
451
452pub fn check(name: &str, args: &[String]) -> Result<bool, ExecError> {
457 check_with_timeout(name, args, None)
458}
459
460pub fn check_with_timeout(
465 name: &str,
466 args: &[String],
467 timeout: Option<Duration>,
468) -> Result<bool, ExecError> {
469 let result = run(
470 name,
471 args,
472 Some(Options {
473 log_level: Some(LogLevel::DEBUG),
474 stderr_log_level: Some(LogLevel::DEBUG),
475 failure_log_level: Some(LogLevel::DEBUG),
476 disable_streaming: true,
477 timeout,
478 ..Default::default()
479 }),
480 );
481
482 match result.error {
483 None => Ok(true),
484 Some(ExecError::CommandFailed(_)) => Ok(false),
485 Some(other) => Err(other),
486 }
487}
488
489pub fn run_sudo(name: &str, args: &[String]) -> Result<(), ExecError> {
491 run_sudo_with(name, args, real_geteuid, look_path)
492}
493
494pub fn run_sudo_with(
502 name: &str,
503 args: &[String],
504 geteuid: impl Fn() -> u32,
505 look_path: impl Fn(&str) -> Result<PathBuf, ExecError>,
506) -> Result<(), ExecError> {
507 let (name, args) = apply_sudo_with(name, args, geteuid, look_path);
508
509 let status = std::process::Command::new(&name)
510 .args(&args)
511 .stdin(Stdio::inherit())
512 .stdout(Stdio::inherit())
513 .stderr(Stdio::inherit())
514 .status();
515
516 match status {
517 Ok(status) if status.success() => Ok(()),
518 Ok(status) => match status.code() {
519 Some(code) if code > 0 => Err(ExecError::CommandFailed(code)),
520 _ => Err(ExecError::RunFailed(format!(
521 "process terminated: {status}"
522 ))),
523 },
524 Err(error) => Err(ExecError::RunFailed(error.to_string())),
525 }
526}
527
528pub fn apply_sudo_with(
536 name: &str,
537 args: &[String],
538 geteuid: impl Fn() -> u32,
539 look_path: impl Fn(&str) -> Result<PathBuf, ExecError>,
540) -> (String, Vec<String>) {
541 if geteuid() == 0 {
542 return (name.to_string(), args.to_vec());
543 }
544
545 match look_path("sudo") {
546 Ok(sudo_path) => {
547 let mut new_args = vec![name.to_string()];
548 new_args.extend(args.iter().cloned());
549 (sudo_path.to_string_lossy().into_owned(), new_args)
550 }
551 Err(_) => {
552 tracing::warn!(
553 command = name,
554 "sudo requested but not found on PATH; running command directly"
555 );
556 (name.to_string(), args.to_vec())
557 }
558 }
559}
560
561pub fn apply_sudo(name: &str, args: &[String]) -> (String, Vec<String>) {
563 apply_sudo_with(name, args, real_geteuid, look_path)
564}
565
566#[cfg(unix)]
570fn real_geteuid() -> u32 {
571 unsafe extern "C" {
572 fn geteuid() -> u32;
573 }
574 unsafe { geteuid() }
578}
579
580#[cfg(not(unix))]
581fn real_geteuid() -> u32 {
582 1
586}
587
588pub fn look_path(file: &str) -> Result<PathBuf, ExecError> {
593 if file.contains(std::path::MAIN_SEPARATOR) {
594 let candidate = PathBuf::from(file);
595 return if is_executable_file(&candidate) {
596 Ok(candidate)
597 } else {
598 Err(ExecError::LookPathFailed(file.to_string()))
599 };
600 }
601
602 let path_var =
603 std::env::var_os("PATH").ok_or_else(|| ExecError::LookPathFailed(file.to_string()))?;
604
605 for dir in std::env::split_paths(&path_var) {
606 let candidate = dir.join(file);
607 if is_executable_file(&candidate) {
608 return Ok(candidate);
609 }
610 }
611
612 Err(ExecError::LookPathFailed(file.to_string()))
613}
614
615#[cfg(unix)]
616fn is_executable_file(path: &std::path::Path) -> bool {
617 use std::os::unix::fs::PermissionsExt;
618 std::fs::metadata(path)
619 .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
620}
621
622#[cfg(not(unix))]
623fn is_executable_file(path: &std::path::Path) -> bool {
624 path.is_file()
625}
626
627#[cfg(test)]
628mod tests {
629 use super::*;
630 use std::sync::{Arc, Mutex};
631 use std::time::Duration as StdDuration;
632
633 #[test]
636 fn run_success() {
637 let result = run("echo", &["hello".into(), "world".into()], None);
638
639 assert!(result.error.is_none(), "command should succeed");
640 assert_eq!(result.exit_code, 0, "exit code should be 0");
641 assert!(!result.stdout.is_empty(), "stdout should contain output");
642 assert!(
643 result.duration > StdDuration::ZERO,
644 "duration should be recorded"
645 );
646 }
647
648 #[test]
649 fn run_command_not_found() {
650 let result = run("nonexistent-command-12345", &[], None);
651
652 assert!(result.error.is_some(), "command should fail");
653 assert_ne!(result.exit_code, 0, "exit code should be non-zero");
654 assert!(
655 result.duration > StdDuration::ZERO,
656 "duration should be recorded"
657 );
658 }
659
660 #[test]
661 fn run_non_zero_exit() {
662 let result = run("sh", &["-c".into(), "exit 42".into()], None);
663
664 assert!(result.error.is_some(), "command should return error");
665 assert_eq!(result.exit_code, 42, "exit code should be 42");
666 assert!(matches!(result.error, Some(ExecError::CommandFailed(42))));
667 }
668
669 #[test]
670 fn run_context_cancellation() {
671 let result = run(
683 "sleep",
684 &["10".into()],
685 Some(Options {
686 timeout: Some(StdDuration::from_millis(100)),
687 ..Default::default()
688 }),
689 );
690
691 assert!(result.error.is_some());
692 assert!(matches!(result.error, Some(ExecError::Cancelled)));
693 }
694
695 #[test]
696 fn run_with_options_custom_logger() {
697 let result = run(
698 "echo",
699 &["test".into()],
700 Some(Options {
701 log_level: Some(LogLevel::INFO),
702 ..Default::default()
703 }),
704 );
705
706 assert!(result.error.is_none());
707 assert_eq!(result.exit_code, 0);
708 }
709
710 #[test]
725 fn run_large_output_does_not_deadlock() {
726 let result = run(
727 "sh",
728 &[
729 "-c".into(),
730 "for i in $(seq 1 5000); do echo line $i; done".into(),
731 ],
732 None,
733 );
734
735 assert!(result.error.is_none(), "command should succeed");
736 assert_eq!(result.exit_code, 0, "exit code should be 0");
737 assert_eq!(
738 result.stdout.len(),
739 5000,
740 "all output lines should be captured"
741 );
742 }
743
744 #[test]
745 fn run_with_options_disable_streaming() {
746 let result = run(
747 "echo",
748 &["test".into()],
749 Some(Options {
750 disable_streaming: true,
751 ..Default::default()
752 }),
753 );
754
755 assert!(result.error.is_none());
756 assert_eq!(result.exit_code, 0);
757 assert!(!result.stdout.is_empty());
758 }
759
760 #[test]
761 fn look_path_finds_and_rejects() {
762 let path = look_path("sh").expect("sh should be found");
763 assert!(!path.as_os_str().is_empty());
764
765 assert!(look_path("nonexistent-command-xyz").is_err());
766 }
767
768 #[test]
769 fn result_combined_output() {
770 let result = ExecResult {
771 stdout: vec!["line1".into(), "line2".into()],
772 stderr: vec!["error1".into(), "error2".into()],
773 ..Default::default()
774 };
775
776 let output = result.combined_output();
777 assert!(output.contains("line1"));
778 assert!(output.contains("line2"));
779 assert!(output.contains("error1"));
780 assert!(output.contains("error2"));
781 }
782
783 #[test]
784 fn read_stdout_success() {
785 let stdout = read_stdout("echo", &["hello".into(), "world".into()]).unwrap();
786 assert!(stdout.contains("hello world"));
787 }
788
789 #[test]
790 fn read_stdout_multiple_lines() {
791 let stdout = read_stdout("sh", &["-c".into(), "echo line1; echo line2".into()]).unwrap();
792 assert!(stdout.contains("line1"));
793 assert!(stdout.contains("line2"));
794 }
795
796 #[test]
797 fn read_stdout_non_zero_exit() {
798 let err = read_stdout("sh", &["-c".into(), "exit 1".into()]).unwrap_err();
799 assert!(matches!(err, ExecError::CommandFailed(1)));
800 }
801
802 #[test]
803 fn read_stdout_empty_output() {
804 let stdout = read_stdout("sh", &["-c".into(), "".into()]).unwrap();
805 assert!(stdout.is_empty());
806 }
807
808 #[test]
809 fn run_non_zero_exit_default_failure_log_level() {
810 let buf: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
816
817 let result = run(
818 "sh",
819 &["-c".into(), "exit 1".into()],
820 Some(Options {
821 log_writer: Some(buf.clone()),
822 ..Default::default()
823 }),
824 );
825
826 assert!(result.error.is_some());
827 assert_eq!(result.exit_code, 1);
828
829 let output = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
830 assert!(output.contains("ERROR"));
831 assert!(output.contains("command error"));
832 }
833
834 #[test]
835 fn run_non_zero_exit_failure_log_level_override() {
836 let buf: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
837
838 let result = run(
839 "sh",
840 &["-c".into(), "exit 1".into()],
841 Some(Options {
842 failure_log_level: Some(LogLevel::DEBUG),
843 log_writer: Some(buf.clone()),
844 ..Default::default()
845 }),
846 );
847
848 assert!(result.error.is_some());
849 assert_eq!(result.exit_code, 1);
850
851 let output = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
852 assert!(!output.contains("ERROR"));
853 assert!(output.contains("DEBUG"));
854 assert!(output.contains("command error"));
855 }
856
857 #[test]
858 fn check_success() {
859 let ok = check("true", &[]).unwrap();
860 assert!(ok);
861 }
862
863 #[test]
864 fn check_non_zero() {
865 let ok = check("false", &[]).unwrap();
866 assert!(!ok);
867 }
868
869 #[test]
870 fn check_binary_missing() {
871 let err = check("/no/such/binary-caproni-test", &[]).unwrap_err();
872 assert!(matches!(err, ExecError::RunFailed(_)));
873 }
874
875 #[test]
876 fn check_context_cancelled() {
877 let err = check_with_timeout("sleep", &["10".into()], Some(StdDuration::from_millis(50)))
881 .unwrap_err();
882 assert!(matches!(err, ExecError::Cancelled));
883 }
884
885 #[test]
886 fn read_stdout_non_zero_exit_quiet_completion() {
887 let buf: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
888
889 let result = run(
890 "sh",
891 &["-c".into(), "echo x; exit 1".into()],
892 Some(Options {
893 log_level: Some(LogLevel::DEBUG),
894 stderr_log_level: Some(LogLevel::DEBUG),
895 failure_log_level: Some(LogLevel::DEBUG),
896 disable_streaming: true,
897 log_writer: Some(buf.clone()),
898 ..Default::default()
899 }),
900 );
901
902 assert!(matches!(result.error, Some(ExecError::CommandFailed(1))));
903 assert_eq!(result.stdout, vec!["x".to_string()]);
904
905 let output = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
906 assert!(!output.contains("ERROR"));
907 assert!(output.contains("command error"));
908 }
909
910 struct SudoTestCase {
911 desc: &'static str,
912 in_name: &'static str,
913 in_args: &'static [&'static str],
914 out_name: &'static str,
915 out_args: &'static [&'static str],
916 uid: u32,
917 sudo_on_path: bool,
918 }
919
920 #[test]
921 fn apply_sudo_cases() {
922 let cases = [
923 SudoTestCase {
924 desc: "already root",
925 in_name: "update-ca-certificates",
926 in_args: &[],
927 out_name: "update-ca-certificates",
928 out_args: &[],
929 uid: 0,
930 sudo_on_path: false,
931 },
932 SudoTestCase {
933 desc: "sudo requested, available",
934 in_name: "security",
935 in_args: &["add-trusted-cert", "-d"],
936 out_name: "/usr/bin/sudo",
937 out_args: &["security", "add-trusted-cert", "-d"],
938 uid: 501,
939 sudo_on_path: true,
940 },
941 SudoTestCase {
942 desc: "sudo requested, not available",
943 in_name: "update-ca-certificates",
944 in_args: &[],
945 out_name: "update-ca-certificates",
946 out_args: &[],
947 uid: 501,
948 sudo_on_path: false,
949 },
950 ];
951
952 for case in cases {
953 let (name, args) = apply_sudo_with(
954 case.in_name,
955 &case
956 .in_args
957 .iter()
958 .map(|s| s.to_string())
959 .collect::<Vec<_>>(),
960 move || case.uid,
961 move |file| {
962 if case.sudo_on_path {
963 Ok(PathBuf::from(format!("/usr/bin/{file}")))
964 } else {
965 Err(ExecError::LookPathFailed(file.to_string()))
966 }
967 },
968 );
969
970 assert_eq!(name, case.out_name, "{}", case.desc);
971 assert_eq!(
972 args,
973 case.out_args
974 .iter()
975 .map(|s| s.to_string())
976 .collect::<Vec<_>>(),
977 "{}",
978 case.desc
979 );
980 }
981 }
982
983 #[test]
984 fn run_sudo_already_root() {
985 let err = run_sudo_with(
991 "true",
992 &[],
993 || 0,
994 |file| Err(ExecError::LookPathFailed(file.to_string())),
995 );
996 assert!(err.is_ok());
997 }
998
999 #[test]
1000 fn run_sudo_non_zero_exit() {
1001 let err = run_sudo_with(
1002 "sh",
1003 &["-c".into(), "exit 7".into()],
1004 || 0,
1005 |file| Err(ExecError::LookPathFailed(file.to_string())),
1006 )
1007 .unwrap_err();
1008 assert!(matches!(err, ExecError::CommandFailed(7)));
1009 }
1010}