flow_application/
cli_tools.rs1use std::process::Stdio;
14
15use serde::Serialize;
16use tokio::process::Command;
17
18#[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
35pub 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 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 Err(_) => CliToolStatus::default(),
70 }
71}
72
73pub 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 }
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
108fn 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
119fn 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}