Skip to main content

ic_host_process/tool/resolution/
mod.rs

1//! Read-only executable selection from caller-supplied paths and search order.
2
3#[cfg(test)]
4mod tests;
5
6use std::{
7    fmt, fs, io,
8    os::unix::{ffi::OsStrExt as _, fs::PermissionsExt as _},
9    path::{Path, PathBuf},
10};
11
12/// Executable selection failed before any admission or execution.
13#[derive(Debug)]
14pub enum ResolutionError {
15    /// The request is empty, contains NUL, or is a bare `.` or `..`.
16    InvalidRequest,
17    /// The working directory is relative or contains NUL.
18    InvalidWorkingDirectory,
19    /// A caller-supplied search directory contains NUL.
20    InvalidSearchDirectory {
21        /// Zero-based position in the caller's search order.
22        index: usize,
23    },
24    /// No regular file with Unix executable permission bits was found.
25    NotFound,
26    /// An explicitly requested path is not a regular file.
27    NotRegularFile,
28    /// An explicitly requested file has no Unix executable permission bits.
29    NotExecutable,
30    /// Metadata or canonicalization failed. Search stops on errors other than
31    /// missing candidates rather than silently selecting a later directory.
32    Io {
33        /// Search-directory position, or `None` for an explicitly requested path.
34        directory: Option<usize>,
35        /// Underlying filesystem failure, without rendered input paths.
36        source: io::Error,
37    },
38}
39
40impl fmt::Display for ResolutionError {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        match self {
43            Self::InvalidRequest => f.write_str("invalid executable request"),
44            Self::InvalidWorkingDirectory => f.write_str(
45                "executable resolution requires an absolute working directory without NUL",
46            ),
47            Self::InvalidSearchDirectory { index } => {
48                write!(f, "executable search directory {index} contains NUL")
49            }
50            Self::NotFound => {
51                f.write_str("executable was not found in the supplied search directories")
52            }
53            Self::NotRegularFile => f.write_str("requested executable is not a regular file"),
54            Self::NotExecutable => f.write_str("requested file has no executable permission bits"),
55            Self::Io {
56                directory: Some(index),
57                ..
58            } => write!(
59                f,
60                "executable filesystem resolution failed in search directory {index}"
61            ),
62            Self::Io {
63                directory: None, ..
64            } => f.write_str("requested executable filesystem resolution failed"),
65        }
66    }
67}
68
69impl std::error::Error for ResolutionError {
70    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
71        match self {
72            Self::Io { source, .. } => Some(source),
73            _ => None,
74        }
75    }
76}
77
78/// Resolve one executable candidate without reading ambient PATH or running it.
79///
80/// Requests containing `/` are literal paths, including `./tool`; absolute paths
81/// are used directly and relative paths are rooted at `current_dir`. Other names
82/// search only `search_directories`, in order. Relative search directories and
83/// explicitly supplied empty entries are rooted at `current_dir`; an empty list
84/// searches nothing. The working directory must be absolute, even if unused.
85/// No HOME/default installation directory, shell expansion or fallback is added.
86///
87/// Selection requires a regular file with at least one Unix execute permission
88/// bit. Search skips missing candidates (including dangling symlinks), directories
89/// and nonexecutable files, and stops on other filesystem errors.
90/// Once an eligible candidate is observed, canonicalization errors stop selection,
91/// including if that candidate disappears. All search
92/// directories are checked for NUL before a name search; literal requests ignore
93/// the unused search list. Symlinks are followed and the result is canonical and
94/// absolute. Permission bits do not prove effective user access or interpreter
95/// validity. Caller-owned trusted path trees and exclusion of concurrent writers
96/// remain necessary; this path is not a frozen file capability.
97///
98/// A selected path must still undergo [`super::AdmittedTool::admit`] with the
99/// consumer's digest, byte bound and exact version authority before execution.
100/// No candidate is retried or replaced after that admission fails.
101///
102/// # Errors
103/// Returns typed invalid-input, missing/nonexecutable or filesystem failures.
104pub fn resolve_executable(
105    requested: &Path,
106    current_dir: &Path,
107    search_directories: &[PathBuf],
108) -> Result<PathBuf, ResolutionError> {
109    if !current_dir.is_absolute() || contains_nul(current_dir) {
110        return Err(ResolutionError::InvalidWorkingDirectory);
111    }
112    if requested.as_os_str().is_empty() || contains_nul(requested) {
113        return Err(ResolutionError::InvalidRequest);
114    }
115    // Inspect literal bytes: Path::components normalizes away `./`, which must
116    // not turn an explicit relative path into a search-directory request.
117    if requested.as_os_str().as_bytes().contains(&b'/') {
118        return candidate(&current_dir.join(requested), None);
119    }
120    if requested == Path::new(".") || requested == Path::new("..") {
121        return Err(ResolutionError::InvalidRequest);
122    }
123    for (index, directory) in search_directories.iter().enumerate() {
124        if contains_nul(directory) {
125            return Err(ResolutionError::InvalidSearchDirectory { index });
126        }
127    }
128    for (index, directory) in search_directories.iter().enumerate() {
129        let path = current_dir.join(directory).join(requested);
130        match candidate(&path, Some(index)) {
131            Ok(path) => return Ok(path),
132            Err(
133                ResolutionError::NotFound
134                | ResolutionError::NotRegularFile
135                | ResolutionError::NotExecutable,
136            ) => {}
137            Err(error) => return Err(error),
138        }
139    }
140    Err(ResolutionError::NotFound)
141}
142
143fn contains_nul(path: &Path) -> bool {
144    path.as_os_str().as_bytes().contains(&0)
145}
146
147fn candidate(path: &Path, directory: Option<usize>) -> Result<PathBuf, ResolutionError> {
148    let metadata = fs::metadata(path).map_err(|source| {
149        // Only an absent metadata candidate may advance a search. A later
150        // canonicalization failure must not silently select another executable.
151        if directory.is_some() && source.kind() == io::ErrorKind::NotFound {
152            ResolutionError::NotFound
153        } else {
154            ResolutionError::Io { directory, source }
155        }
156    })?;
157    if !metadata.is_file() {
158        return Err(ResolutionError::NotRegularFile);
159    }
160    if metadata.permissions().mode() & 0o111 == 0 {
161        return Err(ResolutionError::NotExecutable);
162    }
163    fs::canonicalize(path).map_err(|source| ResolutionError::Io { directory, source })
164}