Skip to main content

bnto_core/
binary_probe.rs

1//! In-process PATH lookup that answers "would `Command::new(name)` find it?"
2//!
3//! Dependency checks used to spawn `which` / `where`, but `where` searches
4//! the CWD first and honours every PATHEXT type, while `which` is missing on
5//! minimal Linux images. This module resolves names with no child process,
6//! and the native context spawns the resolved path, so a dependency that
7//! checks as found is exactly the file that runs.
8
9use std::ffi::OsStr;
10use std::path::{Path, PathBuf};
11
12/// Characters that never appear in a bare program name. Separators would
13/// bypass the PATH search; the rest are wildcards or drive markers to
14/// Windows lookup tools.
15const REJECTED_CHARS: [char; 5] = ['/', '\\', '*', '?', ':'];
16
17/// Resolve `name` against a PATH-style variable to a file that can be spawned.
18///
19/// Returns the first runnable candidate in PATH order. Only bare names are
20/// accepted (no separators or wildcards) and empty PATH entries are
21/// skipped, so the result never depends on the current directory.
22/// Pure apart from filesystem metadata reads — callers supply `path_var`.
23pub fn find_on_path(name: &str, path_var: &OsStr) -> Option<PathBuf> {
24    if !is_bare_name(name) {
25        return None;
26    }
27    let file_names = candidate_file_names(name);
28    std::env::split_paths(path_var)
29        .filter(|dir| !dir.as_os_str().is_empty())
30        .flat_map(|dir| file_names.iter().map(move |file| dir.join(file)))
31        .find(|candidate| is_runnable(candidate))
32}
33
34fn is_bare_name(name: &str) -> bool {
35    !name.is_empty() && name != "." && name != ".." && !name.contains(REJECTED_CHARS)
36}
37
38/// Extensions std can launch by full path on Windows: `.exe` directly,
39/// `.cmd` / `.bat` through cmd.exe with its own argument escaping.
40#[cfg(windows)]
41const SPAWNABLE_EXTENSIONS: [&str; 3] = [".exe", ".cmd", ".bat"];
42
43/// File names to try in each PATH directory, in priority order.
44///
45/// Windows mirrors cmd.exe for the extensions std can spawn: a name that
46/// already carries one is used as is, otherwise `.exe` then `.cmd` then
47/// `.bat`. That covers npm's `.cmd` shims for global tools.
48#[cfg(windows)]
49fn candidate_file_names(name: &str) -> Vec<String> {
50    let lower = name.to_ascii_lowercase();
51    if SPAWNABLE_EXTENSIONS.iter().any(|ext| lower.ends_with(ext)) {
52        return vec![name.to_string()];
53    }
54    SPAWNABLE_EXTENSIONS
55        .iter()
56        .map(|ext| format!("{name}{ext}"))
57        .collect()
58}
59
60#[cfg(not(windows))]
61fn candidate_file_names(name: &str) -> Vec<String> {
62    vec![name.to_string()]
63}
64
65/// Windows: std checks existence without following reparse points, so App
66/// Execution Aliases under `WindowsApps` count, including the Microsoft
67/// Store `python3` stub, matching what `Command::new` would launch.
68#[cfg(windows)]
69fn is_runnable(path: &Path) -> bool {
70    std::fs::symlink_metadata(path).is_ok_and(|m| !m.is_dir())
71}
72
73/// Unix: `execvp` skips entries that are not executable regular files.
74#[cfg(unix)]
75fn is_runnable(path: &Path) -> bool {
76    use std::os::unix::fs::PermissionsExt;
77    std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
78}
79
80/// Targets without processes (WASM) can never spawn anything.
81#[cfg(not(any(unix, windows)))]
82fn is_runnable(_path: &Path) -> bool {
83    false
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89    use std::path::Path;
90
91    fn path_of(dirs: &[&Path]) -> std::ffi::OsString {
92        std::env::join_paths(dirs).unwrap()
93    }
94
95    /// Create a file that the host can spawn: `<name>.exe` on Windows,
96    /// a mode-0755 file on unix.
97    fn make_runnable(dir: &Path, name: &str) -> PathBuf {
98        let file = dir.join(if cfg!(windows) {
99            format!("{name}.exe")
100        } else {
101            name.to_string()
102        });
103        std::fs::write(&file, b"").unwrap();
104        #[cfg(unix)]
105        {
106            use std::os::unix::fs::PermissionsExt;
107            std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o755)).unwrap();
108        }
109        file
110    }
111
112    #[test]
113    fn finds_runnable_binary_on_path() {
114        let dir = tempfile::tempdir().unwrap();
115        let exe = make_runnable(dir.path(), "tool");
116        let found = find_on_path("tool", &path_of(&[dir.path()]));
117        assert_eq!(found, Some(exe));
118    }
119
120    #[test]
121    fn returns_none_when_not_on_path() {
122        let dir = tempfile::tempdir().unwrap();
123        assert_eq!(find_on_path("tool", &path_of(&[dir.path()])), None);
124    }
125
126    #[test]
127    fn returns_first_match_in_path_order() {
128        let first = tempfile::tempdir().unwrap();
129        let second = tempfile::tempdir().unwrap();
130        make_runnable(second.path(), "tool");
131        let expected = make_runnable(first.path(), "tool");
132        let path = path_of(&[first.path(), second.path()]);
133        assert_eq!(find_on_path("tool", &path), Some(expected));
134    }
135
136    #[test]
137    fn skips_directories_with_the_binary_name() {
138        let dir = tempfile::tempdir().unwrap();
139        let name = if cfg!(windows) { "tool.exe" } else { "tool" };
140        std::fs::create_dir(dir.path().join(name)).unwrap();
141        assert_eq!(find_on_path("tool", &path_of(&[dir.path()])), None);
142    }
143
144    #[test]
145    fn rejects_names_with_path_separators() {
146        let dir = tempfile::tempdir().unwrap();
147        std::fs::create_dir(dir.path().join("sub")).unwrap();
148        make_runnable(&dir.path().join("sub"), "tool");
149        let path = path_of(&[dir.path()]);
150        assert_eq!(find_on_path("sub/tool", &path), None);
151        assert_eq!(find_on_path("sub\\tool", &path), None);
152    }
153
154    #[test]
155    fn rejects_empty_and_wildcard_names() {
156        let dir = tempfile::tempdir().unwrap();
157        make_runnable(dir.path(), "tool");
158        let path = path_of(&[dir.path()]);
159        for name in ["", ".", "..", "to*", "to?l", "c:tool"] {
160            assert_eq!(find_on_path(name, &path), None, "{name:?}");
161        }
162    }
163
164    #[test]
165    fn ignores_empty_path_entries() {
166        // An empty entry means "current directory" to some shells; a
167        // dependency check must never resolve relative to the CWD.
168        assert_eq!(find_on_path("tool", OsStr::new("")), None);
169    }
170
171    #[cfg(windows)]
172    #[test]
173    fn windows_finds_cmd_and_bat_shims() {
174        // npm installs global tools as `.cmd` shims on Windows; spawning the
175        // resolved path runs them through cmd.exe, so they count as found.
176        let dir = tempfile::tempdir().unwrap();
177        let cmd = dir.path().join("patreon-dl.cmd");
178        let bat = dir.path().join("legacy.bat");
179        std::fs::write(&cmd, b"").unwrap();
180        std::fs::write(&bat, b"").unwrap();
181        let path = path_of(&[dir.path()]);
182        assert_eq!(find_on_path("patreon-dl", &path), Some(cmd));
183        assert_eq!(find_on_path("legacy", &path), Some(bat));
184    }
185
186    #[cfg(windows)]
187    #[test]
188    fn windows_prefers_exe_over_shim_in_the_same_directory() {
189        let dir = tempfile::tempdir().unwrap();
190        std::fs::write(dir.path().join("tool.cmd"), b"").unwrap();
191        let exe = make_runnable(dir.path(), "tool");
192        assert_eq!(find_on_path("tool", &path_of(&[dir.path()])), Some(exe));
193    }
194
195    #[cfg(windows)]
196    #[test]
197    fn windows_earlier_directory_wins_over_extension() {
198        // Like cmd.exe: each PATH entry is searched for every extension
199        // before moving on, so an earlier `.cmd` beats a later `.exe`.
200        let first = tempfile::tempdir().unwrap();
201        let second = tempfile::tempdir().unwrap();
202        make_runnable(second.path(), "tool");
203        let shim = first.path().join("tool.cmd");
204        std::fs::write(&shim, b"").unwrap();
205        let path = path_of(&[first.path(), second.path()]);
206        assert_eq!(find_on_path("tool", &path), Some(shim));
207    }
208
209    #[cfg(windows)]
210    #[test]
211    fn windows_ignores_other_pathext_types() {
212        // Scripts like `.ps1` / `.vbs` need an interpreter std won't pick.
213        let dir = tempfile::tempdir().unwrap();
214        std::fs::write(dir.path().join("tool.ps1"), b"").unwrap();
215        std::fs::write(dir.path().join("tool.vbs"), b"").unwrap();
216        assert_eq!(find_on_path("tool", &path_of(&[dir.path()])), None);
217    }
218
219    #[cfg(windows)]
220    #[test]
221    fn windows_accepts_explicit_suffix_case_insensitively() {
222        let dir = tempfile::tempdir().unwrap();
223        let exe = make_runnable(dir.path(), "tool");
224        std::fs::write(dir.path().join("shim.cmd"), b"").unwrap();
225        let path = path_of(&[dir.path()]);
226        assert_eq!(find_on_path("tool.exe", &path), Some(exe.clone()));
227        assert!(find_on_path("TOOL.EXE", &path).is_some());
228        assert!(find_on_path("SHIM.CMD", &path).is_some());
229    }
230
231    #[cfg(windows)]
232    #[test]
233    fn windows_ignores_extensionless_file() {
234        let dir = tempfile::tempdir().unwrap();
235        std::fs::write(dir.path().join("tool"), b"").unwrap();
236        assert_eq!(find_on_path("tool", &path_of(&[dir.path()])), None);
237    }
238
239    #[cfg(unix)]
240    #[test]
241    fn unix_skips_non_executable_file() {
242        use std::os::unix::fs::PermissionsExt;
243        let dir = tempfile::tempdir().unwrap();
244        let file = dir.path().join("tool");
245        std::fs::write(&file, b"").unwrap();
246        std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap();
247        assert_eq!(find_on_path("tool", &path_of(&[dir.path()])), None);
248    }
249
250    #[cfg(unix)]
251    #[test]
252    fn unix_follows_symlinks_to_executables() {
253        let dir = tempfile::tempdir().unwrap();
254        let real = make_runnable(dir.path(), "real-tool");
255        std::os::unix::fs::symlink(&real, dir.path().join("tool")).unwrap();
256        assert!(find_on_path("tool", &path_of(&[dir.path()])).is_some());
257    }
258}