anodizer_core/tool_detect.rs
1//! Generic external-tool detection. The one module answering both
2//! availability questions, each right for a different surface:
3//!
4//! - [`on_path`] — "is `<tool>` reachable on `PATH`?" A pure lookup with no
5//! exec. The right question for **existence gating** (can a stage spawn
6//! this binary): tools with no version flag (`hdiutil`) or that exit
7//! non-zero on `--version` (`pkgbuild`, WiX `candle`/`light`) are present
8//! and runnable yet fail a version probe, so gating on [`runs`] reports
9//! them missing and hard-fails work that would have succeeded.
10//! - [`runs`] — "does `<tool> <version-flag>` actually run and exit zero?"
11//! A spawn probe. The right question for **health reporting**
12//! (`healthcheck`, validator availability): a broken stub on `PATH`
13//! passes [`on_path`] but not [`runs`].
14//!
15//! Also hosts `<tool> <args>` capability probes (e.g. the `docker info`
16//! reachability probe in the publish preflight) via [`tool_runs_with_args`].
17//!
18//! Centralised here so the `Command::new(<tool>)` probe shell-outs live
19//! inside the module-boundaries allow-list. The CLI used to do these
20//! probes inline; that put `Command::new` outside the allow-list and
21//! counted as a boundary violation. Capability probes in other core
22//! modules delegate here for the same reason.
23
24use std::io;
25use std::path::Path;
26use std::process::Command;
27
28/// Outcome of a spawn-probe availability check ([`runs`]).
29///
30/// The `NotFound`-folds-into-`Unavailable` decision is made exactly once,
31/// here — and a genuine probe failure is a distinct variant so no call site
32/// can silently masquerade a broken probe as clean tool absence.
33#[derive(Debug)]
34pub enum ToolProbe {
35 /// The probe ran and exited zero — the tool is available.
36 Available,
37 /// The tool is cleanly absent: not on `PATH` (spawn failed with
38 /// `NotFound`), or it ran but exited non-zero on its version flag
39 /// (stub binary / version-flag mismatch).
40 Unavailable,
41 /// The probe itself failed for a non-`NotFound` reason (permission
42 /// denied, exec-format error, …): presence is UNKNOWN, not "absent".
43 /// Callers must surface the error rather than collapse it to
44 /// [`ToolProbe::Unavailable`].
45 ProbeFailed(io::Error),
46}
47
48/// Check whether a binary is reachable on the system — a pure `PATH`
49/// lookup with no exec.
50///
51/// For absolute or relative paths (containing `/`), checks if the file
52/// exists. For bare names, searches each directory in the `PATH`
53/// environment variable for an executable with the given name. This is a
54/// pure-Rust implementation that avoids shelling out to `which` or
55/// `command -v`, making it portable across all platforms.
56pub fn on_path(name: &str) -> bool {
57 if name.contains('/') || name.contains('\\') {
58 return Path::new(name).exists();
59 }
60
61 // On Windows, PATHEXT lists extensions to try (e.g., .COM;.EXE;.BAT;.CMD).
62 // When the caller asks for "upx", we also check for "upx.exe", etc.
63 let extensions: Vec<String> = if cfg!(windows) {
64 std::env::var("PATHEXT")
65 .unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string())
66 .split(';')
67 .filter(|e| !e.is_empty())
68 .map(|e| e.to_string())
69 .collect()
70 } else {
71 Vec::new()
72 };
73
74 if let Ok(path_var) = std::env::var("PATH") {
75 for dir in std::env::split_paths(&path_var) {
76 let candidate = dir.join(name);
77 if candidate.is_file() {
78 return true;
79 }
80 for ext in &extensions {
81 let with_ext = dir.join(format!("{}{}", name, ext));
82 if with_ext.is_file() {
83 return true;
84 }
85 }
86 }
87 }
88
89 false
90}
91
92/// The version flag `<name>` answers with a zero exit. `--version` for
93/// almost everything; OpenSSH's `ssh` rejects `--version` (exit 255,
94/// usage text) and only supports `-V`; cosign rejects `--version`
95/// (exit 1, "unknown flag") and only supports the `version` subcommand.
96fn version_flag(name: &str) -> &'static str {
97 match name {
98 "ssh" => "-V",
99 "cosign" => "version",
100 _ => "--version",
101 }
102}
103
104/// Probe `<name> --version` (or the tool's own version flag, see
105/// `version_flag`) and report the tri-state outcome.
106///
107/// A missing-on-`PATH` binary (spawn `NotFound`) is folded together with a
108/// ran-but-exited-non-zero probe into [`ToolProbe::Unavailable`] — the one
109/// place that fold happens. Any other spawn error is
110/// [`ToolProbe::ProbeFailed`] and must be surfaced by the caller.
111/// stdout/stderr are silenced so a missing tool doesn't pollute the log.
112pub fn runs(name: &str) -> ToolProbe {
113 match Command::new(name)
114 .arg(version_flag(name))
115 .current_dir(crate::path_util::probe_dir())
116 .stdout(std::process::Stdio::null())
117 .stderr(std::process::Stdio::null())
118 .status()
119 {
120 Ok(status) if status.success() => ToolProbe::Available,
121 Ok(_) => ToolProbe::Unavailable,
122 Err(e) if e.kind() == io::ErrorKind::NotFound => ToolProbe::Unavailable,
123 Err(e) => ToolProbe::ProbeFailed(e),
124 }
125}
126
127/// How many leading output lines `extract_version_line` scans for a
128/// version-looking line. Bounded so a chatty tool cannot make the probe
129/// scan megabytes; comfortably clears cosign's ASCII banner (~10 lines
130/// before `GitVersion:`).
131const VERSION_SCAN_LINES: usize = 15;
132
133/// Pick the first version-looking line — non-empty and carrying at least
134/// one digit — from the leading [`VERSION_SCAN_LINES`] lines of `stdout`,
135/// falling back to `stderr` (ssh prints its version there). `None` when
136/// neither stream yields one, so callers omit the version instead of
137/// rendering banner art (cosign leads with a digit-free ASCII banner that
138/// a naive first-line grab would report as its version).
139fn extract_version_line(stdout: &str, stderr: &str) -> Option<String> {
140 let versionish = |text: &str| {
141 text.lines()
142 .take(VERSION_SCAN_LINES)
143 .map(str::trim)
144 .find(|line| !line.is_empty() && line.chars().any(|c| c.is_ascii_digit()))
145 .map(str::to_string)
146 };
147 versionish(stdout).or_else(|| versionish(stderr))
148}
149
150/// Run the tool's version probe (see `version_flag`) and return a
151/// version-looking output line.
152///
153/// `Ok(Some(line))` — tool ran, exited zero, and one of the leading
154/// output lines looks like a version (see `extract_version_line`).
155/// `Ok(None)` — tool ran but exited non-zero, or produced no
156/// version-looking line; no version string to report.
157/// `Err(_)` — tool could not be spawned. Distinct from `Ok(None)` so
158/// callers can log why the probe itself failed at trace level rather
159/// than collapsing every failure to "tool missing".
160pub fn tool_version(name: &str) -> io::Result<Option<String>> {
161 let output = Command::new(name)
162 .arg(version_flag(name))
163 .current_dir(crate::path_util::probe_dir())
164 .output()?;
165 if output.status.success() {
166 Ok(extract_version_line(
167 &String::from_utf8_lossy(&output.stdout),
168 &String::from_utf8_lossy(&output.stderr),
169 ))
170 } else {
171 Ok(None)
172 }
173}
174
175/// Probe whether `<name> <args...>` runs and exits zero.
176///
177/// Used by capability probes that pass extra flags beyond a bare
178/// `--version` (e.g. `docker info` to check daemon reachability).
179/// stdout/stderr are silenced; `false` covers both "binary missing"
180/// and "exited non-zero" — callers that need to distinguish those two
181/// cases should use [`runs`] / [`tool_version`] instead.
182pub fn tool_runs_with_args(name: &str, args: &[&str]) -> bool {
183 Command::new(name)
184 .args(args)
185 .current_dir(crate::path_util::probe_dir())
186 .stdout(std::process::Stdio::null())
187 .stderr(std::process::Stdio::null())
188 .status()
189 .map(|s| s.success())
190 .unwrap_or(false)
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196
197 /// `true(1)` is part of GNU coreutils on Linux/macOS; it accepts no
198 /// args and always exits zero. The test asserts the happy path of
199 /// `tool_runs_with_args` does not regress.
200 #[test]
201 #[cfg(unix)]
202 fn tool_runs_with_args_returns_true_for_existing_zero_exit_tool() {
203 assert!(tool_runs_with_args("true", &[]));
204 }
205
206 /// A binary that definitively does not exist on PATH must collapse
207 /// to `false` (not panic, not return `Err`) — the public contract
208 /// is "Err and exit-non-zero both fold to false".
209 #[test]
210 fn tool_runs_with_args_returns_false_for_missing_binary() {
211 assert!(!tool_runs_with_args(
212 "nonexistent-binary-xyzzy",
213 &["--version"]
214 ));
215 }
216
217 /// ssh must probe with `-V` — OpenSSH exits 255 on `--version`, so
218 /// the default flag would report an installed ssh as missing.
219 #[test]
220 fn version_flag_maps_ssh_to_dash_v() {
221 assert_eq!(version_flag("ssh"), "-V");
222 assert_eq!(version_flag("git"), "--version");
223 }
224
225 #[test]
226 fn runs_reports_present_tool_available() {
227 assert!(matches!(runs("git"), ToolProbe::Available));
228 }
229
230 /// The missing-binary case is the CLEAN absence outcome: the
231 /// `NotFound`-folds-into-`Unavailable` decision lives in `runs`, not
232 /// at every call site.
233 #[test]
234 fn runs_folds_not_found_into_unavailable() {
235 assert!(matches!(
236 runs("this-tool-does-not-exist-12345"),
237 ToolProbe::Unavailable
238 ));
239 }
240
241 #[test]
242 fn on_path_absolute_path_exists() {
243 if cfg!(windows) {
244 // cmd.exe exists on all Windows systems
245 assert!(on_path("C:\\Windows\\System32\\cmd.exe"));
246 } else {
247 // /usr/bin/env exists on virtually all Unix systems
248 assert!(on_path("/usr/bin/env"));
249 }
250 }
251
252 #[test]
253 fn on_path_absolute_path_does_not_exist() {
254 if cfg!(windows) {
255 assert!(!on_path("C:\\nonexistent\\binary\\path.exe"));
256 } else {
257 assert!(!on_path("/nonexistent/binary/path"));
258 }
259 }
260
261 #[test]
262 fn on_path_bare_name_on_path() {
263 if cfg!(windows) {
264 // "cmd.exe" should be findable on PATH on any Windows system.
265 // The extension-qualified form matches directly; bare names are
266 // additionally probed with each PATHEXT extension appended, so
267 // plain "cmd" would resolve too.
268 assert!(on_path("cmd.exe"));
269 } else {
270 // "env" should be findable on PATH on any Unix system
271 assert!(on_path("env"));
272 }
273 }
274
275 #[test]
276 fn on_path_bare_name_not_on_path() {
277 assert!(!on_path("nonexistent-binary-xyz-12345"));
278 }
279
280 /// cosign leads its `version` output with a digit-free ASCII banner;
281 /// the extractor must skip past it to the first version-looking line
282 /// instead of reporting banner art as the version.
283 #[test]
284 fn extract_version_line_skips_cosign_banner() {
285 let stdout = [
286 " ______ ______ _______. __ _______ .__ __.",
287 " / | / __ \\ / || | / _____|| \\ | |",
288 "| ,----'| | | | | (----`| | | | __ | \\| |",
289 "| `----.| `--' | .----) | | | | |__| | | |\\ |",
290 " \\______| \\______/ |_______/ |__| \\______| |__| \\__|",
291 "cosign: A tool for Container Signing, Verification and Storage in an OCI registry.",
292 "",
293 "GitVersion: v2.2.4",
294 "GitCommit: abc",
295 ]
296 .join("\n");
297 assert_eq!(
298 extract_version_line(&stdout, ""),
299 Some("GitVersion: v2.2.4".to_string())
300 );
301 }
302
303 /// The common single-line case ("git version 2.43.0") is unchanged by
304 /// the banner-skipping scan.
305 #[test]
306 fn extract_version_line_takes_normal_first_line() {
307 assert_eq!(
308 extract_version_line("git version 2.43.0\n", ""),
309 Some("git version 2.43.0".to_string())
310 );
311 // ssh prints its version to stderr.
312 assert_eq!(
313 extract_version_line("", "OpenSSH_9.6p1, OpenSSL 3.0.13\n"),
314 Some("OpenSSH_9.6p1, OpenSSL 3.0.13".to_string())
315 );
316 }
317
318 /// No digit anywhere in the scanned window → no version to report;
319 /// callers omit the parenthetical rather than print garbage.
320 #[test]
321 fn extract_version_line_returns_none_without_digits() {
322 assert_eq!(extract_version_line("all prose, no version\n", ""), None);
323 assert_eq!(extract_version_line("", ""), None);
324 }
325}