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