release_kit/setup/
process.rs1use 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
25pub struct Exec {
27 pub program: OsString,
29 pub args: Vec<OsString>,
31 pub env: Vec<(OsString, OsString)>,
33 pub cwd: PathBuf,
35 pub stdin: Option<Zeroizing<Vec<u8>>>,
38}
39
40impl std::fmt::Debug for Exec {
41 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 #[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#[derive(Debug)]
70pub struct Outcome {
71 pub exit_code: i32,
73 pub stdout: Vec<u8>,
75 pub stderr: Vec<u8>,
77}
78
79impl Outcome {
80 #[must_use]
82 pub const fn success(&self) -> bool {
83 self.exit_code == 0
84 }
85}
86
87pub 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 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
148fn 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
170fn 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#[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 #[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 #[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 #[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 #[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}