Skip to main content

rumdl_lib/code_block_tools/
lookup.rs

1//! Locating a tool binary the way the process spawner would.
2//!
3//! Availability is answered in-process rather than by spawning `which` or
4//! `where`. Neither finder is part of any platform contract (a minimal container
5//! image routinely lacks `which`), and a spawn that fails because the finder is
6//! missing is indistinguishable from the tool being missing: every configured
7//! tool then reports "not found in PATH" while sitting on the PATH. The rules
8//! here mirror what `Command::new` itself resolves, so a tool reported present is
9//! one that will spawn and one reported missing is one that would not have.
10
11use std::ffi::OsStr;
12#[cfg(any(unix, windows))]
13use std::path::Path;
14use std::path::PathBuf;
15
16/// Where `Command::new(program)` would find `program`, or `None` if it would not.
17///
18/// `search_path` is the `PATH` value to search, normally the environment's; it is
19/// a parameter so the lookup can be exercised against a controlled directory.
20///
21/// On unix this follows `execvp`: a name containing a slash is taken as a path
22/// and never searched for, anything else is looked up in each `PATH` entry in
23/// order (an empty entry meaning the current directory), and a match must be a
24/// regular file with an execute bit. When `PATH` is unset the libc default
25/// `/usr/bin:/bin` applies.
26///
27/// On windows this follows the standard library's own resolution. A name with a
28/// path separator is never searched for: one ending in `.exe` is used as written,
29/// any other is tried with `.exe` appended to the name as written and then as
30/// written. A bare name is searched in the directory of the running executable,
31/// the system directory, the Windows directory and then each `PATH` entry, in
32/// that order, with `.exe` appended when the name contains no `.` at all; the
33/// working directory is not searched. `PATHEXT` is deliberately not consulted,
34/// because `Command::new` does not consult it either: a `tool.cmd` shim is not
35/// something the spawn would find under the bare name.
36pub fn resolve_program(program: &OsStr, search_path: Option<&OsStr>) -> Option<PathBuf> {
37    if program.is_empty() {
38        return None;
39    }
40    resolve_for_platform(program, search_path)
41}
42
43#[cfg(unix)]
44fn resolve_for_platform(program: &OsStr, search_path: Option<&OsStr>) -> Option<PathBuf> {
45    use std::os::unix::ffi::OsStrExt;
46
47    if program.as_bytes().contains(&b'/') {
48        let path = Path::new(program);
49        return is_executable_file(path).then(|| path.to_path_buf());
50    }
51
52    let default_path = OsStr::new("/usr/bin:/bin");
53    let search_path = search_path.unwrap_or(default_path);
54    std::env::split_paths(search_path)
55        .map(|dir| dir.join(program))
56        .find(|candidate| is_executable_file(candidate))
57}
58
59#[cfg(unix)]
60fn is_executable_file(path: &Path) -> bool {
61    use std::os::unix::fs::PermissionsExt;
62
63    std::fs::metadata(path).is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
64}
65
66#[cfg(windows)]
67fn resolve_for_platform(program: &OsStr, search_path: Option<&OsStr>) -> Option<PathBuf> {
68    use std::ffi::OsString;
69
70    let bytes = program.as_encoded_bytes();
71    if bytes.ends_with(b"\\") || bytes.ends_with(b"/") {
72        return None;
73    }
74    let has_separator = bytes.contains(&b'\\') || bytes.contains(&b'/');
75
76    if has_separator {
77        let path = Path::new(program);
78        let has_exe_suffix = bytes.len() >= 4 && bytes[bytes.len() - 4..].eq_ignore_ascii_case(b".exe");
79        if has_exe_suffix {
80            return path.is_file().then(|| path.to_path_buf());
81        }
82        // `.exe` is appended to the name as written (`dir\tool.cmd` is tried as
83        // `dir\tool.cmd.exe`), then the name as written is used.
84        let mut with_exe: OsString = program.to_os_string();
85        with_exe.push(".exe");
86        let with_exe = PathBuf::from(with_exe);
87        if with_exe.is_file() {
88            return Some(with_exe);
89        }
90        return path.is_file().then(|| path.to_path_buf());
91    }
92
93    // A bare name gets `.exe` only when it has no extension at all, and any `.`
94    // counts as one: `tool.v2` is looked up as written.
95    let has_extension = bytes.contains(&b'.');
96
97    let mut dirs: Vec<PathBuf> = Vec::new();
98    if let Some(dir) = std::env::current_exe()
99        .ok()
100        .and_then(|exe| exe.parent().map(Path::to_path_buf))
101    {
102        dirs.push(dir);
103    }
104    dirs.extend(windows_system_directories());
105    if let Some(search_path) = search_path {
106        dirs.extend(std::env::split_paths(search_path).filter(|dir| !dir.as_os_str().is_empty()));
107    }
108
109    dirs.into_iter().find_map(|dir| {
110        let mut candidate = dir.join(program);
111        if !has_extension {
112            candidate.set_extension("exe");
113        }
114        candidate.is_file().then_some(candidate)
115    })
116}
117
118/// The system directory and the Windows directory, asked of the system the way
119/// the spawner asks (`GetSystemDirectoryW`, then `GetWindowsDirectoryW`) rather
120/// than read from `SystemRoot`, so the answer holds in a process whose
121/// environment lacks or rewrites that variable.
122#[cfg(windows)]
123fn windows_system_directories() -> Vec<PathBuf> {
124    use std::ffi::OsString;
125    use std::os::windows::ffi::OsStringExt;
126    use windows_sys::Win32::System::SystemInformation::{GetSystemDirectoryW, GetWindowsDirectoryW};
127
128    // Each call reports the length written, or the length it needs (counting
129    // the terminating NUL) when the buffer is too small, or 0 on failure.
130    let query = |get: unsafe extern "system" fn(*mut u16, u32) -> u32| -> Option<PathBuf> {
131        let mut buf = vec![0u16; 260];
132        loop {
133            // SAFETY: `buf` is a live, writable buffer of `buf.len()` UTF-16
134            // units and that length is what the call is told it may write.
135            let len = unsafe { get(buf.as_mut_ptr(), u32::try_from(buf.len()).ok()?) } as usize;
136            if len == 0 {
137                return None;
138            }
139            if len < buf.len() {
140                buf.truncate(len);
141                return Some(PathBuf::from(OsString::from_wide(&buf)));
142            }
143            buf.resize(len, 0);
144        }
145    };
146    [GetSystemDirectoryW, GetWindowsDirectoryW]
147        .into_iter()
148        .filter_map(query)
149        .collect()
150}
151
152#[cfg(not(any(unix, windows)))]
153fn resolve_for_platform(_program: &OsStr, _search_path: Option<&OsStr>) -> Option<PathBuf> {
154    // No process spawning on this platform, so no tool can be run.
155    None
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    #[cfg(unix)]
162    use std::ffi::OsString;
163
164    #[cfg(unix)]
165    fn write_tool(dir: &Path, name: &str, executable: bool) -> PathBuf {
166        use std::os::unix::fs::PermissionsExt;
167
168        let path = dir.join(name);
169        std::fs::write(&path, "#!/bin/sh\nexit 0\n").unwrap();
170        let mode = if executable { 0o755 } else { 0o644 };
171        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap();
172        path
173    }
174
175    #[test]
176    fn empty_program_name_never_resolves() {
177        assert_eq!(resolve_program(OsStr::new(""), Some(OsStr::new("/usr/bin"))), None);
178    }
179
180    #[cfg(unix)]
181    #[test]
182    fn tool_on_a_path_without_which_resolves() {
183        // Issue #820: the search must not depend on a `which` binary. The PATH
184        // handed in holds only the temp dir, so there is no `which` anywhere the
185        // lookup can see, and the tool must still be found.
186        let dir = tempfile::tempdir().unwrap();
187        let tool = write_tool(dir.path(), "shellcheck", true);
188        assert_eq!(
189            resolve_program(OsStr::new("shellcheck"), Some(dir.path().as_os_str())),
190            Some(tool)
191        );
192    }
193
194    #[cfg(unix)]
195    #[test]
196    fn file_without_execute_bit_does_not_resolve() {
197        // Same name, same directory, only the mode differs: the negative control
198        // for the test above.
199        let dir = tempfile::tempdir().unwrap();
200        write_tool(dir.path(), "shellcheck", false);
201        assert_eq!(
202            resolve_program(OsStr::new("shellcheck"), Some(dir.path().as_os_str())),
203            None
204        );
205    }
206
207    #[cfg(unix)]
208    #[test]
209    fn absent_tool_does_not_resolve() {
210        let dir = tempfile::tempdir().unwrap();
211        write_tool(dir.path(), "shellcheck", true);
212        assert_eq!(resolve_program(OsStr::new("shfmt"), Some(dir.path().as_os_str())), None);
213    }
214
215    #[cfg(unix)]
216    #[test]
217    fn directory_named_like_the_tool_does_not_resolve() {
218        let dir = tempfile::tempdir().unwrap();
219        std::fs::create_dir(dir.path().join("shellcheck")).unwrap();
220        assert_eq!(
221            resolve_program(OsStr::new("shellcheck"), Some(dir.path().as_os_str())),
222            None
223        );
224    }
225
226    #[cfg(unix)]
227    #[test]
228    fn first_path_entry_wins() {
229        let first = tempfile::tempdir().unwrap();
230        let second = tempfile::tempdir().unwrap();
231        let expected = write_tool(first.path(), "ruff", true);
232        write_tool(second.path(), "ruff", true);
233        let path = std::env::join_paths([first.path(), second.path()]).unwrap();
234        assert_eq!(resolve_program(OsStr::new("ruff"), Some(&path)), Some(expected));
235    }
236
237    #[cfg(unix)]
238    #[test]
239    fn a_name_with_a_slash_is_a_path_and_is_not_searched() {
240        // `Command::new("bin/tool")` spawns relative to the working directory and
241        // never consults PATH; the lookup must agree, or a tool that will not
242        // spawn is reported present.
243        let on_path = tempfile::tempdir().unwrap();
244        write_tool(on_path.path(), "tool", true);
245        let elsewhere = tempfile::tempdir().unwrap();
246        let absolute = write_tool(elsewhere.path(), "tool", true);
247
248        assert_eq!(
249            resolve_program(absolute.as_os_str(), Some(on_path.path().as_os_str())),
250            Some(absolute.clone())
251        );
252        let missing: OsString = elsewhere.path().join("bin").join("tool").into();
253        assert_eq!(resolve_program(&missing, Some(on_path.path().as_os_str())), None);
254    }
255
256    #[cfg(unix)]
257    #[test]
258    fn a_path_to_a_non_executable_file_does_not_resolve() {
259        let dir = tempfile::tempdir().unwrap();
260        let plain = write_tool(dir.path(), "tool", false);
261        assert_eq!(resolve_program(plain.as_os_str(), None), None);
262    }
263
264    #[cfg(unix)]
265    #[test]
266    fn unset_path_falls_back_to_the_libc_default() {
267        // `sh` lives in /bin on every unix; a tool that exists nowhere does not.
268        assert!(resolve_program(OsStr::new("sh"), None).is_some());
269        assert_eq!(resolve_program(OsStr::new("rumdl-no-such-tool-820"), None), None);
270    }
271
272    #[cfg(windows)]
273    #[test]
274    fn bare_name_resolves_to_exe_on_path() {
275        let dir = tempfile::tempdir().unwrap();
276        let exe = dir.path().join("shellcheck.exe");
277        std::fs::write(&exe, b"").unwrap();
278        assert_eq!(
279            resolve_program(OsStr::new("shellcheck"), Some(dir.path().as_os_str())),
280            Some(exe.clone())
281        );
282        assert_eq!(
283            resolve_program(OsStr::new("shellcheck.exe"), Some(dir.path().as_os_str())),
284            Some(exe)
285        );
286    }
287
288    #[cfg(windows)]
289    #[test]
290    fn cmd_shim_is_not_found_under_the_bare_name() {
291        // `Command::new("tool")` appends `.exe`, never `.cmd`, so a shim that only
292        // `where` would report must not count as available.
293        let dir = tempfile::tempdir().unwrap();
294        std::fs::write(dir.path().join("tool.cmd"), b"").unwrap();
295        assert_eq!(resolve_program(OsStr::new("tool"), Some(dir.path().as_os_str())), None);
296        let shim = dir.path().join("tool.cmd");
297        assert_eq!(resolve_program(shim.as_os_str(), None), Some(shim));
298    }
299
300    #[cfg(windows)]
301    #[test]
302    fn absent_tool_does_not_resolve() {
303        let dir = tempfile::tempdir().unwrap();
304        std::fs::write(dir.path().join("shellcheck.exe"), b"").unwrap();
305        assert_eq!(resolve_program(OsStr::new("shfmt"), Some(dir.path().as_os_str())), None);
306    }
307
308    #[cfg(windows)]
309    #[test]
310    fn any_dot_in_a_bare_name_is_its_extension() {
311        // The spawner appends `.exe` only to a name with no `.` at all, so
312        // `tool.v2` is looked up as written: it finds a file of that exact name
313        // and never `tool.v2.exe`.
314        let dir = tempfile::tempdir().unwrap();
315        let as_written = dir.path().join("tool.v2");
316        std::fs::write(&as_written, b"").unwrap();
317        assert_eq!(
318            resolve_program(OsStr::new("tool.v2"), Some(dir.path().as_os_str())),
319            Some(as_written)
320        );
321
322        let other = tempfile::tempdir().unwrap();
323        std::fs::write(other.path().join("tool.v2.exe"), b"").unwrap();
324        assert_eq!(
325            resolve_program(OsStr::new("tool.v2"), Some(other.path().as_os_str())),
326            None
327        );
328    }
329
330    #[cfg(windows)]
331    #[test]
332    fn a_sub_path_tries_exe_appended_and_then_the_name_as_written() {
333        let dir = tempfile::tempdir().unwrap();
334        let bare = dir.path().join("tool");
335        let exe = dir.path().join("tool.exe");
336        std::fs::write(&bare, b"").unwrap();
337        std::fs::write(&exe, b"").unwrap();
338        assert_eq!(resolve_program(bare.as_os_str(), None), Some(exe.clone()));
339        std::fs::remove_file(&exe).unwrap();
340        assert_eq!(resolve_program(bare.as_os_str(), None), Some(bare));
341
342        // `.exe` is appended to the name as written, not swapped for its
343        // extension: `dir\tool.cmd` is tried as `dir\tool.cmd.exe`.
344        let shim = dir.path().join("tool.cmd");
345        let shim_exe = dir.path().join("tool.cmd.exe");
346        std::fs::write(&shim, b"").unwrap();
347        std::fs::write(&shim_exe, b"").unwrap();
348        assert_eq!(resolve_program(shim.as_os_str(), None), Some(shim_exe));
349
350        // A name already ending in `.exe` is used as written and nothing else
351        // is tried for it.
352        let missing = dir.path().join("absent.exe");
353        assert_eq!(resolve_program(missing.as_os_str(), None), None);
354        let trailing = dir.path().join("tool\\");
355        assert_eq!(resolve_program(trailing.as_os_str(), None), None);
356    }
357
358    #[cfg(windows)]
359    #[test]
360    fn the_system_directory_is_searched_before_path() {
361        // `cmd` lives in the system directory, which the spawner searches before
362        // `PATH`, so a `cmd.exe` on `PATH` never shadows it and an empty `PATH`
363        // still finds it.
364        let system32 = windows_system_directories().into_iter().next().unwrap();
365        assert!(system32.join("cmd.exe").is_file(), "{system32:?} lacks cmd.exe");
366        let dir = tempfile::tempdir().unwrap();
367        std::fs::write(dir.path().join("cmd.exe"), b"").unwrap();
368        assert_eq!(
369            resolve_program(OsStr::new("cmd"), Some(dir.path().as_os_str())),
370            Some(system32.join("cmd.exe"))
371        );
372        let empty = tempfile::tempdir().unwrap();
373        assert_eq!(
374            resolve_program(OsStr::new("cmd"), Some(empty.path().as_os_str())),
375            Some(system32.join("cmd.exe"))
376        );
377    }
378
379    #[cfg(windows)]
380    #[test]
381    fn the_working_directory_is_not_searched() {
382        // A tool that exists only in the working directory would not spawn under
383        // its bare name, so it must not be reported present. The positive
384        // control puts the same directory on `PATH`.
385        let dir = tempfile::tempdir().unwrap();
386        let tool = dir.path().join("rumdl-lookup-probe.exe");
387        std::fs::write(&tool, b"").unwrap();
388        let elsewhere = tempfile::tempdir().unwrap();
389        let previous = std::env::current_dir().unwrap();
390        std::env::set_current_dir(dir.path()).unwrap();
391        let from_cwd = resolve_program(OsStr::new("rumdl-lookup-probe"), Some(elsewhere.path().as_os_str()));
392        let from_path = resolve_program(OsStr::new("rumdl-lookup-probe"), Some(dir.path().as_os_str()));
393        std::env::set_current_dir(previous).unwrap();
394        assert_eq!(from_cwd, None);
395        assert_eq!(from_path, Some(tool));
396    }
397}