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