use std::ffi::OsString;
use std::io::{Read, Write as _};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::sync::mpsc;
use zeroize::Zeroizing;
use crate::events::ChildStream;
pub struct Exec {
pub program: OsString,
pub args: Vec<OsString>,
pub env: Vec<(OsString, OsString)>,
pub cwd: PathBuf,
pub stdin: Option<Zeroizing<Vec<u8>>>,
}
impl std::fmt::Debug for Exec {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Exec")
.field("program", &self.program)
.field("args", &self.args)
.field("cwd", &self.cwd)
.field("stdin", &self.stdin.as_ref().map(|_| "[redacted]"))
.finish_non_exhaustive()
}
}
impl Exec {
#[must_use]
pub fn echo(&self) -> String {
let mut line = String::from("+ ");
line.push_str(&self.program.to_string_lossy());
for arg in &self.args {
line.push(' ');
line.push_str(&arg.to_string_lossy());
}
line
}
}
#[derive(Debug)]
pub struct Outcome {
pub exit_code: i32,
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
}
impl Outcome {
#[must_use]
pub const fn success(&self) -> bool {
self.exit_code == 0
}
}
pub fn run(exec: &Exec, mut on_chunk: impl FnMut(ChildStream, &[u8])) -> std::io::Result<Outcome> {
let mut command = Command::new(&exec.program);
command
.args(&exec.args)
.env_clear()
.envs(exec.env.iter().map(|(k, v)| (k, v)))
.current_dir(&exec.cwd)
.stdin(if exec.stdin.is_some() {
Stdio::piped()
} else {
Stdio::null()
})
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = command.spawn()?;
let (sender, receiver) = mpsc::channel::<(ChildStream, Vec<u8>)>();
let mut drains = Vec::new();
if let Some(pipe) = child.stdout.take() {
drains.push(spawn_drain(pipe, ChildStream::Stdout, sender.clone()));
}
if let Some(pipe) = child.stderr.take() {
drains.push(spawn_drain(pipe, ChildStream::Stderr, sender));
}
if let Some(bytes) = &exec.stdin {
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(bytes);
}
}
let mut stdout = Vec::new();
let mut stderr = Vec::new();
for (stream, chunk) in receiver {
on_chunk(stream, &chunk);
match stream {
ChildStream::Stdout => stdout.extend_from_slice(&chunk),
ChildStream::Stderr => stderr.extend_from_slice(&chunk),
}
}
for drain in drains {
let _ = drain.join();
}
let status = child.wait()?;
Ok(Outcome {
exit_code: surface_exit(status),
stdout,
stderr,
})
}
fn spawn_drain(
mut pipe: impl Read + Send + 'static,
stream: ChildStream,
sender: mpsc::Sender<(ChildStream, Vec<u8>)>,
) -> std::thread::JoinHandle<()> {
std::thread::spawn(move || {
let mut buffer = [0u8; 8192];
loop {
match pipe.read(&mut buffer) {
Ok(0) | Err(_) => break,
Ok(n) => {
if sender.send((stream, buffer[..n].to_vec())).is_err() {
break;
}
}
}
}
})
}
fn surface_exit(status: std::process::ExitStatus) -> i32 {
if let Some(code) = status.code() {
return code;
}
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt as _;
if let Some(signal) = status.signal() {
return 128 + signal;
}
}
-1
}
#[must_use]
pub fn redact(chunk: &[u8], secrets: &[impl AsRef<[u8]>]) -> Vec<u8> {
let mut out = chunk.to_vec();
for secret in secrets {
let secret = secret.as_ref();
if secret.is_empty() {
continue;
}
while let Some(pos) = out
.windows(secret.len())
.position(|window| window == secret)
{
out.splice(pos..pos + secret.len(), b"[redacted]".iter().copied());
}
}
out
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use super::{Exec, Zeroizing, redact, run};
use std::path::PathBuf;
fn sh(script: &str, stdin: Option<Vec<u8>>) -> Exec {
Exec {
program: "sh".into(),
args: vec!["-c".into(), script.into()],
env: vec![(
"PATH".into(),
std::env::var_os("PATH").expect("a PATH exists"),
)],
cwd: PathBuf::from("."),
stdin: stdin.map(Zeroizing::new),
}
}
#[test]
fn a_debug_rendering_omits_the_stdin_bytes() {
let exec = sh("true", Some(b"sekret-stdin-value".to_vec()));
let rendered = format!("{exec:?}");
assert!(!rendered.contains("sekret-stdin-value"));
assert!(rendered.contains("[redacted]"));
}
#[test]
fn a_chatty_child_with_stdin_does_not_deadlock() {
let big_input = vec![b'x'; 512 * 1024];
let exec = sh(
"cat >/dev/null; i=0; while [ $i -lt 300 ]; do printf '%01024d' $i; printf '%0512d' $i >&2; i=$((i+1)); done",
Some(big_input),
);
let outcome = run(&exec, |_, _| {}).expect("the child runs");
assert_eq!(outcome.exit_code, 0);
assert_eq!(outcome.stdout.len(), 300 * 1024);
assert_eq!(outcome.stderr.len(), 300 * 512);
}
#[test]
fn invalid_utf8_is_preserved() {
let exec = sh(r"printf 'a\377\376b'", None);
let outcome = run(&exec, |_, _| {}).expect("the child runs");
assert_eq!(outcome.stdout, [b'a', 0xff, 0xfe, b'b']);
}
#[cfg(unix)]
#[test]
fn a_signalled_child_surfaces_as_128_plus_n() {
let exec = sh("kill -TERM $$", None);
let outcome = run(&exec, |_, _| {}).expect("the child runs");
assert_eq!(outcome.exit_code, 128 + 15);
}
#[test]
fn redaction_replaces_every_occurrence() {
let secrets = vec![b"sekret".to_vec()];
assert_eq!(
redact(b"a sekret and a sekret", &secrets),
b"a [redacted] and a [redacted]".to_vec()
);
assert_eq!(redact(b"clean", &secrets), b"clean".to_vec());
}
}