Skip to main content

ic_testkit/artifacts/
tool.rs

1use std::{
2    ffi::OsStr,
3    fs, io,
4    path::{Path, PathBuf},
5};
6
7#[cfg(windows)]
8use std::ffi::OsString;
9
10/// Resolve one executable exactly as an artifact-cache tool input.
11///
12/// Paths containing more than one component are resolved directly. Bare
13/// program names are searched through the current `PATH`. The returned path is
14/// canonical, points to a regular file, and can be passed to
15/// [`super::ArtifactCacheSpec::with_tool`].
16/// Absolute paths do not depend on the current directory. Missing, non-directory,
17/// and non-executable search candidates are skipped; explicit paths retain
18/// their errors.
19pub fn resolve_executable(program: impl AsRef<OsStr>) -> io::Result<PathBuf> {
20    let program = program.as_ref();
21    if program.is_empty() {
22        return Err(io::Error::new(
23            io::ErrorKind::InvalidInput,
24            "executable name must not be empty",
25        ));
26    }
27    let path = Path::new(program);
28    if path.is_absolute() {
29        return canonical_executable(path);
30    }
31    let current_dir = std::env::current_dir()?;
32    if path.components().count() > 1 {
33        return canonical_executable(&current_dir.join(path));
34    }
35
36    let search_path = std::env::var_os("PATH").ok_or_else(|| {
37        io::Error::new(
38            io::ErrorKind::NotFound,
39            format!(
40                "cannot resolve executable `{}` because PATH is unset",
41                program.to_string_lossy()
42            ),
43        )
44    })?;
45    resolve_executable_in(program, &search_path, &current_dir)
46}
47
48fn resolve_executable_in(
49    program: &OsStr,
50    search_path: &OsStr,
51    current_dir: &Path,
52) -> io::Result<PathBuf> {
53    for directory in std::env::split_paths(search_path) {
54        let directory = if directory.as_os_str().is_empty() {
55            current_dir.to_owned()
56        } else if directory.is_absolute() {
57            directory
58        } else {
59            current_dir.join(directory)
60        };
61        for candidate in executable_candidates(&directory, program) {
62            match canonical_executable(&candidate) {
63                Ok(path) => return Ok(path),
64                Err(error)
65                    if matches!(
66                        error.kind(),
67                        io::ErrorKind::NotFound
68                            | io::ErrorKind::NotADirectory
69                            | io::ErrorKind::PermissionDenied
70                    ) => {}
71                Err(error) => return Err(error),
72            }
73        }
74    }
75    Err(io::Error::new(
76        io::ErrorKind::NotFound,
77        format!(
78            "executable `{}` was not found in PATH",
79            program.to_string_lossy()
80        ),
81    ))
82}
83
84fn canonical_executable(path: &Path) -> io::Result<PathBuf> {
85    let canonical = path.canonicalize()?;
86    let metadata = fs::metadata(&canonical)?;
87    if !metadata.is_file() {
88        return Err(io::Error::new(
89            io::ErrorKind::NotFound,
90            format!("executable path is not a regular file: {}", path.display()),
91        ));
92    }
93    if !is_executable(&metadata) {
94        return Err(io::Error::new(
95            io::ErrorKind::PermissionDenied,
96            format!("file is not executable: {}", path.display()),
97        ));
98    }
99    Ok(canonical)
100}
101
102#[cfg(unix)]
103fn is_executable(metadata: &fs::Metadata) -> bool {
104    use std::os::unix::fs::PermissionsExt as _;
105    metadata.permissions().mode() & 0o111 != 0
106}
107
108#[cfg(not(unix))]
109fn is_executable(_metadata: &fs::Metadata) -> bool {
110    true
111}
112
113#[cfg(windows)]
114fn executable_candidates(directory: &Path, program: &OsStr) -> Vec<PathBuf> {
115    let program_path = Path::new(program);
116    if program_path.extension().is_some() {
117        return vec![directory.join(program_path)];
118    }
119    let extensions =
120        std::env::var_os("PATHEXT").unwrap_or_else(|| OsString::from(".COM;.EXE;.BAT;.CMD"));
121    extensions
122        .to_string_lossy()
123        .split(';')
124        .filter(|extension| !extension.is_empty())
125        .map(|extension| {
126            let mut name = program.to_os_string();
127            name.push(extension);
128            directory.join(name)
129        })
130        .collect()
131}
132
133#[cfg(not(windows))]
134fn executable_candidates(directory: &Path, program: &OsStr) -> Vec<PathBuf> {
135    vec![directory.join(program)]
136}
137
138#[cfg(test)]
139mod tests {
140    use super::resolve_executable_in;
141    use crate::artifacts::test_support::unique_temp_directory;
142    use std::{ffi::OsStr, fs};
143
144    #[cfg(unix)]
145    use crate::artifacts::test_support::write_executable_script;
146
147    #[test]
148    #[cfg(unix)]
149    fn path_resolution_returns_one_canonical_executable_file() {
150        let root = unique_temp_directory("resolve-executable")
151            .canonicalize()
152            .unwrap();
153        let bin = root.join("bin");
154        fs::create_dir_all(&bin).expect("create executable search directory");
155        let tool = bin.join("optimizer");
156        write_executable_script(&tool, b"#!/bin/sh\nexit 0\n");
157
158        for (search, current) in [
159            (bin.as_os_str(), &root),
160            (OsStr::new("bin"), &root),
161            (OsStr::new(""), &bin),
162        ] {
163            let resolved = resolve_executable_in(OsStr::new("optimizer"), search, current)
164                .expect("resolve executable from supplied PATH");
165            assert_eq!(resolved, tool.canonicalize().unwrap());
166        }
167        fs::remove_dir_all(root).expect("remove executable-resolution fixture");
168    }
169
170    #[test]
171    #[cfg(unix)]
172    fn path_resolution_skips_unusable_search_entries() {
173        let root = unique_temp_directory("resolve-executable-search")
174            .canonicalize()
175            .unwrap();
176        let not_directory = root.join("not-directory");
177        fs::write(&not_directory, b"file").unwrap();
178        let not_executable = root.join("not-executable");
179        fs::create_dir(&not_executable).unwrap();
180        fs::write(not_executable.join("optimizer"), b"not executable").unwrap();
181        let directory_candidate = root.join("directory-candidate");
182        fs::create_dir_all(directory_candidate.join("optimizer")).unwrap();
183        let bin = root.join("bin");
184        fs::create_dir(&bin).unwrap();
185        let tool = bin.join("optimizer");
186        write_executable_script(&tool, b"#!/bin/sh\nexit 0\n");
187        let search = std::env::join_paths([
188            root.join("missing"),
189            not_directory.clone(),
190            not_executable.clone(),
191            directory_candidate,
192            bin,
193        ])
194        .unwrap();
195        assert_eq!(
196            resolve_executable_in(OsStr::new("optimizer"), &search, &root).unwrap(),
197            tool.canonicalize().unwrap()
198        );
199        assert_eq!(
200            super::resolve_executable(not_directory.join("optimizer"))
201                .unwrap_err()
202                .kind(),
203            std::io::ErrorKind::NotADirectory
204        );
205        assert_eq!(
206            super::resolve_executable(not_executable.join("optimizer"))
207                .unwrap_err()
208                .kind(),
209            std::io::ErrorKind::PermissionDenied
210        );
211        assert_eq!(
212            resolve_executable_in(OsStr::new("missing"), &search, &root)
213                .unwrap_err()
214                .kind(),
215            std::io::ErrorKind::NotFound
216        );
217        fs::remove_dir_all(root).unwrap();
218    }
219
220    #[test]
221    #[cfg(unix)]
222    fn absolute_executable_resolution_does_not_require_current_directory() {
223        const CHILD_ENV: &str = "IC_TESTKIT_EXECUTABLE_REMOVED_CWD_CHILD";
224        if std::env::var_os(CHILD_ENV).is_none() {
225            // Removing the working directory must not affect other test threads.
226            let child = std::process::Command::new(std::env::current_exe().unwrap())
227                .args([
228                    "--exact",
229                    "artifacts::tool::tests::absolute_executable_resolution_does_not_require_current_directory",
230                    "--test-threads=1",
231                ])
232                .env(CHILD_ENV, "1")
233                .output()
234                .unwrap();
235            assert!(
236                child.status.success(),
237                "absolute resolution regression failed: {}{}",
238                String::from_utf8_lossy(&child.stdout),
239                String::from_utf8_lossy(&child.stderr)
240            );
241            return;
242        }
243        let root = unique_temp_directory("resolve-executable-removed-cwd")
244            .canonicalize()
245            .unwrap();
246        let tool = root.join("optimizer");
247        write_executable_script(&tool, b"#!/bin/sh\nexit 0\n");
248        let expected = tool.canonicalize().unwrap();
249        std::env::set_current_dir(&root).unwrap();
250        assert_eq!(super::resolve_executable("./optimizer").unwrap(), expected);
251        let removed = root.join("removed");
252        fs::create_dir(&removed).unwrap();
253        std::env::set_current_dir(&removed).unwrap();
254        fs::remove_dir(&removed).unwrap();
255        assert!(std::env::current_dir().is_err());
256        assert_eq!(super::resolve_executable(&tool).unwrap(), expected);
257        assert!(super::resolve_executable("optimizer").is_err());
258        std::env::set_current_dir(&root).unwrap();
259        fs::remove_dir_all(root).unwrap();
260    }
261}