Skip to main content

mant_loader/
executable.rs

1//! Resolves directly runnable programs using native host conventions.
2
3use std::{collections::BTreeMap, env, ffi::OsStr, path::PathBuf};
4
5#[cfg(unix)]
6use std::os::unix::fs::PermissionsExt;
7
8/// Read-only executable lookup against a caller-owned environment snapshot.
9///
10/// Construction borrows the map without reading the process environment.
11/// Lookup applies the native `PATH`/`PATHEXT` rules and inspects candidate file
12/// metadata; it never starts a program or creates cache state. The snapshot
13/// does not freeze the filesystem, and a returned path grants no execution
14/// authority to a caller.
15pub struct ExecutableLookup<'env> {
16    environment: &'env BTreeMap<String, String>,
17}
18
19impl<'env> ExecutableLookup<'env> {
20    /// Borrow an existing environment without copying or refreshing it.
21    #[must_use]
22    pub const fn new(environment: &'env BTreeMap<String, String>) -> Self {
23        Self { environment }
24    }
25
26    /// Read one borrowed value with native environment-name case rules.
27    ///
28    /// Exact spelling wins. Windows additionally accepts ASCII case variants;
29    /// other platforms retain case-sensitive lookup.
30    #[must_use]
31    pub fn environment_value(&self, name: &str) -> Option<&'env str> {
32        environment_value(self.environment, name)
33    }
34
35    /// Find the first directly runnable candidate without executing it.
36    ///
37    /// Candidate precedence and executable checks use the current platform's
38    /// conventions. A missing `PATH` or eligible file returns `None`.
39    #[must_use]
40    pub fn find(&self, name: &str) -> Option<PathBuf> {
41        find_executable(name, self.environment)
42    }
43}
44
45/// Look up an environment value while respecting Windows' case-insensitive
46/// variable names.
47pub(crate) fn environment_value<'a>(
48    environment: &'a BTreeMap<String, String>,
49    name: &str,
50) -> Option<&'a str> {
51    if let Some(value) = environment.get(name) {
52        return Some(value.as_str());
53    }
54
55    #[cfg(windows)]
56    {
57        environment
58            .iter()
59            .find(|(candidate, _)| candidate.eq_ignore_ascii_case(name))
60            .map(|(_, value)| value.as_str())
61    }
62    #[cfg(not(windows))]
63    {
64        None
65    }
66}
67
68/// Find a program that the current host can execute directly.
69pub(crate) fn find_executable(
70    name: &str,
71    environment: &BTreeMap<String, String>,
72) -> Option<PathBuf> {
73    let path = environment_value(environment, "PATH")?;
74    let names = executable_names(name, environment);
75    env::split_paths(OsStr::new(path))
76        .flat_map(|directory| names.iter().map(move |name| directory.join(name)))
77        .find(|candidate| is_executable(candidate))
78}
79
80/// Locate one directly runnable program using the current host's `PATH` and
81/// native executable-suffix rules without spawning it.
82#[must_use]
83pub fn find_host_executable(name: &str) -> Option<PathBuf> {
84    let environment = env::vars().collect::<BTreeMap<_, _>>();
85    ExecutableLookup::new(&environment).find(name)
86}
87
88#[cfg(unix)]
89fn executable_names(name: &str, _environment: &BTreeMap<String, String>) -> Vec<String> {
90    vec![name.to_owned()]
91}
92
93#[cfg(windows)]
94fn executable_names(name: &str, environment: &BTreeMap<String, String>) -> Vec<String> {
95    if std::path::Path::new(name).extension().is_some() {
96        return vec![name.to_owned()];
97    }
98    windows_name_candidates(name, environment_value(environment, "PATHEXT"))
99        .into_iter()
100        .skip(1)
101        .collect()
102}
103
104#[cfg(not(any(unix, windows)))]
105fn executable_names(name: &str, _environment: &BTreeMap<String, String>) -> Vec<String> {
106    vec![name.to_owned()]
107}
108
109#[cfg(unix)]
110fn is_executable(path: &std::path::Path) -> bool {
111    path.metadata()
112        .is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
113}
114
115#[cfg(windows)]
116fn is_executable(path: &std::path::Path) -> bool {
117    path.is_file()
118}
119
120/// Names that preserve an exact document lookup before applying native host
121/// command-suffix conventions.
122pub(crate) fn query_name_candidates(name: &str) -> Vec<String> {
123    #[cfg(windows)]
124    {
125        windows_name_candidates(name, env::var("PATHEXT").ok().as_deref())
126    }
127    #[cfg(not(windows))]
128    {
129        vec![name.to_owned()]
130    }
131}
132
133/// Model Windows command-name elision without depending on the build host.
134///
135/// The exact name remains first because registered documents may intentionally
136/// have no executable suffix. Only extensionless names are expanded.
137#[cfg(any(windows, test))]
138fn windows_name_candidates(name: &str, pathext: Option<&str>) -> Vec<String> {
139    let mut candidates = vec![name.to_owned()];
140    if std::path::Path::new(name).extension().is_some() {
141        return candidates;
142    }
143
144    let extensions = pathext
145        .filter(|value| !value.trim().is_empty())
146        .unwrap_or(".COM;.EXE;.BAT;.CMD");
147    for extension in extensions.split(';').map(str::trim) {
148        if extension.is_empty() || extension.contains(['/', '\\']) {
149            continue;
150        }
151        let extension = if extension.starts_with('.') {
152            extension.to_owned()
153        } else {
154            format!(".{extension}")
155        };
156        let candidate = format!("{name}{extension}");
157        if !candidates
158            .iter()
159            .any(|existing| existing.eq_ignore_ascii_case(&candidate))
160        {
161            candidates.push(candidate);
162        }
163    }
164    candidates
165}
166
167#[cfg(test)]
168mod tests {
169    use super::windows_name_candidates;
170
171    #[test]
172    fn lookup_borrows_values_and_keeps_native_environment_case_rules() {
173        use std::collections::BTreeMap;
174
175        let mut environment = BTreeMap::from([("Path".to_owned(), "mixed".to_owned())]);
176        let lookup = super::ExecutableLookup::new(&environment);
177        let value = lookup.environment_value("Path").unwrap();
178        assert!(std::ptr::eq(value.as_ptr(), environment["Path"].as_ptr()));
179        assert_eq!(
180            lookup.environment_value("PATH"),
181            cfg!(windows).then_some("mixed")
182        );
183        assert_eq!(lookup.environment_value("missing"), None);
184
185        environment.insert("PATH".to_owned(), "exact".to_owned());
186        assert_eq!(
187            super::ExecutableLookup::new(&environment).environment_value("PATH"),
188            Some("exact"),
189            "exact spelling retains precedence even with a case-colliding map"
190        );
191        assert_eq!(
192            super::ExecutableLookup::new(&BTreeMap::new()).find("tool"),
193            None,
194            "lookup must not fall back to the process PATH"
195        );
196    }
197
198    #[cfg(any(unix, windows))]
199    #[test]
200    fn executable_probe_inspects_candidates_without_running_them() {
201        use std::{collections::BTreeMap, fs, path::PathBuf, time::SystemTime};
202
203        struct Fixture(PathBuf);
204        impl Drop for Fixture {
205            fn drop(&mut self) {
206                let _ = fs::remove_dir_all(&self.0);
207            }
208        }
209
210        let nonce = SystemTime::now()
211            .duration_since(SystemTime::UNIX_EPOCH)
212            .unwrap()
213            .as_nanos();
214        let target = std::env::var_os("CARGO_TARGET_DIR").map_or_else(
215            || PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target"),
216            PathBuf::from,
217        );
218        fs::create_dir_all(&target).unwrap();
219        let directory = target.join(format!("executable-probe-{}-{nonce}", std::process::id()));
220        fs::create_dir(&directory).unwrap();
221        let fixture = Fixture(directory);
222        #[cfg(unix)]
223        let (name, script) = ("probe", "#!/bin/sh\n: > \"$0.was-run\"\nexit 1\n");
224        #[cfg(windows)]
225        let (name, script) = (
226            "probe.CMD",
227            "@echo off\r\necho ran>\"%~f0.was-run\"\r\nexit /b 1\r\n",
228        );
229        let candidate = fixture.0.join(name);
230        fs::write(&candidate, script).unwrap();
231        #[cfg(unix)]
232        {
233            use std::os::unix::fs::PermissionsExt;
234            fs::set_permissions(&candidate, fs::Permissions::from_mode(0o700)).unwrap();
235        }
236        let environment = BTreeMap::from([(
237            "PATH".to_owned(),
238            std::env::join_paths([&fixture.0])
239                .unwrap()
240                .into_string()
241                .unwrap(),
242        )]);
243        assert_eq!(
244            super::ExecutableLookup::new(&environment).find(name),
245            Some(candidate.clone())
246        );
247        assert!(!fixture.0.join(format!("{name}.was-run")).exists());
248        assert_eq!(fs::read_to_string(candidate).unwrap(), script);
249    }
250
251    #[test]
252    fn windows_candidates_keep_exact_names_before_pathext_order() {
253        assert_eq!(
254            windows_name_candidates("cargo", Some(".EXE;.CMD;.PS1")),
255            ["cargo", "cargo.EXE", "cargo.CMD", "cargo.PS1"]
256        );
257    }
258
259    #[test]
260    fn windows_candidates_do_not_expand_an_explicit_suffix() {
261        assert_eq!(
262            windows_name_candidates("where.exe", Some(".EXE;.CMD")),
263            ["where.exe"]
264        );
265    }
266
267    #[test]
268    fn windows_candidates_use_the_native_default_when_pathext_is_absent() {
269        assert_eq!(
270            windows_name_candidates("tool", None),
271            ["tool", "tool.COM", "tool.EXE", "tool.BAT", "tool.CMD"]
272        );
273    }
274
275    #[test]
276    fn windows_candidates_normalise_and_deduplicate_extensions() {
277        assert_eq!(
278            windows_name_candidates("tool", Some(" EXE ;.exe;.CMD;bad/path;.PS1 ")),
279            ["tool", "tool.EXE", "tool.CMD", "tool.PS1"]
280        );
281    }
282
283    #[cfg(not(windows))]
284    #[test]
285    fn non_windows_queries_never_elide_executable_suffixes() {
286        assert_eq!(super::query_name_candidates("tool"), ["tool"]);
287    }
288}
289
290#[cfg(not(any(unix, windows)))]
291fn is_executable(path: &std::path::Path) -> bool {
292    path.is_file()
293}