Skip to main content

artifactize_tools/platform/
program.rs

1//! Literal program lookup, shared by preflight and process launches.
2
3use crate::platform;
4use std::{
5    env,
6    ffi::{OsStr, OsString},
7    io,
8    path::{Path, PathBuf},
9};
10
11/// The variable listing the directories a bare program name is looked up in.
12const SEARCH_PATH: &str = "PATH";
13/// The Windows variable listing the extensions an extensionless name is tried with.
14const EXTENSIONS: &str = "PATHEXT";
15
16/// Resolve against the inherited PATH and, on Windows, PATHEXT. A bare name never
17/// implicitly searches the working directory; relative PATH entries are based on `cwd`.
18pub fn resolve(program: &OsStr, cwd: &Path) -> io::Result<PathBuf> {
19    resolve_with(program, cwd, |name| env::var_os(name))
20}
21
22/// Resolve using the environment that the child will receive, not the caller's PATH:
23/// `variable` reads one variable of that environment.
24pub fn resolve_with(
25    program: &OsStr,
26    cwd: &Path,
27    variable: impl Fn(&str) -> Option<OsString>,
28) -> io::Result<PathBuf> {
29    resolve_from(
30        program,
31        cwd,
32        variable(SEARCH_PATH).as_deref(),
33        variable(EXTENSIONS).as_deref(),
34    )
35}
36
37fn resolve_from(
38    program: &OsStr,
39    cwd: &Path,
40    path: Option<&OsStr>,
41    pathext: Option<&OsStr>,
42) -> io::Result<PathBuf> {
43    let program = Path::new(program);
44    let candidates = candidates_from(program, pathext);
45    let explicit = program.is_absolute() || program.components().count() > 1;
46    let found = if explicit {
47        candidates
48            .into_iter()
49            .map(|candidate| cwd.join(candidate))
50            .find(|candidate| platform::is_executable(candidate))
51    } else {
52        env::split_paths(path.unwrap_or_default()).find_map(|directory| {
53            candidates
54                .iter()
55                .map(|candidate| cwd.join(&directory).join(candidate))
56                .find(|candidate| platform::is_executable(candidate))
57        })
58    };
59    found.ok_or_else(|| {
60        io::Error::new(
61            io::ErrorKind::NotFound,
62            format!(
63                "Program is unavailable: {}",
64                program.to_string_lossy().replace('\\', "/")
65            ),
66        )
67    })
68}
69
70/// Candidate paths in lookup order, for the environment `variable` reads. Scoped callers
71/// validate each candidate inside their own access boundary before allowing it to reach a
72/// process launcher.
73pub fn candidates(program: &Path, variable: impl Fn(&str) -> Option<OsString>) -> Vec<PathBuf> {
74    candidates_from(program, variable(EXTENSIONS).as_deref())
75}
76
77fn candidates_from(program: &Path, pathext: Option<&OsStr>) -> Vec<PathBuf> {
78    if platform::USES_PATHEXT && program.extension().is_none() {
79        pathext_candidates(program, pathext)
80    } else {
81        vec![program.to_owned()]
82    }
83}
84
85/// The Windows default when the variable is absent, not when explicitly empty.
86const DEFAULT_PATHEXT: &str = ".COM;.EXE;.BAT;.CMD";
87
88/// One candidate per `PATHEXT` suffix, in its order.
89fn pathext_candidates(program: &Path, pathext: Option<&OsStr>) -> Vec<PathBuf> {
90    pathext
91        .unwrap_or_else(|| OsStr::new(DEFAULT_PATHEXT))
92        .to_string_lossy()
93        .split(';')
94        .filter(|extension| extension.starts_with('.') && !extension.contains(['/', '\\']))
95        .map(|extension| {
96            let mut candidate = program.as_os_str().to_owned();
97            candidate.push(extension);
98            PathBuf::from(candidate)
99        })
100        .collect()
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn lookup_uses_path_order_relative_to_child_cwd_and_never_implicit_local_search() {
109        let root = crate::test_os::tempdir();
110        for directory in ["first", "second"] {
111            std::fs::create_dir(root.path().join(directory)).unwrap();
112            crate::test_os::lookup_program(&root.path().join(directory), "shim", None);
113        }
114        let program = crate::test_os::executable_name("shim");
115        let path = env::join_paths(["second", "first"]).unwrap();
116        assert_eq!(
117            resolve_with(OsStr::new(&program), root.path(), |name| (name
118                == SEARCH_PATH)
119                .then(|| path.clone()))
120            .unwrap(),
121            root.path().join("second").join(&program)
122        );
123        assert!(resolve_with(OsStr::new(&program), root.path(), |_| None).is_err());
124    }
125
126    // Only Windows applies PATHEXT when producing executable candidates.
127    #[cfg(windows)]
128    #[test]
129    fn pathext_candidates_keep_the_variable_order_and_skip_unsafe_suffixes() {
130        assert_eq!(
131            candidates(Path::new("dir/shim"), |_| Some(".CMD;bad;.a/b;.EXE".into())),
132            [PathBuf::from("dir/shim.CMD"), PathBuf::from("dir/shim.EXE")]
133        );
134        assert_eq!(
135            candidates(Path::new("shim"), |_| None),
136            [".COM", ".EXE", ".BAT", ".CMD"].map(|suffix| PathBuf::from(format!("shim{suffix}")))
137        );
138    }
139
140    // Only Windows looks up extensionless names with PATHEXT, on a case-insensitive volume.
141    #[cfg(windows)]
142    #[test]
143    fn extensionless_names_follow_pathext_order_not_exe_preference() {
144        let root = crate::test_os::tempdir();
145        for extension in ["cmd", "exe", "bat"] {
146            crate::test_os::lookup_program(root.path(), "shim", Some(extension));
147        }
148        let path = env::join_paths([root.path()]).unwrap();
149        assert_eq!(
150            resolve_with(OsStr::new("shim"), root.path(), |name| match name {
151                SEARCH_PATH => Some(path.clone()),
152                EXTENSIONS => Some(".CMD;.EXE".into()),
153                _ => None,
154            })
155            .unwrap(),
156            root.path().join("shim.CMD")
157        );
158        assert_eq!(
159            resolve_with(OsStr::new("shim"), root.path(), |name| match name {
160                SEARCH_PATH => Some(path.clone()),
161                EXTENSIONS => Some(".BAT;.CMD".into()),
162                _ => None,
163            })
164            .unwrap(),
165            root.path().join("shim.BAT")
166        );
167    }
168}