Skip to main content

release_kit/setup/
process.rs

1//! The process adapter: the obligations a wrapper owes its child.
2//!
3//! Both output pipes are drained concurrently while stdin is being written,
4//! because filling a pipe buffer while the child blocks on stdin is a
5//! deadlock. The environment is constructed by the caller and applied over
6//! `env_clear`. A child killed by signal N surfaces as 128+N, so an
7//! interrupted setup dies the way an operator expects. Interruption reaches
8//! the child through the shared process group — a terminal's SIGINT is
9//! delivered to parent and child alike — and this adapter installs no
10//! handler of its own: forwarding a signal aimed at `rk` alone would need a
11//! raw `kill(2)`, which the crate-wide `unsafe_code = "forbid"` rules out.
12//! Chunks reach the caller in arrival order, raw; redaction is the caller's
13//! job, because only the caller knows the secrets of the run.
14
15use std::ffi::OsString;
16use std::io::{Read, Write as _};
17use std::path::PathBuf;
18use std::process::{Command, Stdio};
19use std::sync::mpsc;
20
21use zeroize::Zeroizing;
22
23use crate::events::ChildStream;
24
25/// One spawn request, fully constructed before anything runs.
26pub struct Exec {
27    /// The program to spawn.
28    pub program: OsString,
29    /// Its arguments; a secret never appears here.
30    pub args: Vec<OsString>,
31    /// The constructed environment, applied over `env_clear`.
32    pub env: Vec<(OsString, OsString)>,
33    /// The working directory.
34    pub cwd: PathBuf,
35    /// Bytes written to the child's stdin, then closed. Scrubbed on drop,
36    /// because this is the channel a credential travels on.
37    pub stdin: Option<Zeroizing<Vec<u8>>>,
38}
39
40impl std::fmt::Debug for Exec {
41    /// Everything but `stdin`: that field carries a credential, and a
42    /// derived rendering would put it in whatever formatted an `Exec`.
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        f.debug_struct("Exec")
45            .field("program", &self.program)
46            .field("args", &self.args)
47            .field("cwd", &self.cwd)
48            .field("stdin", &self.stdin.as_ref().map(|_| "[redacted]"))
49            .finish_non_exhaustive()
50    }
51}
52
53impl Exec {
54    /// The one `+ `-prefixed echo line: a rendering of the typed argument
55    /// list, produced here and never by shell tracing.
56    #[must_use]
57    pub fn echo(&self) -> String {
58        let mut line = String::from("+ ");
59        line.push_str(&self.program.to_string_lossy());
60        for arg in &self.args {
61            line.push(' ');
62            line.push_str(&arg.to_string_lossy());
63        }
64        line
65    }
66}
67
68/// What a finished child left behind.
69#[derive(Debug)]
70pub struct Outcome {
71    /// The surfaced exit code: the child's own, or 128+N for signal N.
72    pub exit_code: i32,
73    /// Everything the child wrote to stdout, in order.
74    pub stdout: Vec<u8>,
75    /// Everything the child wrote to stderr, in order.
76    pub stderr: Vec<u8>,
77}
78
79impl Outcome {
80    /// Whether the child succeeded.
81    #[must_use]
82    pub const fn success(&self) -> bool {
83        self.exit_code == 0
84    }
85}
86
87/// Run a child to completion, draining both pipes concurrently and calling
88/// `on_chunk` for every chunk in arrival order.
89///
90/// # Errors
91///
92/// Returns the spawn failure; a child that runs and fails is an [`Outcome`],
93/// not an error.
94pub fn run(exec: &Exec, mut on_chunk: impl FnMut(ChildStream, &[u8])) -> std::io::Result<Outcome> {
95    let mut command = Command::new(&exec.program);
96    command
97        .args(&exec.args)
98        .env_clear()
99        .envs(exec.env.iter().map(|(k, v)| (k, v)))
100        .current_dir(&exec.cwd)
101        .stdin(if exec.stdin.is_some() {
102            Stdio::piped()
103        } else {
104            Stdio::null()
105        })
106        .stdout(Stdio::piped())
107        .stderr(Stdio::piped());
108    let mut child = command.spawn()?;
109
110    let (sender, receiver) = mpsc::channel::<(ChildStream, Vec<u8>)>();
111    let mut drains = Vec::new();
112    if let Some(pipe) = child.stdout.take() {
113        drains.push(spawn_drain(pipe, ChildStream::Stdout, sender.clone()));
114    }
115    if let Some(pipe) = child.stderr.take() {
116        drains.push(spawn_drain(pipe, ChildStream::Stderr, sender));
117    }
118
119    // The drains are already running, so this write cannot deadlock against
120    // a full output pipe; a child that exits early surfaces as a broken
121    // pipe, which only means it stopped reading.
122    if let Some(bytes) = &exec.stdin {
123        if let Some(mut stdin) = child.stdin.take() {
124            let _ = stdin.write_all(bytes);
125        }
126    }
127
128    let mut stdout = Vec::new();
129    let mut stderr = Vec::new();
130    for (stream, chunk) in receiver {
131        on_chunk(stream, &chunk);
132        match stream {
133            ChildStream::Stdout => stdout.extend_from_slice(&chunk),
134            ChildStream::Stderr => stderr.extend_from_slice(&chunk),
135        }
136    }
137    for drain in drains {
138        let _ = drain.join();
139    }
140    let status = child.wait()?;
141    Ok(Outcome {
142        exit_code: surface_exit(status),
143        stdout,
144        stderr,
145    })
146}
147
148/// Drain one pipe to the channel in chunks, preserving arrival order within
149/// the stream and byte fidelity across invalid UTF-8.
150fn spawn_drain(
151    mut pipe: impl Read + Send + 'static,
152    stream: ChildStream,
153    sender: mpsc::Sender<(ChildStream, Vec<u8>)>,
154) -> std::thread::JoinHandle<()> {
155    std::thread::spawn(move || {
156        let mut buffer = [0u8; 8192];
157        loop {
158            match pipe.read(&mut buffer) {
159                Ok(0) | Err(_) => break,
160                Ok(n) => {
161                    if sender.send((stream, buffer[..n].to_vec())).is_err() {
162                        break;
163                    }
164                }
165            }
166        }
167    })
168}
169
170/// The exit code a caller sees: the child's own, or 128+N for signal N.
171fn surface_exit(status: std::process::ExitStatus) -> i32 {
172    if let Some(code) = status.code() {
173        return code;
174    }
175    #[cfg(unix)]
176    {
177        use std::os::unix::process::ExitStatusExt as _;
178        if let Some(signal) = status.signal() {
179            return 128 + signal;
180        }
181    }
182    -1
183}
184
185/// Replace every occurrence of each secret in `chunk` with `[redacted]`.
186///
187/// Chunk-level replacement is the guarantee the tests hold; a secret split
188/// exactly across a chunk boundary is out of reach here, which is one more
189/// reason no step ever prints one.
190#[must_use]
191pub fn redact(chunk: &[u8], secrets: &[impl AsRef<[u8]>]) -> Vec<u8> {
192    let mut out = chunk.to_vec();
193    for secret in secrets {
194        let secret = secret.as_ref();
195        if secret.is_empty() {
196            continue;
197        }
198        while let Some(pos) = out
199            .windows(secret.len())
200            .position(|window| window == secret)
201        {
202            out.splice(pos..pos + secret.len(), b"[redacted]".iter().copied());
203        }
204    }
205    out
206}
207
208#[cfg(test)]
209mod tests {
210    #![allow(clippy::expect_used)]
211
212    use super::{Exec, Zeroizing, redact, run};
213    use std::path::PathBuf;
214
215    fn sh(script: &str, stdin: Option<Vec<u8>>) -> Exec {
216        Exec {
217            program: "sh".into(),
218            args: vec!["-c".into(), script.into()],
219            env: vec![(
220                "PATH".into(),
221                std::env::var_os("PATH").expect("a PATH exists"),
222            )],
223            cwd: PathBuf::from("."),
224            stdin: stdin.map(Zeroizing::new),
225        }
226    }
227
228    /// A formatted spawn request never carries what it writes to stdin.
229    #[test]
230    fn a_debug_rendering_omits_the_stdin_bytes() {
231        let exec = sh("true", Some(b"sekret-stdin-value".to_vec()));
232        let rendered = format!("{exec:?}");
233        assert!(!rendered.contains("sekret-stdin-value"));
234        assert!(rendered.contains("[redacted]"));
235    }
236
237    /// The concurrent-drain obligation: output far past the pipe buffer
238    /// completes while stdin is being written.
239    #[test]
240    fn a_chatty_child_with_stdin_does_not_deadlock() {
241        let big_input = vec![b'x'; 512 * 1024];
242        let exec = sh(
243            "cat >/dev/null; i=0; while [ $i -lt 300 ]; do printf '%01024d' $i; printf '%0512d' $i >&2; i=$((i+1)); done",
244            Some(big_input),
245        );
246        let outcome = run(&exec, |_, _| {}).expect("the child runs");
247        assert_eq!(outcome.exit_code, 0);
248        assert_eq!(outcome.stdout.len(), 300 * 1024);
249        assert_eq!(outcome.stderr.len(), 300 * 512);
250    }
251
252    /// Invalid UTF-8 travels byte-for-byte.
253    #[test]
254    fn invalid_utf8_is_preserved() {
255        let exec = sh(r"printf 'a\377\376b'", None);
256        let outcome = run(&exec, |_, _| {}).expect("the child runs");
257        assert_eq!(outcome.stdout, [b'a', 0xff, 0xfe, b'b']);
258    }
259
260    /// A child killed by signal N surfaces as 128+N.
261    #[cfg(unix)]
262    #[test]
263    fn a_signalled_child_surfaces_as_128_plus_n() {
264        let exec = sh("kill -TERM $$", None);
265        let outcome = run(&exec, |_, _| {}).expect("the child runs");
266        assert_eq!(outcome.exit_code, 128 + 15);
267    }
268
269    #[test]
270    fn redaction_replaces_every_occurrence() {
271        let secrets = vec![b"sekret".to_vec()];
272        assert_eq!(
273            redact(b"a sekret and a sekret", &secrets),
274            b"a [redacted] and a [redacted]".to_vec()
275        );
276        assert_eq!(redact(b"clean", &secrets), b"clean".to_vec());
277    }
278}