1use crate::error::{Result, RobotError};
2use std::io::{ErrorKind, Read};
3use std::path::{Component, Path};
4use std::process::{Child, Command, Stdio};
5use std::thread;
6use std::time::{Duration, Instant};
7
8#[derive(Debug)]
9pub struct RobotOutput {
10 pub exit_code: i32,
11 pub stdout: String,
12 pub stderr: String,
13}
14
15const ALLOWED_SUBCOMMANDS: &[&str] =
16 &["validate-profile", "validate", "merge", "report", "reason", "query", "convert"];
17
18pub const DEFAULT_ROBOT_TIMEOUT_SECS: u64 = 300;
20
21pub const MAX_STDIO_BYTES: usize = 1024 * 1024; pub fn detect_robot(explicit_path: Option<&str>) -> Result<String> {
26 if let Some(path) = explicit_path.filter(|p| !p.is_empty()) {
27 let p = Path::new(path);
28 let name = p.file_name().and_then(|s| s.to_str()).unwrap_or("").to_ascii_lowercase();
29 if !matches!(name.as_str(), "robot" | "robot.cmd" | "robot.bat" | "robot.exe") {
30 return Err(RobotError::NotFound);
31 }
32 if p.components().any(|c| matches!(c, Component::ParentDir)) {
34 return Err(RobotError::NotFound);
35 }
36 if p.exists() {
37 return Ok(path.to_string());
38 }
39 return Err(RobotError::NotFound);
40 }
41 for name in ["robot", "robot.cmd", "robot.bat", "robot.exe"] {
44 if robot_exists_on_path(name) {
45 return Ok(name.to_string());
46 }
47 }
48 Err(RobotError::NotFound)
49}
50
51fn robot_exists_on_path(name: &str) -> bool {
52 match Command::new(name).arg("--version").output() {
53 Ok(_) => true,
54 Err(err) => err.kind() != ErrorKind::NotFound,
55 }
56}
57
58fn read_capped(mut reader: impl Read, cap: usize) -> Result<Vec<u8>> {
59 let mut buf = Vec::new();
60 let mut chunk = [0u8; 8192];
61 loop {
62 let n = reader.read(&mut chunk)?;
63 if n == 0 {
64 break;
65 }
66 if buf.len().saturating_add(n) > cap {
67 return Err(RobotError::OutputTooLarge(cap));
68 }
69 buf.extend_from_slice(&chunk[..n]);
70 }
71 Ok(buf)
72}
73
74pub fn run_robot(robot_path: Option<&str>, args: &[String]) -> Result<RobotOutput> {
75 run_robot_with_timeout(robot_path, args, Duration::from_secs(DEFAULT_ROBOT_TIMEOUT_SECS))
76}
77
78pub(crate) fn run_robot_with_timeout(
79 robot_path: Option<&str>,
80 args: &[String],
81 timeout: Duration,
82) -> Result<RobotOutput> {
83 if let Some(sub) = args.first() {
84 let base = sub.split_whitespace().next().unwrap_or(sub);
85 if !ALLOWED_SUBCOMMANDS.contains(&base) {
86 return Err(RobotError::Run(format!(
87 "ROBOT subcommand '{base}' is not allowed; permitted: {}",
88 ALLOWED_SUBCOMMANDS.join(", ")
89 )));
90 }
91 } else {
92 return Err(RobotError::Run("ROBOT requires a subcommand".to_string()));
93 }
94 let robot = detect_robot(robot_path)?;
95 let mut cmd = Command::new(&robot);
96 cmd.args(args).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
97 configure_process_group(&mut cmd);
98 let mut child = cmd.spawn()?;
99 let mut stdout = child.stdout.take().expect("stdout piped");
100 let mut stderr = child.stderr.take().expect("stderr piped");
101 let out_handle = thread::spawn(move || read_capped(&mut stdout, MAX_STDIO_BYTES));
102 let err_handle = thread::spawn(move || read_capped(&mut stderr, MAX_STDIO_BYTES));
103
104 let deadline = Instant::now() + timeout;
105 loop {
106 match child.try_wait() {
107 Ok(Some(status)) => {
108 let out = out_handle
109 .join()
110 .map_err(|_| RobotError::Run("stdout reader panicked".into()))??;
111 let err = err_handle
112 .join()
113 .map_err(|_| RobotError::Run("stderr reader panicked".into()))??;
114 return Ok(RobotOutput {
115 exit_code: status.code().unwrap_or(1),
116 stdout: String::from_utf8_lossy(&out).into_owned(),
117 stderr: String::from_utf8_lossy(&err).into_owned(),
118 });
119 }
120 Ok(None) => {
121 if Instant::now() >= deadline {
122 kill_robot_tree(&mut child);
123 let _ = child.wait();
124 let _ = out_handle.join();
125 let _ = err_handle.join();
126 return Err(RobotError::TimedOut(timeout.as_secs().max(1)));
127 }
128 thread::sleep(Duration::from_millis(25));
129 }
130 Err(e) => {
131 kill_robot_tree(&mut child);
132 let _ = child.wait();
133 let _ = out_handle.join();
134 let _ = err_handle.join();
135 return Err(RobotError::Io(e));
136 }
137 }
138 }
139}
140
141fn configure_process_group(cmd: &mut Command) {
142 #[cfg(unix)]
143 {
144 use std::os::unix::process::CommandExt;
145 cmd.process_group(0);
146 }
147 let _ = cmd;
148}
149
150fn kill_robot_tree(child: &mut Child) {
151 #[cfg(unix)]
152 {
153 let pgid = child.id() as i32;
154 let group_kill = unsafe { libc::kill(-pgid, libc::SIGKILL) };
156 if group_kill != 0 {
157 let _ = child.kill();
158 }
159 }
160 #[cfg(not(unix))]
161 {
162 let _ = child.kill();
163 }
164}
165
166pub fn robot_validate(robot_path: Option<&str>, ontology_path: &Path) -> Result<RobotOutput> {
167 run_robot(
168 robot_path,
169 &[
170 "validate-profile".to_string(),
171 "--input".to_string(),
172 ontology_path.display().to_string(),
173 "--profile".to_string(),
174 "DL".to_string(),
175 ],
176 )
177}
178
179pub fn robot_convert(robot_path: Option<&str>, input: &Path, output: &Path) -> Result<RobotOutput> {
180 run_robot(
181 robot_path,
182 &[
183 "convert".into(),
184 "--input".into(),
185 input.display().to_string(),
186 "--output".into(),
187 output.display().to_string(),
188 ],
189 )
190}
191
192pub fn robot_merge(
193 robot_path: Option<&str>,
194 inputs: &[String],
195 output: &Path,
196) -> Result<RobotOutput> {
197 let mut args = vec!["merge".to_string()];
198 for input in inputs {
199 args.push("--input".to_string());
200 args.push(input.clone());
201 }
202 args.push("--output".to_string());
203 args.push(output.display().to_string());
204 run_robot(robot_path, &args)
205}
206
207pub fn robot_report(
208 robot_path: Option<&str>,
209 ontology_path: &Path,
210 report_path: &Path,
211) -> Result<RobotOutput> {
212 run_robot(
213 robot_path,
214 &[
215 "report".to_string(),
216 "--input".to_string(),
217 ontology_path.display().to_string(),
218 "--output".to_string(),
219 report_path.display().to_string(),
220 ],
221 )
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227 use crate::error::RobotError;
228 use std::io::Cursor;
229
230 #[test]
231 fn rejects_disallowed_robot_subcommand() {
232 let err = match run_robot(Some("/definitely/not/robot"), &[String::from("diff")]) {
233 Err(e) => e,
234 Ok(output) => panic!("expected disallowed subcommand error, got {output:?}"),
235 };
236 match err {
237 RobotError::Run(msg) => {
238 assert!(msg.contains("not allowed"));
239 assert!(msg.contains("diff"));
240 }
241 other => panic!("expected Run error, got {other:?}"),
242 }
243 }
244
245 #[test]
246 fn rejects_empty_robot_args() {
247 let err = match run_robot(Some("/definitely/not/robot"), &[]) {
248 Err(e) => e,
249 Ok(output) => panic!("expected empty-args error, got {output:?}"),
250 };
251 match err {
252 RobotError::Run(msg) => assert!(msg.contains("requires a subcommand")),
253 other => panic!("expected Run error, got {other:?}"),
254 }
255 }
256
257 #[test]
258 fn allows_known_subcommands_in_validation() {
259 for sub in ["validate-profile", "validate", "merge", "report", "reason", "query", "convert"]
260 {
261 let result = run_robot(Some("/definitely/not/robot"), &[String::from(sub)]);
262 match result {
263 Err(RobotError::Run(ref msg)) if msg.contains("not allowed") => {
264 panic!("subcommand {sub} should pass allowlist, got {msg}");
265 }
266 _ => {}
267 }
268 }
269 }
270
271 #[test]
272 fn rejects_parent_dir_in_explicit_robot_path() {
273 let err = detect_robot(Some("../robot")).unwrap_err();
274 assert!(matches!(err, RobotError::NotFound));
275 }
276
277 #[test]
278 fn rejects_non_robot_binary_name() {
279 let err = detect_robot(Some("/usr/bin/java")).unwrap_err();
280 assert!(matches!(err, RobotError::NotFound));
281 }
282
283 #[test]
284 fn accepts_robot_binary_names() {
285 for name in ["robot", "robot.cmd", "robot.bat", "robot.exe"] {
286 let path = format!("/definitely/not/{name}");
287 let err = detect_robot(Some(&path)).unwrap_err();
288 assert!(
289 matches!(err, RobotError::NotFound),
290 "{name} should be accepted by name check before existence"
291 );
292 }
293 }
294
295 #[test]
296 fn path_probe_skips_missing_binary_without_which() {
297 assert!(!robot_exists_on_path("strixonomy-definitely-missing-robot-bin-xyz"));
299 }
300
301 #[test]
302 fn read_capped_rejects_oversize() {
303 let data = vec![b'x'; 40];
304 let err = read_capped(Cursor::new(data), 32).unwrap_err();
305 assert!(matches!(err, RobotError::OutputTooLarge(32)));
306 }
307
308 #[test]
309 fn read_capped_accepts_exact_cap() {
310 let data = vec![b'y'; 32];
311 let out = read_capped(Cursor::new(data), 32).expect("exact cap ok");
312 assert_eq!(out.len(), 32);
313 }
314
315 #[cfg(unix)]
316 #[test]
317 fn robot_timeout_kills_hanging_binary() {
318 use std::os::unix::fs::PermissionsExt;
319
320 let dir = tempfile::tempdir().unwrap();
321 let robot = dir.path().join("robot");
322 std::fs::write(&robot, "#!/bin/sh\nsleep 999\n").unwrap();
323 let mut perms = std::fs::metadata(&robot).unwrap().permissions();
324 perms.set_mode(0o755);
325 std::fs::set_permissions(&robot, perms).unwrap();
326
327 let err = run_robot_with_timeout(
328 Some(robot.to_str().unwrap()),
329 &[String::from("validate")],
330 Duration::from_millis(400),
331 )
332 .expect_err("must time out");
333 assert!(matches!(err, RobotError::TimedOut(_)), "got {err:?}");
334 }
335
336 #[cfg(unix)]
337 #[test]
338 fn robot_rejects_oversized_stdout() {
339 use std::os::unix::fs::PermissionsExt;
340
341 let dir = tempfile::tempdir().unwrap();
342 let robot = dir.path().join("robot");
343 let script = format!("#!/bin/sh\nhead -c {} /dev/zero\n", MAX_STDIO_BYTES + 1);
345 std::fs::write(&robot, script).unwrap();
346 let mut perms = std::fs::metadata(&robot).unwrap().permissions();
347 perms.set_mode(0o755);
348 std::fs::set_permissions(&robot, perms).unwrap();
349
350 let err = run_robot_with_timeout(
351 Some(robot.to_str().unwrap()),
352 &[String::from("report")],
353 Duration::from_secs(10),
354 )
355 .expect_err("must reject oversized stdout");
356 assert!(matches!(err, RobotError::OutputTooLarge(MAX_STDIO_BYTES)), "got {err:?}");
357 }
358}