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