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