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(¤t_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}