Skip to main content

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(&current) {
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}