use std::ffi::OsString;
use std::path::Path;
use std::process::Stdio;
use std::time::Duration;
use thiserror::Error;
use tokio::io::{AsyncRead, AsyncReadExt};
use tokio::process::Command;
#[derive(Debug, Clone, Copy)]
pub struct CommandRunner {
timeout: Duration,
maximum_output_bytes: usize,
}
impl CommandRunner {
#[must_use]
pub const fn new(timeout: Duration, maximum_output_bytes: usize) -> Self {
Self {
timeout,
maximum_output_bytes,
}
}
pub async fn run(
&self,
program: &Path,
arguments: &[OsString],
) -> Result<String, CommandError> {
let mut command = Command::new(program);
command
.args(arguments)
.env_clear()
.env("LC_ALL", "C")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child = command.spawn().map_err(CommandError::Spawn)?;
let stdout = child
.stdout
.take()
.ok_or_else(|| CommandError::MissingPipe(program.display().to_string()))?;
let stderr = child
.stderr
.take()
.ok_or_else(|| CommandError::MissingPipe(program.display().to_string()))?;
let operation = async {
tokio::try_join!(
read_bounded(stdout, self.maximum_output_bytes, program),
read_bounded(stderr, self.maximum_output_bytes, program),
async { child.wait().await.map_err(CommandError::Spawn) },
)
};
let (stdout, _stderr, status) = tokio::time::timeout(self.timeout, operation)
.await
.map_err(|_| CommandError::Timeout(program.display().to_string()))??;
if !status.success() {
return Err(CommandError::Exit {
program: program.display().to_string(),
status: status.code(),
});
}
String::from_utf8(stdout).map_err(CommandError::Encoding)
}
}
async fn read_bounded(
reader: impl AsyncRead + Unpin,
maximum_output_bytes: usize,
program: &Path,
) -> Result<Vec<u8>, CommandError> {
let limit = u64::try_from(maximum_output_bytes)
.map_err(|_| CommandError::OutputLimit(program.display().to_string()))?;
let mut reader = reader.take(limit.saturating_add(1));
let mut bytes = Vec::with_capacity(maximum_output_bytes.min(64 * 1024));
reader
.read_to_end(&mut bytes)
.await
.map_err(CommandError::Spawn)?;
if bytes.len() > maximum_output_bytes {
return Err(CommandError::OutputLimit(program.display().to_string()));
}
Ok(bytes)
}
#[derive(Debug, Error)]
pub enum CommandError {
#[error("failed to run external tool: {0}")]
Spawn(#[source] std::io::Error),
#[error("external tool timed out: {0}")]
Timeout(String),
#[error("external tool exceeded output limit: {0}")]
OutputLimit(String),
#[error("external tool output pipe was unavailable: {0}")]
MissingPipe(String),
#[error("external tool {program} failed with status {status:?}")]
Exit {
program: String,
status: Option<i32>,
},
#[error("external tool output was not UTF-8: {0}")]
Encoding(#[source] std::string::FromUtf8Error),
}