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