Skip to main content

strixonomy_robot/
runner.rs

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
18/// Default kill deadline for ROBOT subprocesses (#341).
19pub const DEFAULT_ROBOT_TIMEOUT_SECS: u64 = 300;
20
21/// Max bytes buffered per stdout/stderr stream (#423). Matches plugin host default.
22pub const MAX_STDIO_BYTES: usize = 1024 * 1024; // 1 MiB per stream
23
24/// Resolve a ROBOT executable. Explicit paths must be named `robot` / `robot.cmd` / `robot.bat`.
25pub 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        // Reject path traversal in the explicit path.
33        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    // Probe PATH by spawning allowlisted names. Avoids Unix-only `which` (#316);
42    // Windows PATHEXT resolves `robot` → `robot.cmd` / `.bat` / `.exe` when present.
43    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        // SAFETY: kill(-pgid) signals the process group created via process_group(0).
155        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        // ensure robot_exists_on_path returns false for a nonsense name (no panic / which).
298        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        // Emit just over the stdio cap then exit (allowlisted subcommand is ignored).
344        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}