bnto_core/
binary_probe.rs1use std::ffi::OsStr;
10use std::path::{Path, PathBuf};
11
12const REJECTED_CHARS: [char; 5] = ['/', '\\', '*', '?', ':'];
16
17pub 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#[cfg(windows)]
41const SPAWNABLE_EXTENSIONS: [&str; 3] = [".exe", ".cmd", ".bat"];
42
43#[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#[cfg(windows)]
69fn is_runnable(path: &Path) -> bool {
70 std::fs::symlink_metadata(path).is_ok_and(|m| !m.is_dir())
71}
72
73#[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#[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 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 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 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 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 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}