use std::{
ffi::{OsStr, OsString},
fs::File,
io::{PipeReader, Read, Seek, Write, pipe},
os::unix::ffi::OsStrExt,
path::PathBuf,
process::{Command, Output, Stdio},
};
#[cfg(unix)]
use std::os::unix::process::ExitStatusExt;
use log::trace;
use tempfile::tempfile;
const SAFE_BYTES: &[u8] =
b"abcdefghijklmnopqrstuvwxyABCDEFGHIJKLMNOPQRSTUVWXY01234567890-_./@=%:+,";
#[derive(Debug)]
enum Stdin {
Null,
Inherit,
Feed(Vec<u8>),
}
#[derive(Debug)]
pub struct CommandRunner {
cmd: Command,
stdin: Stdin,
stdout: Option<PipeReader>,
}
impl CommandRunner {
pub fn new(cmd: Command) -> Self {
trace!("new CommandRunner: {cmd:#?}");
trace!("child process stdin is empty, stdout and stderr are inherited");
Self {
cmd,
stdin: Stdin::Null,
stdout: None,
}
}
#[mutants::skip]
pub fn inherit_stdin(&mut self) {
trace!("run command so it inherits stdin from parent process");
self.stdin = Stdin::Inherit;
}
pub fn feed_stdin(&mut self, data: impl Into<Vec<u8>>) {
let data = data.into();
trace!("feed child process stdin {} bytes of input", data.len());
self.stdin = Stdin::Feed(data);
}
pub fn capture_stdout(&mut self) {
trace!("capture child process stdout");
self.cmd.stdout(Stdio::piped());
}
pub fn capture_stderr(&mut self) {
trace!("capture child process stderr");
self.cmd.stderr(Stdio::piped());
}
pub fn combine_stdouterr(&mut self) -> Result<(), CommandError> {
trace!("capture child process combined stdout and stderr");
let (r, w) = pipe().map_err(CommandError::PipeCapture)?;
self.stdout = Some(r);
self.cmd
.stdout(w.try_clone().map_err(CommandError::PipeClone)?);
self.cmd.stderr(w);
Ok(())
}
#[mutants::skip]
pub fn execute(mut self) -> Result<Output, CommandError> {
if let Some(dirname) = self.cmd.get_current_dir() {
if !dirname.exists() {
return Err(CommandError::NoSuchDir(dirname.to_path_buf()));
}
}
let program_name = self.cmd.get_program().to_os_string();
match &self.stdin {
Stdin::Null => {
self.cmd.stdin(Stdio::null());
}
Stdin::Inherit => {
self.cmd.stdin(Stdio::inherit());
}
Stdin::Feed(data) => {
self.cmd
.stdin(write_temp_file(data).map_err(CommandError::Stdin)?);
}
}
trace!("spawn child process");
let r = self.cmd.spawn();
let mut child = match r {
Ok(child) => child,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Err(CommandError::NoSuchCommand(program_name));
}
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
return Err(CommandError::NoPermission(program_name));
}
Err(err) => {
return Err(CommandError::Other {
program_name,
source: err,
});
}
};
trace!("wait for child process to terminate");
let result = if let Some(mut combined) = self.stdout.take() {
std::mem::drop(self.cmd);
let mut output = Vec::new();
combined
.read_to_end(&mut output)
.map_err(CommandError::ReadCombined)?;
match child.wait() {
Err(err) => Err(err),
Ok(status) => Ok(Output {
status,
stdout: output,
stderr: vec![],
}),
}
} else {
child.wait_with_output()
};
match result {
Ok(output) => {
#[cfg(unix)]
if let Some(signal) = output.status.signal() {
return Err(CommandError::KilledBySignal {
program_name,
signal,
});
}
if let Some(code) = output.status.code() {
if code != 0 {
return Err(CommandError::command_failed(program_name, output));
}
}
assert!(output.status.success());
Ok(output)
}
Err(err) => Err(CommandError::Other {
program_name,
source: err,
}),
}
}
}
fn write_temp_file(data: &[u8]) -> Result<File, std::io::Error> {
let mut tmp = tempfile()?;
tmp.write_all(data)?;
tmp.rewind()?;
Ok(tmp)
}
#[derive(Debug, thiserror::Error)]
pub enum CommandError {
#[error("directory does not exist: {0}")]
NoSuchDir(PathBuf),
#[error("command does not exist: {0:?}")]
NoSuchCommand(OsString),
#[error("no permission to run command: {0:?}")]
NoPermission(OsString),
#[error("command failed: {program_name:?}")]
CommandFailed {
program_name: OsString,
output: Box<Output>,
},
#[cfg(unix)]
#[error("command {program_name:?} was terminated by signal number {signal:?}")]
KilledBySignal {
program_name: OsString,
signal: i32,
},
#[error("unknown error while running command: {program_name:?}")]
Other {
program_name: OsString,
#[source]
source: std::io::Error,
},
#[error("failed to create temporary file for stdin")]
Stdin(#[source] std::io::Error),
#[error("failed to create pipe for capturing output")]
PipeCapture(#[source] std::io::Error),
#[error("failed to clone write end of anonymous pipe")]
PipeClone(#[source] std::io::Error),
#[error("failed to read child process combined output")]
ReadCombined(#[source] std::io::Error),
}
impl CommandError {
fn command_failed<O: Into<OsString>>(program_name: O, output: Output) -> Self {
Self::CommandFailed {
program_name: program_name.into(),
output: Box::new(output),
}
}
}
pub fn shell_quote(s: &OsStr) -> OsString {
let out = if s.as_bytes().iter().all(|byte| SAFE_BYTES.contains(byte)) {
s.as_bytes().to_vec()
} else {
const SINGLE: u8 = b'\'';
let mut out = vec![SINGLE];
for byte in s.as_bytes() {
if *byte == SINGLE {
out.push(SINGLE);
out.push(b'"');
out.push(SINGLE);
out.push(b'"');
out.push(SINGLE);
} else {
out.push(*byte);
}
}
out.push(SINGLE);
out
};
unsafe { OsString::from_encoded_bytes_unchecked(out) }
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod test {
use std::os::unix::ffi::OsStrExt;
use tempfile::tempdir;
use super::*;
fn quote(bytes: &[u8]) -> Vec<u8> {
shell_quote(OsStr::from_bytes(bytes))
.into_encoded_bytes()
.to_vec()
}
#[test]
fn empty() {
assert_eq!(quote(b""), b"");
}
#[test]
fn quote_safe_bytes() {
assert_eq!(quote(SAFE_BYTES), SAFE_BYTES);
}
#[test]
fn minimal_quote_safe() {
assert_eq!(quote(b"hello"), b"hello");
}
#[test]
fn minimal_quote_unsafe() {
assert_eq!(quote(b"hello world"), b"'hello world'");
}
#[test]
fn single_quote() {
assert_eq!(quote(b"'"), b"''\"'\"''");
}
#[test]
fn mix() {
assert_eq!(
quote(b"it's a !#$ travesty"),
b"'it'\"'\"'s a !#$ travesty'"
);
}
#[test]
fn run_inherit_stdin_combine_outputs() {
let mut cmd = Command::new("echo");
cmd.args(["hello", "world"]);
let mut runner = CommandRunner::new(cmd);
runner.combine_stdouterr().unwrap();
let output = runner.execute().unwrap();
eprintln!("{output:#?}");
assert!(output.status.success());
assert_eq!(output.stdout, b"hello world\n");
assert!(output.stderr.is_empty());
}
#[test]
fn run_feed_stdin_capture_outputs_separately() {
let cmd = Command::new("cat");
let mut runner = CommandRunner::new(cmd);
runner.feed_stdin(b"hello");
runner.capture_stdout();
runner.capture_stderr();
let output = runner.execute().unwrap();
eprintln!("{output:#?}");
assert!(output.status.success());
assert_eq!(output.stdout, b"hello");
assert!(output.stderr.is_empty());
}
#[test]
fn capture_stderr() {
let mut cmd = Command::new("sh");
cmd.arg("-c");
cmd.arg("echo foo 1>&2");
let mut runner = CommandRunner::new(cmd);
runner.capture_stderr();
let output = runner.execute().unwrap();
assert_eq!(output.stderr, b"foo\n");
}
#[test]
fn run_non_exec() {
let tmp = tempdir().unwrap();
let bin = tmp.path().join("noexec.sh");
std::fs::write(&bin, b"").unwrap();
let cmd = Command::new(&bin);
let runner = CommandRunner::new(cmd);
let r = runner.execute();
eprintln!("r={r:#?}");
assert!(matches!(r, Err(CommandError::NoPermission(_))));
}
#[test]
fn run_nonexistent() {
let cmd = Command::new("./does-not-exist");
let runner = CommandRunner::new(cmd);
let r = runner.execute();
eprintln!("r={r:#?}");
assert!(matches!(r, Err(CommandError::NoSuchCommand(_))));
}
#[test]
#[allow(clippy::panic)]
fn current_dir_does_not_exist() {
let dirname = PathBuf::from("/does/not/exist");
let mut cmd = Command::new("true");
cmd.current_dir(&dirname);
let runner = CommandRunner::new(cmd);
let r = runner.execute();
eprintln!("r={r:#?}");
match r {
Err(CommandError::NoSuchDir(actual)) if actual == dirname => (),
_ => panic!("unexpected result"),
}
}
}