Skip to main content

cargo_codesign/
subprocess.rs

1use std::fmt;
2use std::process::Command;
3
4#[derive(Debug)]
5pub struct RunOutput {
6    pub stdout: String,
7    pub stderr: String,
8    pub success: bool,
9    pub code: Option<i32>,
10}
11
12#[derive(Debug, thiserror::Error)]
13pub enum SubprocessError {
14    #[error("failed to execute {command}: {source}")]
15    SpawnFailed {
16        command: String,
17        source: std::io::Error,
18    },
19}
20
21/// A command-line argument that may or may not contain sensitive data.
22///
23/// `Display` always redacts `Sensitive` values, so accidental logging or
24/// formatting cannot leak secrets.
25#[derive(Debug, Clone, Copy)]
26pub enum Arg<'a> {
27    Plain(&'a str),
28    Sensitive(&'a str),
29}
30
31impl<'a> Arg<'a> {
32    pub fn sensitive(s: &'a str) -> Self {
33        Arg::Sensitive(s)
34    }
35
36    pub fn as_str(&self) -> &'a str {
37        match self {
38            Arg::Plain(s) | Arg::Sensitive(s) => s,
39        }
40    }
41}
42
43impl<'a> From<&'a str> for Arg<'a> {
44    fn from(s: &'a str) -> Self {
45        Arg::Plain(s)
46    }
47}
48
49impl fmt::Display for Arg<'_> {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        match self {
52            Arg::Plain(s) => f.write_str(s),
53            Arg::Sensitive(_) => f.write_str("****"),
54        }
55    }
56}
57
58/// Run a subprocess, passing all arguments as plain (non-sensitive) strings.
59pub fn run(command: &str, args: &[&str], verbose: bool) -> Result<RunOutput, SubprocessError> {
60    let typed: Vec<Arg<'_>> = args.iter().map(|&s| Arg::Plain(s)).collect();
61    run_args(command, &typed, verbose)
62}
63
64/// Run a subprocess with typed arguments that distinguish sensitive values.
65pub fn run_args(
66    command: &str,
67    args: &[Arg<'_>],
68    verbose: bool,
69) -> Result<RunOutput, SubprocessError> {
70    if verbose {
71        let display: Vec<_> = args.iter().map(ToString::to_string).collect();
72        eprintln!("  $ {} {}", command, display.join(" "));
73    }
74
75    let raw: Vec<&str> = args.iter().map(Arg::as_str).collect();
76    let output =
77        Command::new(command)
78            .args(&raw)
79            .output()
80            .map_err(|e| SubprocessError::SpawnFailed {
81                command: command.to_string(),
82                source: e,
83            })?;
84
85    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
86    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
87
88    if verbose && !stdout.is_empty() {
89        eprint!("{stdout}");
90    }
91    if verbose && !stderr.is_empty() {
92        eprint!("{stderr}");
93    }
94
95    Ok(RunOutput {
96        stdout,
97        stderr,
98        success: output.status.success(),
99        code: output.status.code(),
100    })
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn sensitive_arg_display_is_redacted() {
109        let arg = Arg::sensitive("super-secret-password");
110        assert_eq!(arg.to_string(), "****");
111    }
112
113    #[test]
114    fn plain_arg_display_shows_value() {
115        let arg = Arg::Plain("visible-value");
116        assert_eq!(arg.to_string(), "visible-value");
117    }
118
119    #[test]
120    fn sensitive_arg_as_str_exposes_real_value() {
121        let arg = Arg::sensitive("real-password");
122        assert_eq!(arg.as_str(), "real-password");
123    }
124
125    #[test]
126    fn from_str_creates_plain_arg() {
127        let arg: Arg<'_> = "hello".into();
128        assert_eq!(arg.to_string(), "hello");
129        assert_eq!(arg.as_str(), "hello");
130    }
131
132    #[test]
133    fn mixed_args_display_redacts_only_sensitive() {
134        let args: Vec<Arg<'_>> = vec![
135            "create-keychain".into(),
136            "-p".into(),
137            Arg::sensitive("my-secret"),
138            "keychain-name".into(),
139        ];
140        let display: Vec<String> = args.iter().map(ToString::to_string).collect();
141        assert_eq!(display, &["create-keychain", "-p", "****", "keychain-name"]);
142    }
143}