Skip to main content

ontocore_robot/
runner.rs

1use crate::error::{Result, RobotError};
2use std::path::{Component, Path};
3use std::process::Command;
4
5#[derive(Debug)]
6pub struct RobotOutput {
7    pub exit_code: i32,
8    pub stdout: String,
9    pub stderr: String,
10}
11
12const ALLOWED_SUBCOMMANDS: &[&str] =
13    &["validate-profile", "validate", "merge", "report", "reason", "query"];
14
15/// Resolve a ROBOT executable. Explicit paths must be named `robot` / `robot.cmd` / `robot.bat`.
16pub fn detect_robot(explicit_path: Option<&str>) -> Result<String> {
17    if let Some(path) = explicit_path.filter(|p| !p.is_empty()) {
18        let p = Path::new(path);
19        let name = p.file_name().and_then(|s| s.to_str()).unwrap_or("").to_ascii_lowercase();
20        if !matches!(name.as_str(), "robot" | "robot.cmd" | "robot.bat" | "robot.exe") {
21            return Err(RobotError::NotFound);
22        }
23        // Reject path traversal in the explicit path.
24        if p.components().any(|c| matches!(c, Component::ParentDir)) {
25            return Err(RobotError::NotFound);
26        }
27        if p.exists() {
28            return Ok(path.to_string());
29        }
30        return Err(RobotError::NotFound);
31    }
32    let which = Command::new("which").arg("robot").output();
33    if let Ok(output) = which {
34        if output.status.success() {
35            let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
36            if !path.is_empty() {
37                return Ok(path);
38            }
39        }
40    }
41    Err(RobotError::NotFound)
42}
43
44pub fn run_robot(robot_path: Option<&str>, args: &[String]) -> Result<RobotOutput> {
45    if let Some(sub) = args.first() {
46        let base = sub.split_whitespace().next().unwrap_or(sub);
47        if !ALLOWED_SUBCOMMANDS.contains(&base) {
48            return Err(RobotError::Run(format!(
49                "ROBOT subcommand '{base}' is not allowed; permitted: {}",
50                ALLOWED_SUBCOMMANDS.join(", ")
51            )));
52        }
53    } else {
54        return Err(RobotError::Run("ROBOT requires a subcommand".to_string()));
55    }
56    let robot = detect_robot(robot_path)?;
57    let output = Command::new(&robot).args(args).output()?;
58    let exit_code = output.status.code().unwrap_or(1);
59    let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
60    let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
61    Ok(RobotOutput { exit_code, stdout, stderr })
62}
63
64pub fn robot_validate(robot_path: Option<&str>, ontology_path: &Path) -> Result<RobotOutput> {
65    run_robot(
66        robot_path,
67        &[
68            "validate-profile".to_string(),
69            "--input".to_string(),
70            ontology_path.display().to_string(),
71            "--profile".to_string(),
72            "DL".to_string(),
73        ],
74    )
75}
76
77pub fn robot_merge(
78    robot_path: Option<&str>,
79    inputs: &[String],
80    output: &Path,
81) -> Result<RobotOutput> {
82    let mut args = vec!["merge".to_string()];
83    for input in inputs {
84        args.push("--input".to_string());
85        args.push(input.clone());
86    }
87    args.push("--output".to_string());
88    args.push(output.display().to_string());
89    run_robot(robot_path, &args)
90}
91
92pub fn robot_report(
93    robot_path: Option<&str>,
94    ontology_path: &Path,
95    report_path: &Path,
96) -> Result<RobotOutput> {
97    run_robot(
98        robot_path,
99        &[
100            "report".to_string(),
101            "--input".to_string(),
102            ontology_path.display().to_string(),
103            "--output".to_string(),
104            report_path.display().to_string(),
105        ],
106    )
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use crate::error::RobotError;
113
114    #[test]
115    fn rejects_disallowed_robot_subcommand() {
116        let err = match run_robot(Some("/definitely/not/robot"), &[String::from("convert")]) {
117            Err(e) => e,
118            Ok(output) => panic!("expected disallowed subcommand error, got {output:?}"),
119        };
120        match err {
121            RobotError::Run(msg) => {
122                assert!(msg.contains("not allowed"));
123                assert!(msg.contains("convert"));
124            }
125            other => panic!("expected Run error, got {other:?}"),
126        }
127    }
128
129    #[test]
130    fn rejects_empty_robot_args() {
131        let err = match run_robot(Some("/definitely/not/robot"), &[]) {
132            Err(e) => e,
133            Ok(output) => panic!("expected empty-args error, got {output:?}"),
134        };
135        match err {
136            RobotError::Run(msg) => assert!(msg.contains("requires a subcommand")),
137            other => panic!("expected Run error, got {other:?}"),
138        }
139    }
140
141    #[test]
142    fn allows_known_subcommands_in_validation() {
143        for sub in ["validate-profile", "validate", "merge", "report", "reason", "query"] {
144            let result = run_robot(Some("/definitely/not/robot"), &[String::from(sub)]);
145            match result {
146                Err(RobotError::Run(ref msg)) if msg.contains("not allowed") => {
147                    panic!("subcommand {sub} should pass allowlist, got {msg}");
148                }
149                _ => {}
150            }
151        }
152    }
153
154    #[test]
155    fn rejects_parent_dir_in_explicit_robot_path() {
156        let err = detect_robot(Some("../robot")).unwrap_err();
157        assert!(matches!(err, RobotError::NotFound));
158    }
159
160    #[test]
161    fn rejects_non_robot_binary_name() {
162        let err = detect_robot(Some("/usr/bin/java")).unwrap_err();
163        assert!(matches!(err, RobotError::NotFound));
164    }
165
166    #[test]
167    fn accepts_robot_binary_names() {
168        for name in ["robot", "robot.cmd", "robot.bat", "robot.exe"] {
169            let path = format!("/definitely/not/{name}");
170            let err = detect_robot(Some(&path)).unwrap_err();
171            assert!(
172                matches!(err, RobotError::NotFound),
173                "{name} should be accepted by name check before existence"
174            );
175        }
176    }
177}