Skip to main content

osdk_core/
process.rs

1//! Helpers for running external commands (used by delegate backends like
2//! rustup and corepack).
3//!
4//! [`CommandRunner`] is the injectable process boundary used by native
5//! container-runtime adapters. Captured commands have explicit wall-clock and
6//! byte limits. Foreground commands deliberately inherit all three standard
7//! streams and are spawned exactly once without a shell.
8
9use std::collections::BTreeMap;
10use std::ffi::{OsStr, OsString};
11use std::io::{self, Read};
12use std::path::{Path, PathBuf};
13use std::process::{Command, ExitStatus, Stdio};
14use std::sync::mpsc::{self, Receiver, TryRecvError};
15use std::thread;
16use std::time::{Duration, Instant};
17
18use crate::error::{Error, Result};
19
20/// A command description that is intentionally not serializable.
21///
22/// Argument and environment values may contain credentials. Diagnostic output
23/// must use the redacted evidence types in `container::redact`, never this raw
24/// process description.
25#[derive(Clone)]
26pub struct CommandSpec {
27    program: OsString,
28    args: Vec<OsString>,
29    env: BTreeMap<OsString, OsString>,
30    cwd: Option<PathBuf>,
31    clear_env: bool,
32}
33
34impl CommandSpec {
35    pub fn new(program: impl Into<OsString>) -> Self {
36        Self {
37            program: program.into(),
38            args: Vec::new(),
39            env: BTreeMap::new(),
40            cwd: None,
41            clear_env: false,
42        }
43    }
44
45    pub fn arg(mut self, arg: impl Into<OsString>) -> Self {
46        self.args.push(arg.into());
47        self
48    }
49
50    pub fn args<I, S>(mut self, args: I) -> Self
51    where
52        I: IntoIterator<Item = S>,
53        S: Into<OsString>,
54    {
55        self.args.extend(args.into_iter().map(Into::into));
56        self
57    }
58
59    /// Add an environment override on top of the inherited environment.
60    pub fn env(mut self, key: impl Into<OsString>, value: impl Into<OsString>) -> Self {
61        self.env.insert(key.into(), value.into());
62        self
63    }
64
65    /// Add environment overrides on top of the inherited environment.
66    pub fn envs<I, K, V>(mut self, env: I) -> Self
67    where
68        I: IntoIterator<Item = (K, V)>,
69        K: Into<OsString>,
70        V: Into<OsString>,
71    {
72        self.env.extend(
73            env.into_iter()
74                .map(|(key, value)| (key.into(), value.into())),
75        );
76        self
77    }
78
79    pub fn current_dir(mut self, cwd: impl Into<PathBuf>) -> Self {
80        self.cwd = Some(cwd.into());
81        self
82    }
83
84    /// Start the child with an empty environment before applying explicit
85    /// overrides. This keeps ambient package-manager configuration out of
86    /// managed delegate operations.
87    pub fn clear_env(mut self) -> Self {
88        self.clear_env = true;
89        self
90    }
91
92    pub fn program(&self) -> &OsStr {
93        &self.program
94    }
95
96    pub fn arguments(&self) -> &[OsString] {
97        &self.args
98    }
99
100    pub fn environment(&self) -> &BTreeMap<OsString, OsString> {
101        &self.env
102    }
103
104    pub fn working_directory(&self) -> Option<&Path> {
105        self.cwd.as_deref()
106    }
107
108    pub fn environment_is_cleared(&self) -> bool {
109        self.clear_env
110    }
111
112    fn command(&self) -> Command {
113        let mut command = Command::new(&self.program);
114        if self.clear_env {
115            command.env_clear();
116        }
117        command.args(&self.args).envs(&self.env);
118        if let Some(cwd) = &self.cwd {
119            command.current_dir(cwd);
120        }
121        command
122    }
123}
124
125impl std::fmt::Debug for CommandSpec {
126    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127        formatter
128            .debug_struct("CommandSpec")
129            .field("argument_count", &self.args.len())
130            .field("environment_count", &self.env.len())
131            .field("has_working_directory", &self.cwd.is_some())
132            .field("clears_environment", &self.clear_env)
133            .finish()
134    }
135}
136
137/// Hard limits for a captured child process.
138#[derive(Clone, Copy, Debug, PartialEq, Eq)]
139pub struct CaptureLimits {
140    pub timeout: Duration,
141    pub stdout_bytes: usize,
142    pub stderr_bytes: usize,
143}
144
145impl CaptureLimits {
146    pub const fn new(timeout: Duration, stdout_bytes: usize, stderr_bytes: usize) -> Self {
147        Self {
148            timeout,
149            stdout_bytes,
150            stderr_bytes,
151        }
152    }
153}
154
155impl Default for CaptureLimits {
156    fn default() -> Self {
157        Self::new(Duration::from_secs(10), 64 * 1024, 64 * 1024)
158    }
159}
160
161/// Bounded raw output. This type deliberately does not implement `Serialize`;
162/// native output can contain credentials and must be converted to typed,
163/// redacted diagnostic evidence first.
164#[derive(Clone, Default, PartialEq, Eq)]
165pub struct CapturedOutput {
166    pub stdout: Vec<u8>,
167    pub stderr: Vec<u8>,
168    pub stdout_truncated: bool,
169    pub stderr_truncated: bool,
170    pub elapsed: Duration,
171}
172
173impl std::fmt::Debug for CapturedOutput {
174    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175        formatter
176            .debug_struct("CapturedOutput")
177            .field("stdout_bytes", &self.stdout.len())
178            .field("stderr_bytes", &self.stderr.len())
179            .field("stdout_truncated", &self.stdout_truncated)
180            .field("stderr_truncated", &self.stderr_truncated)
181            .field("elapsed", &self.elapsed)
182            .finish()
183    }
184}
185
186/// Result of requesting termination after a capture deadline.
187#[derive(Clone, Copy, Debug, PartialEq, Eq)]
188pub enum TerminationStatus {
189    Requested,
190    AlreadyExited,
191    Failed(io::ErrorKind),
192}
193
194/// Non-secret context for an otherwise ambiguous spawn error.
195#[derive(Clone, Copy, Debug, PartialEq, Eq)]
196pub enum SpawnContext {
197    /// The command supplied an explicit working directory. The operating
198    /// system does not identify whether it or the executable caused errors
199    /// such as `NotFound` or `PermissionDenied`.
200    WithWorkingDirectory,
201}
202
203/// The explicit result of bounded captured execution.
204#[derive(Clone, PartialEq, Eq)]
205pub enum CommandOutcome {
206    /// The executable could not be found.
207    NotInstalled,
208    /// The executable was found but the operating system denied execution.
209    PermissionDenied,
210    /// Spawning failed in a context where the error cannot safely be
211    /// attributed to executable discovery, for example when a working
212    /// directory was configured.
213    SpawnFailed {
214        kind: io::ErrorKind,
215        context: SpawnContext,
216    },
217    /// The child exceeded its wall-clock limit. `termination` reports whether
218    /// the operating system accepted the termination request; reaping proceeds
219    /// asynchronously so the timeout remains a hard return bound.
220    TimedOut {
221        output: CapturedOutput,
222        termination: TerminationStatus,
223    },
224    /// The child exited normally or by signal. Non-zero status is not an I/O
225    /// error and is returned here unchanged.
226    Exited {
227        status: ExitStatus,
228        output: CapturedOutput,
229    },
230    /// Spawning, polling, or setting up capture failed for another I/O reason.
231    ExecutionFailed {
232        kind: io::ErrorKind,
233        output: CapturedOutput,
234    },
235}
236
237impl std::fmt::Debug for CommandOutcome {
238    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239        match self {
240            Self::NotInstalled => formatter.write_str("NotInstalled"),
241            Self::PermissionDenied => formatter.write_str("PermissionDenied"),
242            Self::SpawnFailed { kind, context } => formatter
243                .debug_struct("SpawnFailed")
244                .field("kind", kind)
245                .field("context", context)
246                .finish(),
247            Self::TimedOut {
248                output,
249                termination,
250            } => formatter
251                .debug_struct("TimedOut")
252                .field("output", output)
253                .field("termination", termination)
254                .finish(),
255            Self::Exited { status, output } => formatter
256                .debug_struct("Exited")
257                .field("status", status)
258                .field("output", output)
259                .finish(),
260            Self::ExecutionFailed { kind, output } => formatter
261                .debug_struct("ExecutionFailed")
262                .field("kind", kind)
263                .field("output", output)
264                .finish(),
265        }
266    }
267}
268
269impl CommandOutcome {
270    pub fn output(&self) -> Option<&CapturedOutput> {
271        match self {
272            Self::TimedOut { output, .. }
273            | Self::Exited { output, .. }
274            | Self::ExecutionFailed { output, .. } => Some(output),
275            Self::NotInstalled | Self::PermissionDenied | Self::SpawnFailed { .. } => None,
276        }
277    }
278
279    pub fn exit_status(&self) -> Option<ExitStatus> {
280        match self {
281            Self::Exited { status, .. } => Some(*status),
282            _ => None,
283        }
284    }
285}
286
287/// Injectable boundary for all native command execution.
288pub trait CommandRunner: Send + Sync {
289    /// Execute with null stdin, concurrently drained stdout/stderr, and hard
290    /// time/byte ceilings.
291    fn run_captured(&self, command: &CommandSpec, limits: CaptureLimits) -> CommandOutcome;
292
293    /// Spawn one child directly, with inherited stdin/stdout/stderr, then
294    /// return its native exit status. Implementations must not retry or invoke
295    /// a shell.
296    fn run_foreground(&self, command: &CommandSpec) -> io::Result<ExitStatus>;
297}
298
299/// The operating-system process runner.
300#[derive(Clone, Copy, Debug, Default)]
301pub struct SystemCommandRunner;
302
303impl CommandRunner for SystemCommandRunner {
304    fn run_captured(&self, command: &CommandSpec, limits: CaptureLimits) -> CommandOutcome {
305        run_captured(command, limits)
306    }
307
308    fn run_foreground(&self, command: &CommandSpec) -> io::Result<ExitStatus> {
309        let mut child = command.command();
310        child
311            .stdin(Stdio::inherit())
312            .stdout(Stdio::inherit())
313            .stderr(Stdio::inherit());
314        child.status()
315    }
316}
317
318#[derive(Clone, Copy)]
319enum Stream {
320    Stdout,
321    Stderr,
322}
323
324enum StreamEvent {
325    Data(Stream, Vec<u8>),
326    Truncated(Stream),
327    Done(Stream),
328}
329
330#[derive(Default)]
331struct CaptureState {
332    output: CapturedOutput,
333    stdout_done: bool,
334    stderr_done: bool,
335}
336
337impl CaptureState {
338    fn apply(&mut self, event: StreamEvent) {
339        match event {
340            StreamEvent::Data(Stream::Stdout, bytes) => self.output.stdout.extend(bytes),
341            StreamEvent::Data(Stream::Stderr, bytes) => self.output.stderr.extend(bytes),
342            StreamEvent::Truncated(Stream::Stdout) => self.output.stdout_truncated = true,
343            StreamEvent::Truncated(Stream::Stderr) => self.output.stderr_truncated = true,
344            StreamEvent::Done(Stream::Stdout) => self.stdout_done = true,
345            StreamEvent::Done(Stream::Stderr) => self.stderr_done = true,
346        }
347    }
348
349    fn complete(&self) -> bool {
350        self.stdout_done && self.stderr_done
351    }
352}
353
354fn run_captured(command: &CommandSpec, limits: CaptureLimits) -> CommandOutcome {
355    let started = Instant::now();
356    if let Some(cwd) = &command.cwd {
357        match std::fs::metadata(cwd) {
358            Ok(metadata) if metadata.is_dir() => {}
359            Ok(_) => {
360                return CommandOutcome::SpawnFailed {
361                    kind: io::ErrorKind::NotADirectory,
362                    context: SpawnContext::WithWorkingDirectory,
363                };
364            }
365            Err(error) => {
366                return CommandOutcome::SpawnFailed {
367                    kind: error.kind(),
368                    context: SpawnContext::WithWorkingDirectory,
369                };
370            }
371        }
372    }
373    let mut process = command.command();
374    process
375        .stdin(Stdio::null())
376        .stdout(Stdio::piped())
377        .stderr(Stdio::piped());
378
379    let mut child = match process.spawn() {
380        Ok(child) => child,
381        Err(error) => {
382            if command.cwd.is_some() {
383                return CommandOutcome::SpawnFailed {
384                    kind: error.kind(),
385                    context: SpawnContext::WithWorkingDirectory,
386                };
387            }
388            return match error.kind() {
389                io::ErrorKind::NotFound => CommandOutcome::NotInstalled,
390                io::ErrorKind::PermissionDenied => CommandOutcome::PermissionDenied,
391                kind => CommandOutcome::ExecutionFailed {
392                    kind,
393                    output: CapturedOutput {
394                        elapsed: started.elapsed(),
395                        ..CapturedOutput::default()
396                    },
397                },
398            };
399        }
400    };
401
402    let Some(stdout) = child.stdout.take() else {
403        terminate_and_reap(child);
404        return capture_failure(started, io::ErrorKind::Other);
405    };
406    let Some(stderr) = child.stderr.take() else {
407        terminate_and_reap(child);
408        return capture_failure(started, io::ErrorKind::Other);
409    };
410
411    let (sender, receiver) = mpsc::channel();
412    let stdout_sender = sender.clone();
413    if thread::Builder::new()
414        .name("osdk-stdout-drain".into())
415        .spawn(move || drain_stream(stdout, Stream::Stdout, limits.stdout_bytes, stdout_sender))
416        .is_err()
417    {
418        terminate_and_reap(child);
419        return capture_failure(started, io::ErrorKind::Other);
420    }
421    if thread::Builder::new()
422        .name("osdk-stderr-drain".into())
423        .spawn(move || drain_stream(stderr, Stream::Stderr, limits.stderr_bytes, sender))
424        .is_err()
425    {
426        terminate_and_reap(child);
427        return capture_failure(started, io::ErrorKind::Other);
428    }
429
430    let mut capture = CaptureState::default();
431    loop {
432        receive_available(&receiver, &mut capture);
433        match child.try_wait() {
434            Ok(Some(status)) => {
435                finish_capture(
436                    &receiver,
437                    &mut capture,
438                    limits.timeout.saturating_sub(started.elapsed()),
439                );
440                capture.output.elapsed = started.elapsed();
441                return CommandOutcome::Exited {
442                    status,
443                    output: capture.output,
444                };
445            }
446            Ok(None) if started.elapsed() >= limits.timeout => {
447                // Resolve the exit/timeout race once more before terminating.
448                if let Ok(Some(status)) = child.try_wait() {
449                    finish_capture(&receiver, &mut capture, Duration::ZERO);
450                    capture.output.elapsed = started.elapsed();
451                    return CommandOutcome::Exited {
452                        status,
453                        output: capture.output,
454                    };
455                }
456                let termination = terminate_and_reap(child);
457                finish_capture(&receiver, &mut capture, Duration::ZERO);
458                capture.output.elapsed = started.elapsed();
459                return CommandOutcome::TimedOut {
460                    output: capture.output,
461                    termination,
462                };
463            }
464            Ok(None) => {
465                let remaining = limits.timeout.saturating_sub(started.elapsed());
466                thread::sleep(remaining.min(Duration::from_millis(2)));
467            }
468            Err(error) => {
469                let kind = error.kind();
470                terminate_and_reap(child);
471                finish_capture(&receiver, &mut capture, Duration::ZERO);
472                capture.output.elapsed = started.elapsed();
473                return CommandOutcome::ExecutionFailed {
474                    kind,
475                    output: capture.output,
476                };
477            }
478        }
479    }
480}
481
482fn capture_failure(started: Instant, kind: io::ErrorKind) -> CommandOutcome {
483    CommandOutcome::ExecutionFailed {
484        kind,
485        output: CapturedOutput {
486            elapsed: started.elapsed(),
487            ..CapturedOutput::default()
488        },
489    }
490}
491
492fn drain_stream(
493    mut reader: impl Read,
494    stream: Stream,
495    limit: usize,
496    sender: mpsc::Sender<StreamEvent>,
497) {
498    let mut retained = 0usize;
499    let mut reported_truncation = false;
500    let mut buffer = [0u8; 8 * 1024];
501
502    loop {
503        let count = match reader.read(&mut buffer) {
504            Ok(0) => break,
505            Ok(count) => count,
506            Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
507            Err(_) => break,
508        };
509
510        let keep = count.min(limit.saturating_sub(retained));
511        if keep > 0 {
512            if sender
513                .send(StreamEvent::Data(stream, buffer[..keep].to_vec()))
514                .is_err()
515            {
516                return;
517            }
518            retained += keep;
519        }
520        if keep < count && !reported_truncation {
521            if sender.send(StreamEvent::Truncated(stream)).is_err() {
522                return;
523            }
524            reported_truncation = true;
525        }
526    }
527
528    let _ = sender.send(StreamEvent::Done(stream));
529}
530
531fn receive_available(receiver: &Receiver<StreamEvent>, capture: &mut CaptureState) {
532    loop {
533        match receiver.try_recv() {
534            Ok(event) => capture.apply(event),
535            Err(TryRecvError::Empty | TryRecvError::Disconnected) => return,
536        }
537    }
538}
539
540fn finish_capture(
541    receiver: &Receiver<StreamEvent>,
542    capture: &mut CaptureState,
543    max_wait: Duration,
544) {
545    receive_available(receiver, capture);
546    let started = Instant::now();
547    while !capture.complete() {
548        let remaining = max_wait.saturating_sub(started.elapsed());
549        if remaining.is_zero() {
550            break;
551        }
552        match receiver.recv_timeout(remaining) {
553            Ok(event) => capture.apply(event),
554            Err(_) => break,
555        }
556    }
557    receive_available(receiver, capture);
558}
559
560fn terminate_and_reap(mut child: std::process::Child) -> TerminationStatus {
561    let kill_error = child.kill().err().map(|error| error.kind());
562    let (termination, reaped) = match child.try_wait() {
563        Ok(Some(_)) => (TerminationStatus::AlreadyExited, true),
564        Ok(None) => (
565            kill_error
566                .map(TerminationStatus::Failed)
567                .unwrap_or(TerminationStatus::Requested),
568            false,
569        ),
570        Err(error) => (
571            TerminationStatus::Failed(kill_error.unwrap_or_else(|| error.kind())),
572            false,
573        ),
574    };
575
576    if !reaped {
577        // Reaping cannot extend the caller's wall-clock limit. Once kill has
578        // been requested, a detached waiter prevents a zombie without delaying
579        // the diagnostic result.
580        let _ = thread::Builder::new()
581            .name("osdk-child-reaper".into())
582            .spawn(move || {
583                let _ = child.wait();
584            });
585    }
586    termination
587}
588
589/// Run a command to completion, capturing stderr on failure. `env` overrides are
590/// applied on top of the inherited environment.
591pub fn run(
592    program: &str,
593    args: &[&str],
594    env: &BTreeMap<String, String>,
595    cwd: Option<&Path>,
596) -> Result<()> {
597    let mut cmd = Command::new(program);
598    cmd.args(args);
599    for (k, v) in env {
600        cmd.env(k, v);
601    }
602    if let Some(dir) = cwd {
603        cmd.current_dir(dir);
604    }
605    let output = cmd.output().map_err(|e| Error::Command {
606        cmd: format!("{program} {}", args.join(" ")),
607        status: format!("failed to spawn: {e}"),
608        stderr: None,
609    })?;
610    if output.status.success() {
611        Ok(())
612    } else {
613        Err(Error::Command {
614            cmd: format!("{program} {}", args.join(" ")),
615            status: output.status.to_string(),
616            stderr: Some(String::from_utf8_lossy(&output.stderr).into_owned()),
617        })
618    }
619}
620
621pub fn output(
622    program: &str,
623    args: &[&str],
624    env: &BTreeMap<String, String>,
625    cwd: Option<&Path>,
626) -> Result<std::process::Output> {
627    let mut command = Command::new(program);
628    command.args(args);
629    command.envs(env);
630    if let Some(directory) = cwd {
631        command.current_dir(directory);
632    }
633    let output = command.output().map_err(|error| Error::Command {
634        cmd: format!("{program} {}", args.join(" ")),
635        status: format!("failed to spawn: {error}"),
636        stderr: None,
637    })?;
638    if output.status.success() {
639        Ok(output)
640    } else {
641        Err(Error::Command {
642            cmd: format!("{program} {}", args.join(" ")),
643            status: output.status.to_string(),
644            stderr: Some(String::from_utf8_lossy(&output.stderr).into_owned()),
645        })
646    }
647}
648
649/// Whether `program` is resolvable on PATH.
650pub fn exists(program: &str) -> bool {
651    which::which(program).is_ok()
652}
653
654#[cfg(test)]
655mod tests {
656    use std::io::Write;
657
658    use super::*;
659
660    const CHILD_MODE: &str = "OSDK_PROCESS_TEST_CHILD_MODE";
661
662    fn child_command(test_name: &str, mode: &str) -> CommandSpec {
663        CommandSpec::new(std::env::current_exe().unwrap())
664            .args(["--exact", test_name, "--nocapture"])
665            .env(CHILD_MODE, mode)
666    }
667
668    #[test]
669    fn missing_program_is_classified_as_not_installed() {
670        let command = CommandSpec::new(
671            std::env::temp_dir().join("osdk-command-that-does-not-exist-4d912789"),
672        );
673        assert!(matches!(
674            SystemCommandRunner.run_captured(&command, CaptureLimits::default()),
675            CommandOutcome::NotInstalled
676        ));
677    }
678
679    #[test]
680    fn missing_working_directory_is_a_spawn_failure_not_not_installed() {
681        let temporary = tempfile::tempdir().unwrap();
682        let missing = temporary.path().join("missing-working-directory");
683        let command = CommandSpec::new(std::env::current_exe().unwrap()).current_dir(missing);
684
685        assert!(matches!(
686            SystemCommandRunner.run_captured(&command, CaptureLimits::default()),
687            CommandOutcome::SpawnFailed {
688                kind: io::ErrorKind::NotFound,
689                context: SpawnContext::WithWorkingDirectory,
690            }
691        ));
692    }
693
694    #[cfg(unix)]
695    #[test]
696    fn non_executable_program_is_classified_as_permission_denied() {
697        use std::os::unix::fs::PermissionsExt;
698
699        let temporary = tempfile::tempdir().unwrap();
700        let program = temporary.path().join("not-executable");
701        std::fs::write(&program, b"not executable").unwrap();
702        std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o600)).unwrap();
703
704        assert!(matches!(
705            SystemCommandRunner.run_captured(&CommandSpec::new(program), CaptureLimits::default()),
706            CommandOutcome::PermissionDenied
707        ));
708    }
709
710    #[test]
711    fn captured_output_is_bounded_while_both_pipes_are_drained() {
712        let outcome = SystemCommandRunner.run_captured(
713            &child_command("process::tests::child_emits_large_output", "large-output"),
714            CaptureLimits::new(Duration::from_secs(10), 113, 97),
715        );
716        let CommandOutcome::Exited { status, output } = outcome else {
717            panic!("expected exited child, got {outcome:?}");
718        };
719
720        assert!(status.success());
721        assert_eq!(output.stdout.len(), 113);
722        assert_eq!(output.stderr.len(), 97);
723        assert!(output.stdout_truncated);
724        assert!(output.stderr_truncated);
725    }
726
727    #[test]
728    fn captured_process_has_a_wall_clock_timeout() {
729        let outcome = SystemCommandRunner.run_captured(
730            &child_command("process::tests::child_blocks", "block"),
731            CaptureLimits::new(Duration::from_millis(100), 1024, 1024),
732        );
733        let CommandOutcome::TimedOut { output, .. } = outcome else {
734            panic!("expected timed-out child, got {outcome:?}");
735        };
736
737        assert!(output.elapsed >= Duration::from_millis(50));
738        let upper_bound = if cfg!(windows) {
739            Duration::from_secs(3)
740        } else {
741            Duration::from_millis(750)
742        };
743        assert!(output.elapsed < upper_bound, "{output:?}");
744    }
745
746    #[test]
747    fn debug_output_does_not_expose_captured_bytes_or_command_details() {
748        let secret = "never-print-this-secret";
749        let command = CommandSpec::new(secret)
750            .arg(secret)
751            .env(secret, secret)
752            .current_dir(secret);
753        let output = CapturedOutput {
754            stdout: secret.as_bytes().to_vec(),
755            stderr: secret.as_bytes().to_vec(),
756            ..CapturedOutput::default()
757        };
758        let outcome = CommandOutcome::ExecutionFailed {
759            kind: io::ErrorKind::Other,
760            output,
761        };
762
763        assert!(!format!("{command:?}").contains(secret));
764        assert!(!format!("{outcome:?}").contains(secret));
765    }
766
767    #[test]
768    fn child_emits_large_output() {
769        if std::env::var_os(CHILD_MODE).as_deref() != Some(OsStr::new("large-output")) {
770            return;
771        }
772
773        std::io::stdout()
774            .write_all(&vec![b'o'; 128 * 1024])
775            .unwrap();
776        std::io::stderr()
777            .write_all(&vec![b'e'; 128 * 1024])
778            .unwrap();
779        std::io::stdout().flush().unwrap();
780        std::io::stderr().flush().unwrap();
781    }
782
783    #[test]
784    fn child_blocks() {
785        if std::env::var_os(CHILD_MODE).as_deref() != Some(OsStr::new("block")) {
786            return;
787        }
788
789        thread::sleep(Duration::from_secs(5));
790    }
791}