clawft_kernel/node_facts/
host.rs1use std::io::Read;
9use std::path::{Path, PathBuf};
10use std::process::{Command, Stdio};
11use std::time::{Duration, Instant};
12
13pub const MAX_OUTPUT: usize = 1 << 20;
15
16pub trait ProbeHost {
18 fn os(&self) -> String;
20 fn arch(&self) -> String;
22 fn which(&self, tool: &str) -> Option<PathBuf>;
24 fn run(&self, tool: &str, args: &[&str]) -> Option<String>;
27 fn run_all(&self, tool: &str, args: &[&str]) -> Option<String>;
30 fn read_file(&self, path: &str) -> Option<String>;
32 fn list_dir(&self, path: &str) -> Option<Vec<String>>;
34 fn exists(&self, path: &str) -> bool;
36}
37
38#[derive(Debug, Clone)]
40pub struct SystemHost {
41 timeout: Duration,
42 extra_dirs: Vec<PathBuf>,
43}
44
45impl Default for SystemHost {
46 fn default() -> Self {
47 Self::new(Duration::from_secs(8))
48 }
49}
50
51type Pipe = Option<Box<dyn Read + Send>>;
52
53fn drain(r: Pipe) -> std::thread::JoinHandle<Vec<u8>> {
54 std::thread::spawn(move || {
55 let mut buf = Vec::new();
56 if let Some(r) = r {
57 let _ = r.take(MAX_OUTPUT as u64).read_to_end(&mut buf);
58 }
59 buf
60 })
61}
62
63impl SystemHost {
64 pub fn new(timeout: Duration) -> Self {
69 let mut extra_dirs: Vec<PathBuf> = [
70 "/opt/homebrew/bin",
71 "/usr/local/bin",
72 "/usr/bin",
73 "/bin",
74 "/usr/sbin",
75 "/sbin",
76 ]
77 .iter()
78 .map(PathBuf::from)
79 .collect();
80 if let Some(home) = std::env::var_os("HOME") {
81 extra_dirs.push(PathBuf::from(home).join(".local/bin"));
82 }
83 Self {
84 timeout,
85 extra_dirs,
86 }
87 }
88
89 fn exec(&self, tool: &str, args: &[&str], merge_stderr: bool) -> Option<String> {
90 let exe = self.which(tool)?;
91 let mut child = Command::new(exe)
92 .args(args)
93 .stdin(Stdio::null())
94 .stdout(Stdio::piped())
95 .stderr(if merge_stderr {
96 Stdio::piped()
97 } else {
98 Stdio::null()
99 })
100 .spawn()
101 .ok()?;
102 let out = drain(
105 child
106 .stdout
107 .take()
108 .map(|r| Box::new(r) as Box<dyn Read + Send>),
109 );
110 let err = drain(
111 child
112 .stderr
113 .take()
114 .map(|r| Box::new(r) as Box<dyn Read + Send>),
115 );
116 let deadline = Instant::now() + self.timeout;
117 let status = loop {
118 match child.try_wait() {
119 Ok(Some(status)) => break Some(status),
120 Ok(None) if Instant::now() < deadline => {
121 std::thread::sleep(Duration::from_millis(20));
122 }
123 _ => {
124 let _ = child.kill();
125 let _ = child.wait();
126 break None;
127 }
128 }
129 };
130 match status {
133 Some(s) if s.success() => {
134 let mut bytes = out.join().ok()?;
135 bytes.extend(err.join().ok()?);
136 Some(String::from_utf8_lossy(&bytes).into_owned())
137 }
138 _ => None,
139 }
140 }
141}
142
143fn is_executable(p: &Path) -> bool {
144 #[cfg(unix)]
145 {
146 use std::os::unix::fs::PermissionsExt;
147 p.metadata()
148 .map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
149 .unwrap_or(false)
150 }
151 #[cfg(not(unix))]
152 {
153 p.is_file()
154 }
155}
156
157impl ProbeHost for SystemHost {
158 fn os(&self) -> String {
159 std::env::consts::OS.to_string()
160 }
161
162 fn arch(&self) -> String {
163 std::env::consts::ARCH.to_string()
164 }
165
166 fn which(&self, tool: &str) -> Option<PathBuf> {
167 if tool.is_empty() || tool.contains('/') {
168 return None;
169 }
170 let path_dirs = std::env::var_os("PATH")
171 .map(|p| std::env::split_paths(&p).collect::<Vec<_>>())
172 .unwrap_or_default();
173 path_dirs
174 .into_iter()
175 .chain(self.extra_dirs.iter().cloned())
176 .map(|d| d.join(tool))
177 .find(|p| is_executable(p))
178 }
179
180 fn run(&self, tool: &str, args: &[&str]) -> Option<String> {
181 self.exec(tool, args, false)
182 }
183
184 fn run_all(&self, tool: &str, args: &[&str]) -> Option<String> {
185 self.exec(tool, args, true)
186 }
187
188 fn read_file(&self, path: &str) -> Option<String> {
189 let f = std::fs::File::open(path).ok()?;
190 let mut buf = Vec::new();
191 f.take(MAX_OUTPUT as u64).read_to_end(&mut buf).ok()?;
192 Some(String::from_utf8_lossy(&buf).into_owned())
193 }
194
195 fn list_dir(&self, path: &str) -> Option<Vec<String>> {
196 let mut names: Vec<String> = std::fs::read_dir(path)
197 .ok()?
198 .filter_map(|e| e.ok())
199 .filter_map(|e| e.file_name().into_string().ok())
200 .collect();
201 names.sort();
202 Some(names)
203 }
204
205 fn exists(&self, path: &str) -> bool {
206 Path::new(path).exists()
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn which_rejects_paths_and_finds_sh() {
216 let h = SystemHost::default();
217 assert!(h.which("../sh").is_none());
218 assert!(h.which("").is_none());
219 assert!(h.which("sh").is_some());
220 }
221
222 #[test]
223 fn run_returns_stdout_only_on_success() {
224 let h = SystemHost::default();
225 assert_eq!(
226 h.run("sh", &["-c", "echo hi; echo err >&2"]).as_deref(),
227 Some("hi\n")
228 );
229 assert_eq!(
230 h.run_all("sh", &["-c", "echo hi; echo err >&2"]).as_deref(),
231 Some("hi\nerr\n")
232 );
233 assert!(h.run("sh", &["-c", "exit 3"]).is_none());
234 }
235
236 #[test]
237 fn run_kills_a_tool_that_overruns_the_timeout() {
238 let h = SystemHost::new(Duration::from_millis(200));
239 let t = Instant::now();
240 assert!(h.run("sh", &["-c", "sleep 5"]).is_none());
241 assert!(t.elapsed() < Duration::from_secs(3));
242 }
243}