mant_loader/
executable.rs1use std::{collections::BTreeMap, env, ffi::OsStr, path::PathBuf};
4
5#[cfg(unix)]
6use std::os::unix::fs::PermissionsExt;
7
8pub struct ExecutableLookup<'env> {
16 environment: &'env BTreeMap<String, String>,
17}
18
19impl<'env> ExecutableLookup<'env> {
20 #[must_use]
22 pub const fn new(environment: &'env BTreeMap<String, String>) -> Self {
23 Self { environment }
24 }
25
26 #[must_use]
31 pub fn environment_value(&self, name: &str) -> Option<&'env str> {
32 environment_value(self.environment, name)
33 }
34
35 #[must_use]
40 pub fn find(&self, name: &str) -> Option<PathBuf> {
41 find_executable(name, self.environment)
42 }
43}
44
45pub(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
68pub(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#[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
120pub(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#[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}