use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
pub const MAX_OUTPUT: usize = 1 << 20;
pub trait ProbeHost {
fn os(&self) -> String;
fn arch(&self) -> String;
fn which(&self, tool: &str) -> Option<PathBuf>;
fn run(&self, tool: &str, args: &[&str]) -> Option<String>;
fn run_all(&self, tool: &str, args: &[&str]) -> Option<String>;
fn read_file(&self, path: &str) -> Option<String>;
fn list_dir(&self, path: &str) -> Option<Vec<String>>;
fn exists(&self, path: &str) -> bool;
}
#[derive(Debug, Clone)]
pub struct SystemHost {
timeout: Duration,
extra_dirs: Vec<PathBuf>,
}
impl Default for SystemHost {
fn default() -> Self {
Self::new(Duration::from_secs(8))
}
}
type Pipe = Option<Box<dyn Read + Send>>;
fn drain(r: Pipe) -> std::thread::JoinHandle<Vec<u8>> {
std::thread::spawn(move || {
let mut buf = Vec::new();
if let Some(r) = r {
let _ = r.take(MAX_OUTPUT as u64).read_to_end(&mut buf);
}
buf
})
}
impl SystemHost {
pub fn new(timeout: Duration) -> Self {
let mut extra_dirs: Vec<PathBuf> = [
"/opt/homebrew/bin",
"/usr/local/bin",
"/usr/bin",
"/bin",
"/usr/sbin",
"/sbin",
]
.iter()
.map(PathBuf::from)
.collect();
if let Some(home) = std::env::var_os("HOME") {
extra_dirs.push(PathBuf::from(home).join(".local/bin"));
}
Self {
timeout,
extra_dirs,
}
}
fn exec(&self, tool: &str, args: &[&str], merge_stderr: bool) -> Option<String> {
let exe = self.which(tool)?;
let mut child = Command::new(exe)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(if merge_stderr {
Stdio::piped()
} else {
Stdio::null()
})
.spawn()
.ok()?;
let out = drain(
child
.stdout
.take()
.map(|r| Box::new(r) as Box<dyn Read + Send>),
);
let err = drain(
child
.stderr
.take()
.map(|r| Box::new(r) as Box<dyn Read + Send>),
);
let deadline = Instant::now() + self.timeout;
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break Some(status),
Ok(None) if Instant::now() < deadline => {
std::thread::sleep(Duration::from_millis(20));
}
_ => {
let _ = child.kill();
let _ = child.wait();
break None;
}
}
};
match status {
Some(s) if s.success() => {
let mut bytes = out.join().ok()?;
bytes.extend(err.join().ok()?);
Some(String::from_utf8_lossy(&bytes).into_owned())
}
_ => None,
}
}
}
fn is_executable(p: &Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
p.metadata()
.map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(not(unix))]
{
p.is_file()
}
}
impl ProbeHost for SystemHost {
fn os(&self) -> String {
std::env::consts::OS.to_string()
}
fn arch(&self) -> String {
std::env::consts::ARCH.to_string()
}
fn which(&self, tool: &str) -> Option<PathBuf> {
if tool.is_empty() || tool.contains('/') {
return None;
}
let path_dirs = std::env::var_os("PATH")
.map(|p| std::env::split_paths(&p).collect::<Vec<_>>())
.unwrap_or_default();
path_dirs
.into_iter()
.chain(self.extra_dirs.iter().cloned())
.map(|d| d.join(tool))
.find(|p| is_executable(p))
}
fn run(&self, tool: &str, args: &[&str]) -> Option<String> {
self.exec(tool, args, false)
}
fn run_all(&self, tool: &str, args: &[&str]) -> Option<String> {
self.exec(tool, args, true)
}
fn read_file(&self, path: &str) -> Option<String> {
let f = std::fs::File::open(path).ok()?;
let mut buf = Vec::new();
f.take(MAX_OUTPUT as u64).read_to_end(&mut buf).ok()?;
Some(String::from_utf8_lossy(&buf).into_owned())
}
fn list_dir(&self, path: &str) -> Option<Vec<String>> {
let mut names: Vec<String> = std::fs::read_dir(path)
.ok()?
.filter_map(|e| e.ok())
.filter_map(|e| e.file_name().into_string().ok())
.collect();
names.sort();
Some(names)
}
fn exists(&self, path: &str) -> bool {
Path::new(path).exists()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn which_rejects_paths_and_finds_sh() {
let h = SystemHost::default();
assert!(h.which("../sh").is_none());
assert!(h.which("").is_none());
assert!(h.which("sh").is_some());
}
#[test]
fn run_returns_stdout_only_on_success() {
let h = SystemHost::default();
assert_eq!(
h.run("sh", &["-c", "echo hi; echo err >&2"]).as_deref(),
Some("hi\n")
);
assert_eq!(
h.run_all("sh", &["-c", "echo hi; echo err >&2"]).as_deref(),
Some("hi\nerr\n")
);
assert!(h.run("sh", &["-c", "exit 3"]).is_none());
}
#[test]
fn run_kills_a_tool_that_overruns_the_timeout() {
let h = SystemHost::new(Duration::from_millis(200));
let t = Instant::now();
assert!(h.run("sh", &["-c", "sleep 5"]).is_none());
assert!(t.elapsed() < Duration::from_secs(3));
}
}