use std::ffi::{OsStr, OsString};
use std::fmt;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, ExitStatus, Stdio};
use super::Secret;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ExternalCliError {
#[error("`{program}` is not installed or not on PATH")]
NotInstalled {
program: String,
},
#[error(
"refused to run `{program}`: the stdin value also appears in its argv or environment overlay"
)]
ValueInCommandLine {
program: String,
},
#[error("failed to spawn `{program} {args}`: {source}")]
Spawn {
program: String,
args: String,
#[source]
source: std::io::Error,
},
#[error("failed to write stdin to `{program} {args}` (child killed): {source}")]
StdinWrite {
program: String,
args: String,
#[source]
source: std::io::Error,
},
#[error("failed to collect the result of `{program} {args}`: {source}")]
Collect {
program: String,
args: String,
#[source]
source: std::io::Error,
},
#[error("`{program} {args}` failed ({}); output withheld", exit_label(.code))]
NonZero {
program: String,
args: String,
code: Option<i32>,
},
}
fn exit_label(code: &Option<i32>) -> String {
code.map_or_else(|| "signalled".to_string(), |c| format!("exit {c}"))
}
#[derive(Debug)]
#[non_exhaustive]
pub struct ExternalCliOutput {
pub program: String,
pub args: String,
pub code: Option<i32>,
pub success: bool,
pub stdout: Secret<String>,
pub stderr: Secret<String>,
}
impl ExternalCliOutput {
fn new(cmd: &ExternalCliCommand, status: ExitStatus, out: Vec<u8>, err: Vec<u8>) -> Self {
Self {
program: cmd.program_display(),
args: cmd.argv_display(),
code: status.code(),
success: status.success(),
stdout: Secret::new(String::from_utf8_lossy(&out).into_owned()),
stderr: Secret::new(String::from_utf8_lossy(&err).into_owned()),
}
}
pub fn ok(self) -> Result<Self, ExternalCliError> {
if self.success {
return Ok(self);
}
Err(ExternalCliError::NonZero {
program: self.program,
args: self.args,
code: self.code,
})
}
}
#[derive(Clone)]
pub struct ExternalCliCommand {
program: OsString,
args: Vec<OsString>,
cwd: Option<PathBuf>,
envs: Vec<(OsString, Option<OsString>)>,
}
impl fmt::Debug for ExternalCliCommand {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let env: Vec<String> = self
.envs
.iter()
.map(|(k, v)| {
let op = if v.is_some() { "set" } else { "removed" };
format!("{}=<{op}>", k.to_string_lossy())
})
.collect();
f.debug_struct("ExternalCliCommand")
.field("program", &self.program)
.field("args", &self.args)
.field("cwd", &self.cwd)
.field("env", &env)
.finish()
}
}
impl ExternalCliCommand {
pub fn new(program: impl AsRef<OsStr>) -> Self {
Self {
program: program.as_ref().to_os_string(),
args: Vec::new(),
cwd: None,
envs: Vec::new(),
}
}
#[must_use]
pub fn arg(mut self, arg: impl AsRef<OsStr>) -> Self {
self.args.push(arg.as_ref().to_os_string());
self
}
#[must_use]
pub fn args<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
self.args
.extend(args.into_iter().map(|a| a.as_ref().to_os_string()));
self
}
#[must_use]
pub fn cwd(mut self, dir: impl AsRef<Path>) -> Self {
self.cwd = Some(dir.as_ref().to_path_buf());
self
}
#[must_use]
pub fn env(mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> Self {
self.envs.push((
key.as_ref().to_os_string(),
Some(value.as_ref().to_os_string()),
));
self
}
#[must_use]
pub fn env_remove(mut self, key: impl AsRef<OsStr>) -> Self {
self.envs.push((key.as_ref().to_os_string(), None));
self
}
pub fn program_display(&self) -> String {
self.program.to_string_lossy().into_owned()
}
pub fn argv_display(&self) -> String {
self.args
.iter()
.map(|a| a.to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join(" ")
}
pub fn output_with_stdin_blocking<T: AsRef<[u8]>>(
&self,
value: &Secret<T>,
) -> Result<ExternalCliOutput, ExternalCliError> {
let value = value.expose().as_ref();
self.refuse_value_in_command_line(value)?;
self.run_blocking(Some(value))
}
pub fn output_blocking(&self) -> Result<ExternalCliOutput, ExternalCliError> {
self.run_blocking(None)
}
pub async fn output_with_stdin<T: AsRef<[u8]>>(
&self,
value: &Secret<T>,
) -> Result<ExternalCliOutput, ExternalCliError> {
let value = value.expose().as_ref();
self.refuse_value_in_command_line(value)?;
self.run_async(Some(value)).await
}
pub async fn output(&self) -> Result<ExternalCliOutput, ExternalCliError> {
self.run_async(None).await
}
fn refuse_value_in_command_line(&self, value: &[u8]) -> Result<(), ExternalCliError> {
if value.is_empty() {
return Ok(());
}
let overlay = self.envs.iter().filter_map(|(_, v)| v.as_ref());
let found = std::iter::once(&self.program)
.chain(&self.args)
.chain(overlay)
.any(|s| contains(s.as_encoded_bytes(), value));
if found {
return Err(ExternalCliError::ValueInCommandLine {
program: self.program_display(),
});
}
Ok(())
}
fn std_command(&self, stdin: Stdio) -> std::process::Command {
let mut cmd = std::process::Command::new(&self.program);
cmd.args(&self.args)
.stdin(stdin)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(dir) = &self.cwd {
cmd.current_dir(dir);
}
for (k, v) in &self.envs {
match v {
Some(v) => cmd.env(k, v),
None => cmd.env_remove(k),
};
}
cmd
}
fn spawn_error(&self, err: std::io::Error) -> ExternalCliError {
if err.kind() == std::io::ErrorKind::NotFound {
ExternalCliError::NotInstalled {
program: self.program_display(),
}
} else {
ExternalCliError::Spawn {
program: self.program_display(),
args: self.argv_display(),
source: err,
}
}
}
fn stdin_error(&self, source: std::io::Error) -> ExternalCliError {
ExternalCliError::StdinWrite {
program: self.program_display(),
args: self.argv_display(),
source,
}
}
fn collect_error(&self, source: std::io::Error) -> ExternalCliError {
ExternalCliError::Collect {
program: self.program_display(),
args: self.argv_display(),
source,
}
}
fn run_blocking(&self, stdin: Option<&[u8]>) -> Result<ExternalCliOutput, ExternalCliError> {
let piped = if stdin.is_some() {
Stdio::piped()
} else {
Stdio::null()
};
let child = self
.std_command(piped)
.spawn()
.map_err(|e| self.spawn_error(e))?;
let mut guard = ReapGuard {
child,
reaped: false,
};
let out = drain(guard.child.stdout.take()).map_err(|e| self.collect_error(e))?;
let err = drain(guard.child.stderr.take()).map_err(|e| self.collect_error(e))?;
if let (Some(value), Some(mut pipe)) = (stdin, guard.child.stdin.take()) {
pipe.write_all(value).map_err(|e| self.stdin_error(e))?;
}
let status = guard.wait().map_err(|e| self.collect_error(e))?;
let out = join(out).map_err(|e| self.collect_error(e))?;
let err = join(err).map_err(|e| self.collect_error(e))?;
Ok(ExternalCliOutput::new(self, status, out, err))
}
async fn run_async(&self, stdin: Option<&[u8]>) -> Result<ExternalCliOutput, ExternalCliError> {
use tokio::io::AsyncWriteExt;
let piped = if stdin.is_some() {
Stdio::piped()
} else {
Stdio::null()
};
let mut cmd = tokio::process::Command::from(self.std_command(piped));
cmd.kill_on_drop(true);
let mut child = cmd.spawn().map_err(|e| self.spawn_error(e))?;
let stdin_pipe = child.stdin.take();
let read_out = read_all(child.stdout.take());
let read_err = read_all(child.stderr.take());
let child_ref = &mut child;
let write = async move {
let result = match (stdin, stdin_pipe) {
(Some(value), Some(mut pipe)) => pipe.write_all(value).await,
_ => Ok(()),
};
if result.is_err() {
let _ = child_ref.start_kill();
}
result
};
let (written, out, err) = tokio::join!(write, read_out, read_err);
if written.is_err() || out.is_err() || err.is_err() {
let _ = child.start_kill();
}
let status = child.wait().await;
written.map_err(|e| self.stdin_error(e))?;
let status = status.map_err(|e| self.collect_error(e))?;
let out = out.map_err(|e| self.collect_error(e))?;
let err = err.map_err(|e| self.collect_error(e))?;
Ok(ExternalCliOutput::new(self, status, out, err))
}
}
struct ReapGuard {
child: Child,
reaped: bool,
}
impl ReapGuard {
fn wait(&mut self) -> std::io::Result<ExitStatus> {
let status = self.child.wait()?;
self.reaped = true;
Ok(status)
}
}
impl Drop for ReapGuard {
fn drop(&mut self) {
if !self.reaped {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
}
type Drain = Option<std::thread::JoinHandle<std::io::Result<Vec<u8>>>>;
fn drain<R: Read + Send + 'static>(pipe: Option<R>) -> std::io::Result<Drain> {
let Some(mut pipe) = pipe else {
return Ok(None);
};
let handle = std::thread::Builder::new()
.name("external-cli-drain".to_string())
.spawn(move || {
let mut buf = Vec::new();
pipe.read_to_end(&mut buf).map(|_| buf)
})?;
Ok(Some(handle))
}
fn join(drain: Drain) -> std::io::Result<Vec<u8>> {
match drain {
None => Ok(Vec::new()),
Some(handle) => handle
.join()
.map_err(|_| std::io::Error::other("output reader thread panicked"))?,
}
}
async fn read_all<R: tokio::io::AsyncRead + Unpin>(pipe: Option<R>) -> std::io::Result<Vec<u8>> {
use tokio::io::AsyncReadExt;
let mut buf = Vec::new();
if let Some(mut pipe) = pipe {
pipe.read_to_end(&mut buf).await?;
}
Ok(buf)
}
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
!needle.is_empty() && haystack.windows(needle.len()).any(|w| w == needle)
}
#[cfg(all(test, unix))]
#[path = "external_cli_tests.rs"]
mod tests;