Skip to main content

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}