Skip to main content

path_rs/
resolve.rs

1//! Path resolution and safe joining.
2//!
3//! These helpers operate on `Path` / `PathBuf` components. They do not expand
4//! environment variables or glob patterns.
5
6use crate::containment::is_lexically_inside;
7use crate::error::PathError;
8use crate::internal::components::is_drive_relative_path;
9use crate::internal::validation::reject_nul_path;
10use crate::normalize::normalize;
11use std::path::{Path, PathBuf};
12
13/// Make `path` absolute.
14///
15/// - Absolute inputs are lexically normalized.
16/// - Relative inputs are joined with the process current directory, then normalized.
17///
18/// # Filesystem access
19///
20/// Reads the current working directory for relative inputs. Does **not** require
21/// the path to exist and does **not** resolve symlinks.
22pub fn absolute(path: impl AsRef<Path>) -> Result<PathBuf, PathError> {
23    let path = path.as_ref();
24    reject_nul_path(path)?;
25
26    if is_drive_relative_path(path) {
27        return Err(PathError::drive_relative(path));
28    }
29
30    if path.is_absolute() {
31        return normalize(path);
32    }
33
34    let cwd = std::env::current_dir()
35        .map_err(|e| PathError::CurrentDirectoryUnavailable { source: e })?;
36    normalize(cwd.join(path))
37}
38
39/// Resolve `input` against `base`.
40///
41/// - If `input` is absolute, it is normalized and returned (base is ignored).
42/// - If `input` is relative, it is joined to `base` and normalized.
43///
44/// # Filesystem access
45///
46/// **No** (unless `base` itself requires later I/O by the caller).
47/// Does not follow symlinks. Does not require existence.
48pub fn resolve_against(
49    base: impl AsRef<Path>,
50    input: impl AsRef<Path>,
51) -> Result<PathBuf, PathError> {
52    let base = base.as_ref();
53    let input = input.as_ref();
54    reject_nul_path(base)?;
55    reject_nul_path(input)?;
56
57    if is_drive_relative_path(input) {
58        return Err(PathError::drive_relative(input));
59    }
60    if is_drive_relative_path(base) {
61        return Err(PathError::drive_relative(base));
62    }
63
64    if input.is_absolute() {
65        return normalize(input);
66    }
67
68    normalize(base.join(input))
69}
70
71/// Join a **relative** child under `base`, rejecting absolute children.
72///
73/// # Filesystem access
74///
75/// **No.** Does not follow symlinks.
76///
77/// # Errors
78///
79/// - Absolute `child` (including Windows drive-absolute / UNC)
80/// - Drive-relative Windows children
81pub fn join_relative(
82    base: impl AsRef<Path>,
83    child: impl AsRef<Path>,
84) -> Result<PathBuf, PathError> {
85    let base = base.as_ref();
86    let child = child.as_ref();
87    reject_nul_path(base)?;
88    reject_nul_path(child)?;
89
90    if is_drive_relative_path(child) {
91        return Err(PathError::drive_relative(child));
92    }
93    if child.is_absolute() {
94        return Err(PathError::absolute_child(child));
95    }
96
97    // Reject Windows prefixes that appear absolute-ish without is_absolute on some forms.
98    if child
99        .components()
100        .next()
101        .is_some_and(|c| matches!(c, std::path::Component::Prefix(_)))
102    {
103        return Err(PathError::absolute_child(child));
104    }
105
106    normalize(base.join(child))
107}
108
109/// Resolve `child` under `root`, ensuring the result stays inside `root` **lexically**.
110///
111/// # Security
112///
113/// Lexical containment is **not** symlink-safe. For security-sensitive uses,
114/// combine with filesystem canonicalization of both root and result and re-check
115/// containment. See `SECURITY.md`.
116///
117/// # Filesystem access
118///
119/// **No.**
120pub fn resolve_inside(
121    root: impl AsRef<Path>,
122    child: impl AsRef<Path>,
123) -> Result<PathBuf, PathError> {
124    let root = root.as_ref();
125    let child = child.as_ref();
126    reject_nul_path(root)?;
127    reject_nul_path(child)?;
128
129    if is_drive_relative_path(root) {
130        return Err(PathError::drive_relative(root));
131    }
132    if is_drive_relative_path(child) {
133        return Err(PathError::drive_relative(child));
134    }
135
136    let joined = join_relative(root, child)?;
137    let root_norm = normalize(root)?;
138
139    if !is_lexically_inside(&joined, &root_norm) {
140        return Err(PathError::root_escape(&joined));
141    }
142
143    Ok(joined)
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn join_rejects_absolute() {
152        #[cfg(unix)]
153        {
154            assert!(matches!(
155                join_relative("/repo", "/etc/passwd"),
156                Err(PathError::AbsoluteChildPath { .. })
157            ));
158        }
159    }
160
161    #[test]
162    fn resolve_inside_blocks_escape() {
163        let err = resolve_inside("/repo", "../../etc/passwd").unwrap_err();
164        assert!(matches!(err, PathError::RootEscape { .. }));
165    }
166
167    #[test]
168    fn resolve_inside_allows_nested() {
169        let p = resolve_inside("/repo", "src/main.rs").unwrap();
170        assert_eq!(p, PathBuf::from("/repo/src/main.rs"));
171    }
172}