ic_host_fs/path/mod.rs
1//! Resolution of caller-selected paths whose final components need not exist.
2
3use std::{
4 collections::HashSet,
5 fs, io,
6 path::{Component, Path, PathBuf},
7};
8
9#[cfg(all(test, unix))]
10mod tests;
11
12enum ResolutionStep {
13 Component(PathBuf),
14 EndSymlink(PathBuf),
15 RequireDirectory,
16}
17
18/// Check that every existing path component is a physical directory.
19///
20/// Missing components are permitted, but no directories are created. Symlinks
21/// (including dangling links), non-directories and parent traversal (`..`) reject.
22/// Relative paths use the current working directory; callers own empty-path,
23/// pathname-content and absolute-path policy. This is a point-in-time check,
24/// not root confinement or a held directory capability. Callers retain exclusion
25/// of concurrent namespace mutation through subsequent filesystem operations.
26///
27/// # Errors
28/// Returns [`io::ErrorKind::InvalidInput`] for parent traversal,
29/// [`io::ErrorKind::NotADirectory`] for symlinks/non-directories, or the original
30/// filesystem error other than a permitted not-found result.
31pub fn validate_physical_directory_allow_missing(path: &Path) -> io::Result<()> {
32 let mut current = PathBuf::new();
33 for component in path.components() {
34 if component == Component::ParentDir {
35 return Err(io::Error::new(
36 io::ErrorKind::InvalidInput,
37 "physical directory path may not contain '..'",
38 ));
39 }
40 current.push(component.as_os_str());
41 match fs::symlink_metadata(¤t) {
42 Ok(metadata) if metadata.is_dir() => {}
43 Ok(_) => {
44 return Err(io::Error::new(
45 io::ErrorKind::NotADirectory,
46 format!(
47 "path component is not a physical directory: {}",
48 current.display()
49 ),
50 ));
51 }
52 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
53 Err(error) => return Err(error),
54 }
55 }
56 Ok(())
57}
58
59/// Resolve existing symlinks and normalize a missing suffix against an explicit base.
60///
61/// Relative paths require an absolute `base`; absolute paths ignore it. Dangling
62/// symlinks resolve to their missing targets, including relative targets and
63/// chains. Parent traversal out of a missing suffix resumes existing-component
64/// resolution. Native traversal errors are retained; cycles encountered while
65/// normalizing missing targets return [`io::ErrorKind::InvalidInput`].
66/// Non-NotFound filesystem errors are preserved. This is trusted-path resolution,
67/// not root confinement or immutable selection; ancestors and concurrent writers
68/// remain caller-owned. No files are created or removed.
69///
70/// `max_symlink_depth` bounds simultaneously active dangling-target expansions,
71/// including targets reached after missing-suffix normalization. Completed
72/// expansions do not charge later independent links. Zero rejects any such
73/// expansion but still permits ordinary missing paths. Existing-target resolution
74/// retains the native filesystem's symlink limits and errors. This does not
75/// bound path length, filesystem latency or all filesystem operations.
76///
77/// Select `usize::MAX` explicitly when no additional missing-target depth bound
78/// is needed. Opening, confinement and the chosen depth allowance remain caller-owned.
79///
80/// # Errors
81/// Returns path/base errors or [`io::ErrorKind::InvalidInput`] on a missing-target
82/// cycle or exhausted depth allowance. No path is returned on failure.
83pub fn canonicalize_allow_missing(
84 path: &Path,
85 base: &Path,
86 max_symlink_depth: usize,
87) -> io::Result<PathBuf> {
88 let absolute = if path.is_absolute() {
89 path.to_owned()
90 } else if base.is_absolute() {
91 base.join(path)
92 } else {
93 return Err(io::Error::new(
94 io::ErrorKind::InvalidInput,
95 "relative path requires an absolute base",
96 ));
97 };
98 // Preserve native traversal checks (including file/., file/ and file/..)
99 // before Path::components removes separators and current-directory parts.
100 match absolute.canonicalize() {
101 Ok(canonical) => return Ok(canonical),
102 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
103 Err(error) => return Err(error),
104 }
105 let mut resolved = PathBuf::new();
106 let mut missing_depth = 0_usize;
107 let mut pending = Vec::new();
108 let mut active_symlinks = HashSet::new();
109 push_steps(&absolute, &mut pending);
110 while let Some(step) = pending.pop() {
111 let path = match step {
112 ResolutionStep::Component(path) => path,
113 ResolutionStep::EndSymlink(path) => {
114 active_symlinks.remove(&path);
115 continue;
116 }
117 ResolutionStep::RequireDirectory => {
118 if missing_depth == 0 {
119 resolved = resolved.join(".").canonicalize()?;
120 }
121 continue;
122 }
123 };
124 for component in path.components() {
125 match component {
126 Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
127 let candidate = resolved.join(component.as_os_str());
128 if missing_depth == 0 && matches!(component, Component::Normal(_)) {
129 match candidate.canonicalize() {
130 Ok(canonical) => resolved = canonical,
131 Err(error) if error.kind() == io::ErrorKind::NotFound => {
132 match fs::symlink_metadata(&candidate) {
133 Ok(metadata) if metadata.is_symlink() => {
134 if active_symlinks.len() >= max_symlink_depth
135 || !active_symlinks.insert(candidate.clone())
136 {
137 return Err(io::Error::new(
138 io::ErrorKind::InvalidInput,
139 "missing-target symlink cycle or depth limit",
140 ));
141 }
142 let target = fs::read_link(&candidate)?;
143 pending.push(ResolutionStep::EndSymlink(candidate));
144 // Relative targets start at the current resolved
145 // parent. Absolute targets carry a root component.
146 push_steps(&target, &mut pending);
147 }
148 Err(error) if error.kind() != io::ErrorKind::NotFound => {
149 return Err(error);
150 }
151 _ => {
152 resolved = candidate;
153 missing_depth = 1;
154 }
155 }
156 }
157 Err(error) => return Err(error),
158 }
159 } else {
160 resolved = candidate;
161 if matches!(component, Component::Normal(_)) && missing_depth > 0 {
162 missing_depth += 1;
163 }
164 }
165 }
166 Component::CurDir => {}
167 Component::ParentDir => {
168 if missing_depth == 0 {
169 // After leaving a missing suffix, parent traversal must
170 // still reject an existing regular file used as a directory.
171 resolved = resolved.join(component.as_os_str()).canonicalize()?;
172 } else {
173 resolved.pop();
174 missing_depth -= 1;
175 }
176 }
177 }
178 }
179 }
180 Ok(resolved)
181}
182
183fn push_steps(path: &Path, pending: &mut Vec<ResolutionStep>) {
184 // components() drops trailing slashes and '/.'. Preserve their requirement
185 // when missing-prefix normalization later reaches an existing entry.
186 let bytes = path.as_os_str().as_encoded_bytes();
187 let separator = std::path::MAIN_SEPARATOR as u8;
188 if bytes.ends_with(b"/")
189 || bytes.ends_with(b"/.")
190 || bytes.ends_with(&[separator])
191 || bytes.ends_with(&[separator, b'.'])
192 {
193 pending.push(ResolutionStep::RequireDirectory);
194 }
195 pending.extend(
196 path.components()
197 .rev()
198 .map(|component| ResolutionStep::Component(PathBuf::from(component.as_os_str()))),
199 );
200}