Skip to main content

flow_application/
cli_tools.rs

1//! CLI tool detection for `cli-tool` nodes.
2//!
3//! The Node Hub asks "is the binary this `cli-tool` node needs installed, and
4//! is it at least the minimum version the entry declares?". This module answers
5//! the first half - it probes the machine by running `<name> <version_command>`
6//! (default `--version`) and parsing a version token out of the output. The
7//! minimum-version verdict is computed by the caller (the frontend) so the
8//! probe stays a pure "what's installed?" query and the command is reusable.
9//!
10//! Spawning mirrors the adapters' `tokio::process::Command::new(CLI)` pattern,
11//! so no extra Tauri plugin or shell capability is required.
12
13use std::process::Stdio;
14
15use serde::Serialize;
16use tokio::process::Command;
17
18/// Outcome of probing the machine for one CLI tool. `found` distinguishes
19/// "binary missing from PATH" from "present but version unparseable". Never
20/// surfaced as an `Err` so the Hub renders a badge instead of failing.
21#[derive(Debug, Clone, Default, Serialize)]
22#[serde(rename_all = "camelCase")]
23pub struct CliToolStatus {
24    pub found: bool,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub installed_version: Option<String>,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub path: Option<String>,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub raw: Option<String>,
31}
32
33const DEFAULT_VERSION_ARG: &str = "--version";
34
35/// Probe for `name <version_command>`. Returns `found: false` (never an `Err`)
36/// when the binary can't be spawned or the name is rejected.
37pub async fn detect_cli_tool(name: &str, version_command: Option<&str>) -> CliToolStatus {
38    if !is_safe_binary_name(name) {
39        return CliToolStatus::default();
40    }
41    let arg = version_command
42        .map(str::trim)
43        .filter(|s| !s.is_empty())
44        .unwrap_or(DEFAULT_VERSION_ARG);
45
46    match Command::new(name)
47        .arg(arg)
48        .stdin(Stdio::null())
49        .output()
50        .await
51    {
52        Ok(out) => {
53            // The binary exists and ran - that alone means "installed". Most
54            // CLIs print the version to stdout; a few use stderr.
55            let stdout = String::from_utf8_lossy(&out.stdout);
56            let text = if stdout.trim().is_empty() {
57                String::from_utf8_lossy(&out.stderr).into_owned()
58            } else {
59                stdout.into_owned()
60            };
61            CliToolStatus {
62                found: true,
63                installed_version: parse_version(&text),
64                path: which(name),
65                raw: first_nonempty_line(&text),
66            }
67        }
68        // `ErrorKind::NotFound` (and any other spawn failure) => not usable.
69        Err(_) => CliToolStatus::default(),
70    }
71}
72
73/// Extract the first dotted numeric version token (`\d+(\.\d+)+`) from `text`.
74/// Bare integers and dash-separated dates (`2024-01-02`) are skipped so a build
75/// date or revision number isn't mistaken for a version.
76pub fn parse_version(text: &str) -> Option<String> {
77    let bytes = text.as_bytes();
78    let mut i = 0;
79    while i < bytes.len() {
80        if !bytes[i].is_ascii_digit() {
81            i += 1;
82            continue;
83        }
84        let start = i;
85        let mut saw_dot = false;
86        while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {
87            if bytes[i] == b'.' {
88                saw_dot = true;
89            }
90            i += 1;
91        }
92        let token = text[start..i].trim_end_matches('.');
93        if saw_dot && token.contains('.') {
94            return Some(token.to_string());
95        }
96        // Bare integer - keep scanning from where the run ended.
97    }
98    None
99}
100
101fn first_nonempty_line(text: &str) -> Option<String> {
102    text.lines()
103        .map(str::trim)
104        .find(|l| !l.is_empty())
105        .map(str::to_string)
106}
107
108/// Reject anything but a bare binary name - no path separators, spaces, or
109/// shell metacharacters. Defense-in-depth: the catalog is trusted, but the
110/// probe must never become an injection vector.
111fn is_safe_binary_name(name: &str) -> bool {
112    !name.is_empty()
113        && name.len() <= 64
114        && name
115            .chars()
116            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
117}
118
119/// Best-effort resolution of `name` against `PATH`. The version probe already
120/// proves presence; this just enriches the status with a path when one is
121/// found. On Windows it also tries the common executable extensions.
122fn which(name: &str) -> Option<String> {
123    let path_var = std::env::var_os("PATH")?;
124    let exts: &[&str] = if cfg!(windows) {
125        &["", ".exe", ".cmd", ".bat"]
126    } else {
127        &[""]
128    };
129    for dir in std::env::split_paths(&path_var) {
130        for ext in exts {
131            let candidate = dir.join(format!("{name}{ext}"));
132            if candidate.is_file() {
133                return candidate.to_str().map(str::to_string);
134            }
135        }
136    }
137    None
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn parses_version_from_common_banners() {
146        assert_eq!(
147            parse_version("tool version 2.40.0 (2023-11-08)"),
148            Some("2.40.0".into())
149        );
150        assert_eq!(parse_version("mytool 8.0.1"), Some("8.0.1".into()));
151        assert_eq!(parse_version("v1.2"), Some("1.2".into()));
152        assert_eq!(parse_version("name 3.11.5\nmore"), Some("3.11.5".into()));
153        assert_eq!(parse_version("1.2.3-beta+meta"), Some("1.2.3".into()));
154    }
155
156    #[test]
157    fn ignores_bare_ints_and_dates() {
158        assert_eq!(parse_version("built 2024-01-02"), None);
159        assert_eq!(parse_version("revision 12345"), None);
160        assert_eq!(parse_version("no numbers here"), None);
161    }
162
163    #[test]
164    fn rejects_unsafe_binary_names() {
165        assert!(!is_safe_binary_name(""));
166        assert!(!is_safe_binary_name("../evil"));
167        assert!(!is_safe_binary_name("a b"));
168        assert!(!is_safe_binary_name("foo;rm -rf /"));
169        assert!(!is_safe_binary_name("foo/bar"));
170        assert!(is_safe_binary_name("gh"));
171        assert!(is_safe_binary_name("zowe"));
172        assert!(is_safe_binary_name("python3.11"));
173    }
174
175    #[tokio::test]
176    async fn missing_binary_is_not_found_not_error() {
177        let status = detect_cli_tool("definitely-not-a-real-binary-xyz", None).await;
178        assert!(!status.found);
179        assert!(status.installed_version.is_none());
180    }
181
182    #[tokio::test]
183    async fn unsafe_name_short_circuits_to_not_found() {
184        let status = detect_cli_tool("foo/bar", None).await;
185        assert!(!status.found);
186    }
187}