1#[cfg(feature = "mock-process")]
2mod mock;
3mod shell;
4
5use anyhow::{Context, Result, bail};
6use oxdock_fs::{GuardedPath, PolicyPath};
7use shell::shell_cmd;
8pub use shell::{ShellLauncher, shell_program};
9use std::collections::HashMap;
10#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
11use std::fs::File;
12#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
13use std::path::{Path, PathBuf};
14#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
15use std::process::{Child, Command as ProcessCommand, ExitStatus, Output as StdOutput, Stdio};
16use std::{
17 ffi::{OsStr, OsString},
18 iter::IntoIterator,
19};
20
21#[cfg(miri)]
22use oxdock_fs::PathResolver;
23
24#[cfg(feature = "mock-process")]
25pub use mock::{MockHandle, MockProcessManager, MockRunCall, MockSpawnCall};
26
27#[derive(Clone, Debug)]
31pub struct CommandContext {
32 cwd: PolicyPath,
33 envs: HashMap<String, String>,
34 cargo_target_dir: GuardedPath,
35 workspace_root: GuardedPath,
36 build_context: GuardedPath,
37}
38
39impl CommandContext {
40 #[allow(clippy::too_many_arguments)]
41 pub fn new(
42 cwd: &PolicyPath,
43 envs: &HashMap<String, String>,
44 cargo_target_dir: &GuardedPath,
45 workspace_root: &GuardedPath,
46 build_context: &GuardedPath,
47 ) -> Self {
48 Self {
49 cwd: cwd.clone(),
50 envs: envs.clone(),
51 cargo_target_dir: cargo_target_dir.clone(),
52 workspace_root: workspace_root.clone(),
53 build_context: build_context.clone(),
54 }
55 }
56
57 pub fn cwd(&self) -> &PolicyPath {
58 &self.cwd
59 }
60
61 pub fn envs(&self) -> &HashMap<String, String> {
62 &self.envs
63 }
64
65 pub fn cargo_target_dir(&self) -> &GuardedPath {
66 &self.cargo_target_dir
67 }
68
69 pub fn workspace_root(&self) -> &GuardedPath {
70 &self.workspace_root
71 }
72
73 pub fn build_context(&self) -> &GuardedPath {
74 &self.build_context
75 }
76}
77
78pub trait BackgroundHandle {
80 fn try_wait(&mut self) -> Result<Option<ExitStatus>>;
81 fn kill(&mut self) -> Result<()>;
82 fn wait(&mut self) -> Result<ExitStatus>;
83}
84
85pub trait ProcessManager: Clone {
90 type Handle: BackgroundHandle;
91
92 fn run(&mut self, ctx: &CommandContext, script: &str) -> Result<()>;
93 fn run_capture(&mut self, ctx: &CommandContext, script: &str) -> Result<Vec<u8>>;
94 fn spawn_bg(&mut self, ctx: &CommandContext, script: &str) -> Result<Self::Handle>;
95}
96
97#[derive(Clone, Default)]
99#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
100pub struct ShellProcessManager;
101
102impl ProcessManager for ShellProcessManager {
103 type Handle = ChildHandle;
104
105 #[allow(clippy::disallowed_types, clippy::disallowed_methods)]
106 fn run(&mut self, ctx: &CommandContext, script: &str) -> Result<()> {
107 let mut command = shell_cmd(script);
108 apply_ctx(&mut command, ctx);
109 run_cmd(&mut command)
110 }
111
112 #[allow(clippy::disallowed_types, clippy::disallowed_methods)]
113 fn run_capture(&mut self, ctx: &CommandContext, script: &str) -> Result<Vec<u8>> {
114 let mut command = shell_cmd(script);
115 apply_ctx(&mut command, ctx);
116 command.stdout(Stdio::piped());
117 let output = command
118 .output()
119 .with_context(|| format!("failed to run {:?}", command))?;
120 if !output.status.success() {
121 bail!("command {:?} failed with status {}", command, output.status);
122 }
123 Ok(output.stdout)
124 }
125
126 #[allow(clippy::disallowed_types, clippy::disallowed_methods)]
127 fn spawn_bg(&mut self, ctx: &CommandContext, script: &str) -> Result<Self::Handle> {
128 let mut command = shell_cmd(script);
129 apply_ctx(&mut command, ctx);
130 let child = command
131 .spawn()
132 .with_context(|| format!("failed to spawn {:?}", command))?;
133 Ok(ChildHandle { child })
134 }
135}
136
137#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
138fn apply_ctx(command: &mut ProcessCommand, ctx: &CommandContext) {
139 let cwd_path: std::borrow::Cow<std::path::Path> = match ctx.cwd() {
145 PolicyPath::Guarded(p) => oxdock_fs::command_path(p),
146 PolicyPath::Unguarded(p) => std::borrow::Cow::Borrowed(p.as_path()),
147 };
148 command.current_dir(cwd_path);
149 command.envs(ctx.envs());
150 command.env(
151 "CARGO_TARGET_DIR",
152 oxdock_fs::command_path(ctx.cargo_target_dir()).into_owned(),
153 );
154}
155
156#[derive(Debug)]
157#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
158pub struct ChildHandle {
159 child: Child,
160}
161
162impl BackgroundHandle for ChildHandle {
163 fn try_wait(&mut self) -> Result<Option<ExitStatus>> {
164 Ok(self.child.try_wait()?)
165 }
166
167 fn kill(&mut self) -> Result<()> {
168 if self.child.try_wait()?.is_none() {
169 let _ = self.child.kill();
170 }
171 Ok(())
172 }
173
174 fn wait(&mut self) -> Result<ExitStatus> {
175 Ok(self.child.wait()?)
176 }
177}
178
179#[cfg(miri)]
184#[derive(Clone, Default)]
185pub struct SyntheticProcessManager;
186
187#[cfg(miri)]
188#[derive(Clone)]
189pub struct SyntheticBgHandle {
190 ctx: CommandContext,
191 actions: Vec<Action>,
192 remaining: std::time::Duration,
193 last_polled: std::time::Instant,
194 status: ExitStatus,
195 applied: bool,
196 killed: bool,
197}
198
199#[cfg(miri)]
200#[derive(Clone)]
201enum Action {
202 WriteFile { target: GuardedPath, data: Vec<u8> },
203}
204
205#[cfg(miri)]
206impl BackgroundHandle for SyntheticBgHandle {
207 fn try_wait(&mut self) -> Result<Option<ExitStatus>> {
208 if self.killed {
209 self.applied = true;
210 return Ok(Some(self.status));
211 }
212 if self.applied {
213 return Ok(Some(self.status));
214 }
215 let now = std::time::Instant::now();
216 let elapsed = now.saturating_duration_since(self.last_polled);
217 const MAX_ADVANCE: std::time::Duration = std::time::Duration::from_millis(15);
218 let advance = elapsed.min(MAX_ADVANCE).min(self.remaining);
219 self.remaining = self.remaining.saturating_sub(advance);
220 self.last_polled = now;
221
222 if self.remaining.is_zero() {
223 apply_actions(&self.ctx, &self.actions)?;
224 self.applied = true;
225 Ok(Some(self.status))
226 } else {
227 Ok(None)
228 }
229 }
230
231 fn kill(&mut self) -> Result<()> {
232 self.killed = true;
233 Ok(())
234 }
235
236 fn wait(&mut self) -> Result<ExitStatus> {
237 if self.killed {
238 self.applied = true;
239 return Ok(self.status);
240 }
241 if !self.applied {
242 if !self.remaining.is_zero() {
243 std::thread::sleep(self.remaining);
244 }
245 apply_actions(&self.ctx, &self.actions)?;
246 self.applied = true;
247 }
248 Ok(self.status)
249 }
250}
251
252#[cfg(miri)]
253impl ProcessManager for SyntheticProcessManager {
254 type Handle = SyntheticBgHandle;
255
256 fn run(&mut self, ctx: &CommandContext, script: &str) -> Result<()> {
257 let (_out, status) = execute_sync(ctx, script, false)?;
258 if !status.success() {
259 bail!("command {:?} failed with status {}", script, status);
260 }
261 Ok(())
262 }
263
264 fn run_capture(&mut self, ctx: &CommandContext, script: &str) -> Result<Vec<u8>> {
265 let (out, status) = execute_sync(ctx, script, true)?;
266 if !status.success() {
267 bail!("command {:?} failed with status {}", script, status);
268 }
269 Ok(out)
270 }
271
272 fn spawn_bg(&mut self, ctx: &CommandContext, script: &str) -> Result<Self::Handle> {
273 let plan = plan_background(ctx, script)?;
274 Ok(plan)
275 }
276}
277
278#[cfg(miri)]
279fn execute_sync(
280 ctx: &CommandContext,
281 script: &str,
282 capture: bool,
283) -> Result<(Vec<u8>, ExitStatus)> {
284 let mut stdout = Vec::new();
285 let mut status = exit_status_from_code(0);
286 let resolver = PathResolver::new(
287 ctx.workspace_root().as_path(),
288 ctx.build_context().as_path(),
289 )?;
290
291 let script = normalize_shell(script);
292 for raw in script.split(';') {
293 let cmd = raw.trim();
294 if cmd.is_empty() {
295 continue;
296 }
297 let (action, sleep_dur, exit_code) = parse_command(cmd, ctx, &resolver, capture)?;
298 if sleep_dur > std::time::Duration::ZERO {
299 std::thread::sleep(sleep_dur);
300 }
301 if let Some(action) = action {
302 match action {
303 CommandAction::Write { target, data } => {
304 if let Some(parent) = target.as_path().parent() {
305 let parent_guard = GuardedPath::new(target.root(), parent)?;
306 resolver.create_dir_all(&parent_guard)?;
307 }
308 resolver.write_file(&target, &data)?;
309 }
310 CommandAction::Stdout { data } => {
311 stdout.extend_from_slice(&data);
312 }
313 }
314 }
315 if let Some(code) = exit_code {
316 status = exit_status_from_code(code);
317 break;
318 }
319 }
320
321 Ok((stdout, status))
322}
323
324#[cfg(miri)]
325fn plan_background(ctx: &CommandContext, script: &str) -> Result<SyntheticBgHandle> {
326 let resolver = PathResolver::new(
327 ctx.workspace_root().as_path(),
328 ctx.build_context().as_path(),
329 )?;
330 let mut actions: Vec<Action> = Vec::new();
331 let mut ready = std::time::Duration::ZERO;
332 let mut status = exit_status_from_code(0);
333
334 let script = normalize_shell(script);
335 for raw in script.split(';') {
336 let cmd = raw.trim();
337 if cmd.is_empty() {
338 continue;
339 }
340 let (action, sleep_dur, exit_code) = parse_command(cmd, ctx, &resolver, false)?;
341 ready += sleep_dur;
342 if let Some(CommandAction::Write { target, data }) = action {
343 actions.push(Action::WriteFile { target, data });
344 }
345 if let Some(code) = exit_code {
346 status = exit_status_from_code(code);
347 break;
348 }
349 }
350
351 let min_ready = std::time::Duration::from_millis(50);
352 ready = ready.max(min_ready);
353
354 let handle = SyntheticBgHandle {
355 ctx: ctx.clone(),
356 actions,
357 remaining: ready,
358 last_polled: std::time::Instant::now(),
359 status,
360 applied: false,
361 killed: false,
362 };
363 Ok(handle)
364}
365
366#[cfg(miri)]
367enum CommandAction {
368 Write { target: GuardedPath, data: Vec<u8> },
369 Stdout { data: Vec<u8> },
370}
371
372#[cfg(miri)]
373fn parse_command(
374 cmd: &str,
375 ctx: &CommandContext,
376 resolver: &PathResolver,
377 capture: bool,
378) -> Result<(Option<CommandAction>, std::time::Duration, Option<i32>)> {
379 let (core, redirect) = split_redirect(cmd);
380 let tokens: Vec<&str> = core.split_whitespace().collect();
381 if tokens.is_empty() {
382 return Ok((None, std::time::Duration::ZERO, None));
383 }
384
385 match tokens[0] {
386 "sleep" => {
387 let dur = tokens
388 .get(1)
389 .and_then(|s| s.parse::<f64>().ok())
390 .unwrap_or(0.0);
391 let duration = std::time::Duration::from_secs_f64(dur);
392 Ok((None, duration, None))
393 }
394 "exit" => {
395 let code = tokens
396 .get(1)
397 .and_then(|s| s.parse::<i32>().ok())
398 .unwrap_or(0);
399 Ok((None, std::time::Duration::ZERO, Some(code)))
400 }
401 "printf" => {
402 let body = extract_body(&core, "printf %s");
403 let expanded = expand_env(&body, ctx);
404 let data = expanded.into_bytes();
405 if let Some(path_str) = redirect {
406 let target = resolve_write(resolver, ctx, &path_str)?;
407 Ok((
408 Some(CommandAction::Write { target, data }),
409 std::time::Duration::ZERO,
410 None,
411 ))
412 } else if capture {
413 Ok((
414 Some(CommandAction::Stdout { data }),
415 std::time::Duration::ZERO,
416 None,
417 ))
418 } else {
419 Ok((None, std::time::Duration::ZERO, None))
420 }
421 }
422 "echo" => {
423 let body = core.strip_prefix("echo").unwrap_or("").trim();
424 let expanded = expand_env(body, ctx);
425 let mut data = expanded.into_bytes();
426 data.push(b'\n');
427 if let Some(path_str) = redirect {
428 let target = resolve_write(resolver, ctx, &path_str)?;
429 Ok((
430 Some(CommandAction::Write { target, data }),
431 std::time::Duration::ZERO,
432 None,
433 ))
434 } else if capture {
435 Ok((
436 Some(CommandAction::Stdout { data }),
437 std::time::Duration::ZERO,
438 None,
439 ))
440 } else {
441 Ok((None, std::time::Duration::ZERO, None))
442 }
443 }
444 _ => {
445 Ok((None, std::time::Duration::ZERO, None))
447 }
448 }
449}
450
451#[cfg(miri)]
452fn resolve_write(resolver: &PathResolver, ctx: &CommandContext, path: &str) -> Result<GuardedPath> {
453 match ctx.cwd() {
454 PolicyPath::Guarded(p) => resolver.resolve_write(p, path),
455 PolicyPath::Unguarded(_) => bail!("unguarded writes not supported in Miri"),
456 }
457}
458
459#[cfg(miri)]
460fn split_redirect(cmd: &str) -> (String, Option<String>) {
461 if let Some(idx) = cmd.find('>') {
462 let (left, right) = cmd.split_at(idx);
463 let path = right.trim_start_matches('>').trim();
464 (left.trim().to_string(), Some(path.to_string()))
465 } else {
466 (cmd.trim().to_string(), None)
467 }
468}
469
470#[cfg(miri)]
471fn extract_body(cmd: &str, prefix: &str) -> String {
472 cmd.strip_prefix(prefix)
473 .unwrap_or(cmd)
474 .trim()
475 .trim_matches('"')
476 .to_string()
477}
478
479#[cfg(miri)]
480fn expand_env(input: &str, ctx: &CommandContext) -> String {
481 let mut out = String::new();
482 let mut chars = input.chars().peekable();
483 while let Some(c) = chars.next() {
484 if c == '$' {
485 if let Some(&'{') = chars.peek() {
486 chars.next();
487 let mut name = String::new();
488 while let Some(&ch) = chars.peek() {
489 chars.next();
490 if ch == '}' {
491 break;
492 }
493 name.push(ch);
494 }
495 out.push_str(&env_lookup(&name, ctx));
496 } else {
497 let mut name = String::new();
498 while let Some(&ch) = chars.peek() {
499 if ch.is_ascii_alphanumeric() || ch == '_' {
500 name.push(ch);
501 chars.next();
502 } else {
503 break;
504 }
505 }
506 if name.is_empty() {
507 out.push('$');
508 } else {
509 out.push_str(&env_lookup(&name, ctx));
510 }
511 }
512 } else if c == '%' {
513 let mut name = String::new();
515 while let Some(&ch) = chars.peek() {
516 chars.next();
517 if ch == '%' {
518 break;
519 }
520 name.push(ch);
521 }
522 if name.is_empty() {
523 out.push('%');
524 } else {
525 out.push_str(&env_lookup(&name, ctx));
526 }
527 } else {
528 out.push(c);
529 }
530 }
531 out
532}
533
534#[cfg(miri)]
535fn env_lookup(name: &str, ctx: &CommandContext) -> String {
536 if name == "CARGO_TARGET_DIR" {
537 return ctx.cargo_target_dir().display().to_string();
538 }
539 ctx.envs()
540 .get(name)
541 .cloned()
542 .or_else(|| std::env::var(name).ok())
543 .unwrap_or_default()
544}
545
546#[cfg(miri)]
547fn normalize_shell(script: &str) -> String {
548 let trimmed = script.trim();
549 if let Some(rest) = trimmed.strip_prefix("sh -c ") {
550 return rest.trim_matches(&['"', '\''] as &[_]).to_string();
551 }
552 if let Some(rest) = trimmed.strip_prefix("cmd /C ") {
553 return rest.trim_matches(&['"', '\''] as &[_]).to_string();
554 }
555 trimmed.to_string()
556}
557
558#[cfg(miri)]
559fn apply_actions(ctx: &CommandContext, actions: &[Action]) -> Result<()> {
560 let resolver = PathResolver::new(
561 ctx.workspace_root().as_path(),
562 ctx.build_context().as_path(),
563 )?;
564 for action in actions {
565 match action {
566 Action::WriteFile { target, data } => {
567 if let Some(parent) = target.as_path().parent() {
568 let parent_guard = GuardedPath::new(target.root(), parent)?;
569 resolver.create_dir_all(&parent_guard)?;
570 }
571 resolver.write_file(target, data)?;
572 }
573 }
574 }
575 Ok(())
576}
577
578#[cfg(miri)]
579fn exit_status_from_code(code: i32) -> ExitStatus {
580 #[cfg(unix)]
581 {
582 use std::os::unix::process::ExitStatusExt;
583 ExitStatusExt::from_raw(code << 8)
584 }
585 #[cfg(windows)]
586 {
587 use std::os::windows::process::ExitStatusExt;
588 ExitStatusExt::from_raw(code as u32)
589 }
590}
591
592#[cfg(not(miri))]
593pub type DefaultProcessManager = ShellProcessManager;
594
595#[cfg(miri)]
596pub type DefaultProcessManager = SyntheticProcessManager;
597
598pub fn default_process_manager() -> DefaultProcessManager {
599 DefaultProcessManager::default()
600}
601
602#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
603fn run_cmd(cmd: &mut ProcessCommand) -> Result<()> {
604 let status = cmd
605 .status()
606 .with_context(|| format!("failed to run {:?}", cmd))?;
607 if !status.success() {
608 bail!("command {:?} failed with status {}", cmd, status);
609 }
610 Ok(())
611}
612
613#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
615pub struct CommandBuilder {
616 inner: ProcessCommand,
617 program: OsString,
618 args: Vec<OsString>,
619 cwd: Option<PathBuf>,
620}
621
622#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
623impl CommandBuilder {
624 pub fn new(program: impl AsRef<OsStr>) -> Self {
625 let prog = program.as_ref().to_os_string();
626 Self {
627 inner: ProcessCommand::new(&prog),
628 program: prog,
629 args: Vec::new(),
630 cwd: None,
631 }
632 }
633
634 pub fn arg(&mut self, arg: impl AsRef<OsStr>) -> &mut Self {
635 let val = arg.as_ref().to_os_string();
636 self.inner.arg(&val);
637 self.args.push(val);
638 self
639 }
640
641 pub fn args<S, I>(&mut self, args: I) -> &mut Self
642 where
643 S: AsRef<OsStr>,
644 I: IntoIterator<Item = S>,
645 {
646 for arg in args {
647 self.arg(arg);
648 }
649 self
650 }
651
652 pub fn env(&mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> &mut Self {
653 self.inner.env(key, value);
654 self
655 }
656
657 pub fn stdin_file(&mut self, file: File) -> &mut Self {
658 self.inner.stdin(Stdio::from(file));
659 self
660 }
661
662 pub fn current_dir(&mut self, dir: impl AsRef<Path>) -> &mut Self {
663 let path = dir.as_ref();
664 self.inner.current_dir(path);
665 self.cwd = Some(path.to_path_buf());
666 self
667 }
668
669 pub fn status(&mut self) -> Result<ExitStatus> {
670 #[cfg(miri)]
671 {
672 let snap = self.snapshot();
673 synthetic_status(&snap)
674 }
675
676 #[cfg(not(miri))]
677 {
678 let desc = format!("{:?}", self.inner);
679 let status = self
680 .inner
681 .status()
682 .with_context(|| format!("failed to run {desc}"))?;
683 Ok(status)
684 }
685 }
686
687 pub fn output(&mut self) -> Result<CommandOutput> {
688 #[cfg(miri)]
689 {
690 let snap = self.snapshot();
691 synthetic_output(&snap)
692 }
693
694 #[cfg(not(miri))]
695 {
696 let desc = format!("{:?}", self.inner);
697 let out = self
698 .inner
699 .output()
700 .with_context(|| format!("failed to run {desc}"))?;
701 Ok(CommandOutput::from(out))
702 }
703 }
704
705 pub fn spawn(&mut self) -> Result<ChildHandle> {
706 #[cfg(miri)]
707 {
708 bail!("spawn is not supported under miri synthetic process backend")
709 }
710
711 #[cfg(not(miri))]
712 {
713 let desc = format!("{:?}", self.inner);
714 let child = self
715 .inner
716 .spawn()
717 .with_context(|| format!("failed to spawn {desc}"))?;
718 Ok(ChildHandle { child })
719 }
720 }
721
722 pub fn snapshot(&self) -> CommandSnapshot {
724 CommandSnapshot {
725 program: self.program.clone(),
726 args: self.args.clone(),
727 cwd: self.cwd.clone(),
728 }
729 }
730}
731
732#[derive(Clone, Debug, PartialEq, Eq)]
733#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
734pub struct CommandSnapshot {
735 pub program: OsString,
736 pub args: Vec<OsString>,
737 pub cwd: Option<PathBuf>,
738}
739
740pub struct CommandOutput {
741 pub status: ExitStatus,
742 pub stdout: Vec<u8>,
743 pub stderr: Vec<u8>,
744}
745
746impl CommandOutput {
747 pub fn success(&self) -> bool {
748 self.status.success()
749 }
750}
751
752#[allow(clippy::disallowed_types)]
753impl From<StdOutput> for CommandOutput {
754 fn from(value: StdOutput) -> Self {
755 Self {
756 status: value.status,
757 stdout: value.stdout,
758 stderr: value.stderr,
759 }
760 }
761}
762
763#[cfg(miri)]
764fn synthetic_status(snapshot: &CommandSnapshot) -> Result<ExitStatus> {
765 Ok(synthetic_output(snapshot)?.status)
766}
767
768#[cfg(miri)]
769fn synthetic_output(snapshot: &CommandSnapshot) -> Result<CommandOutput> {
770 let program = snapshot.program.to_string_lossy().to_string();
771 let args: Vec<String> = snapshot
772 .args
773 .iter()
774 .map(|a| a.to_string_lossy().to_string())
775 .collect();
776
777 if program == "git" {
778 return simulate_git(&args);
779 }
780 if program == "cargo" {
781 return simulate_cargo(&args);
782 }
783
784 Ok(CommandOutput {
785 status: exit_status_from_code(0),
786 stdout: Vec::new(),
787 stderr: Vec::new(),
788 })
789}
790
791#[cfg(miri)]
792fn simulate_git(args: &[String]) -> Result<CommandOutput> {
793 let mut iter = args.iter();
794 if matches!(iter.next(), Some(arg) if arg == "-C") {
795 let _ = iter.next();
796 }
797 let remaining: Vec<String> = iter.map(|s| s.to_string()).collect();
798
799 if remaining.len() >= 2 && remaining[0] == "rev-parse" && remaining[1] == "HEAD" {
800 return Ok(CommandOutput {
801 status: exit_status_from_code(0),
802 stdout: b"HEAD\n".to_vec(),
803 stderr: Vec::new(),
804 });
805 }
806
807 Ok(CommandOutput {
809 status: exit_status_from_code(0),
810 stdout: Vec::new(),
811 stderr: Vec::new(),
812 })
813}
814
815#[cfg(miri)]
816fn simulate_cargo(args: &[String]) -> Result<CommandOutput> {
817 let mut status = exit_status_from_code(0);
819 if args.iter().any(|a| a.contains("build_exit_fail")) {
820 status = exit_status_from_code(1);
821 }
822 Ok(CommandOutput {
823 status,
824 stdout: Vec::new(),
825 stderr: Vec::new(),
826 })
827}