Skip to main content

roma_core/
path.rs

1//! Safe path resolution helpers.
2//!
3//! [`safe_resolve`] takes a user-supplied path and a trusted base directory,
4//! and returns an absolute path that is guaranteed to live under `base`.
5//! It refuses:
6//!
7//! 1. Any lexical `..` component (`Component::ParentDir`).
8//! 2. Absolute paths (`Component::RootDir` / `Component::Prefix`).
9//! 3. Paths that, after symlink resolution of the existing prefix,
10//!    escape the canonicalized `base`.
11//!
12//! The function is designed to be safe for callers that want to create a
13//! file that does not yet exist: only the longest existing ancestor is
14//! canonicalized (which resolves symlinks), while the non-existent tail
15//! is appended lexically.
16
17use std::path::{Component, Path, PathBuf};
18
19/// Errors returned by [`safe_resolve`].
20#[derive(Debug, thiserror::Error)]
21pub enum PathError {
22    /// The user path contains a `..` component.
23    #[error("path traversal denied: {0}")]
24    Traversal(String),
25    /// The user path is absolute (starts with `/` or a drive prefix).
26    #[error("absolute path not allowed: {0}")]
27    AbsolutePath(String),
28    /// The resolved path lies outside the canonicalized base.
29    #[error("path escapes base directory: {0}")]
30    EscapesBase(String),
31    /// I/O error while canonicalizing the base or an existing ancestor.
32    #[error("io error: {0}")]
33    Io(#[from] std::io::Error),
34}
35
36/// Resolve `user_path` relative to `base`, refusing absolute paths,
37/// `..` traversal, and (after canonicalization) any path that escapes
38/// `base`.
39///
40/// `base` must already exist on disk; it is canonicalized once per call.
41/// `user_path` may reference a not-yet-existing file: the longest
42/// existing ancestor is canonicalized and the remaining components are
43/// appended lexically, so a later `create_dir_all` / `write` will create
44/// the new entries under the canonicalized base without a TOCTOU on the
45/// non-existent tail.
46///
47/// Empty `user_path` resolves to the canonicalized `base`.
48pub fn safe_resolve(base: &Path, user_path: &Path) -> Result<PathBuf, PathError> {
49    // 1. Lexical checks: no absolute root, no parent-dir components.
50    for comp in user_path.components() {
51        match comp {
52            Component::ParentDir => {
53                return Err(PathError::Traversal(user_path.display().to_string()));
54            }
55            Component::RootDir | Component::Prefix(_) => {
56                return Err(PathError::AbsolutePath(user_path.display().to_string()));
57            }
58            _ => {}
59        }
60    }
61
62    let base_canon = base.canonicalize()?;
63
64    // 2. Accumulate components onto the canonicalized base. Walk the
65    //    existing prefix greedily — canonicalizing after each push so
66    //    symlinks are resolved — and append the remaining (non-existent)
67    //    tail lexically.
68    let mut resolved = base_canon.clone();
69    let mut tail_started = false;
70    for comp in user_path.components() {
71        match comp {
72            Component::Normal(seg) => {
73                if tail_started {
74                    resolved.push(seg);
75                    continue;
76                }
77                let candidate = resolved.join(seg);
78                match candidate.canonicalize() {
79                    Ok(c) => {
80                        resolved = c;
81                    }
82                    Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
83                        // Entry does not exist yet: append lexically from
84                        // here on and stop canonicalizing.
85                        resolved.push(seg);
86                        tail_started = true;
87                    }
88                    Err(e) => return Err(PathError::Io(e)),
89                }
90            }
91            // CurDir components are harmless (`.`); skip.
92            Component::CurDir => {}
93            // Filtered above.
94            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
95                return Err(PathError::Traversal(user_path.display().to_string()));
96            }
97        }
98    }
99
100    // 3. Boundary check: every step above either kept us inside
101    //    `base_canon` (canonicalize of existing ancestor) or appended a
102    //    lexical component after we were already inside. Still verify
103    //    explicitly — cheap and defensive.
104    if !resolved.starts_with(&base_canon) {
105        return Err(PathError::EscapesBase(resolved.display().to_string()));
106    }
107
108    Ok(resolved)
109}
110
111#[cfg(test)]
112#[allow(clippy::expect_used, clippy::unwrap_used)]
113mod tests {
114    use super::*;
115    use tempfile::TempDir;
116
117    #[test]
118    fn accepts_simple_relative_path() {
119        let base = TempDir::new().unwrap();
120        std::fs::write(base.path().join("a.txt"), "x").unwrap();
121        let resolved = safe_resolve(base.path(), Path::new("a.txt")).unwrap();
122        assert_eq!(resolved, base.path().canonicalize().unwrap().join("a.txt"));
123    }
124
125    #[test]
126    fn accepts_nested_relative_path() {
127        let base = TempDir::new().unwrap();
128        std::fs::create_dir_all(base.path().join("sub")).unwrap();
129        std::fs::write(base.path().join("sub/a.txt"), "x").unwrap();
130        let resolved = safe_resolve(base.path(), Path::new("sub/a.txt")).unwrap();
131        let expected = base
132            .path()
133            .canonicalize()
134            .unwrap()
135            .join("sub")
136            .join("a.txt");
137        assert_eq!(resolved, expected);
138    }
139
140    #[test]
141    fn accepts_nonexistent_leaf_under_base() {
142        let base = TempDir::new().unwrap();
143        let resolved = safe_resolve(base.path(), Path::new("new.txt")).unwrap();
144        assert_eq!(
145            resolved,
146            base.path().canonicalize().unwrap().join("new.txt")
147        );
148    }
149
150    #[test]
151    fn accepts_nonexistent_nested_leaf() {
152        let base = TempDir::new().unwrap();
153        let resolved = safe_resolve(base.path(), Path::new("a/b/c.txt")).unwrap();
154        let expected = base
155            .path()
156            .canonicalize()
157            .unwrap()
158            .join("a")
159            .join("b")
160            .join("c.txt");
161        assert_eq!(resolved, expected);
162    }
163
164    #[test]
165    fn accepts_curdir_components() {
166        let base = TempDir::new().unwrap();
167        let resolved = safe_resolve(base.path(), Path::new("./x.txt")).unwrap();
168        assert_eq!(resolved, base.path().canonicalize().unwrap().join("x.txt"));
169    }
170
171    #[test]
172    fn rejects_parent_dir_component() {
173        let base = TempDir::new().unwrap();
174        let err = safe_resolve(base.path(), Path::new("../escape.txt")).unwrap_err();
175        assert!(matches!(err, PathError::Traversal(_)));
176    }
177
178    #[test]
179    fn rejects_embedded_parent_dir() {
180        let base = TempDir::new().unwrap();
181        let err = safe_resolve(base.path(), Path::new("a/../../etc/passwd")).unwrap_err();
182        assert!(matches!(err, PathError::Traversal(_)));
183    }
184
185    #[test]
186    fn rejects_absolute_path() {
187        let base = TempDir::new().unwrap();
188        let err = safe_resolve(base.path(), Path::new("/etc/passwd")).unwrap_err();
189        assert!(matches!(err, PathError::AbsolutePath(_)));
190    }
191
192    #[test]
193    fn rejects_missing_base() {
194        let base = TempDir::new().unwrap();
195        let missing = base.path().join("does_not_exist");
196        let err = safe_resolve(&missing, Path::new("x")).unwrap_err();
197        assert!(matches!(err, PathError::Io(_)));
198    }
199
200    #[cfg(unix)]
201    #[test]
202    fn rejects_symlink_escaping_base() {
203        let outside = TempDir::new().unwrap();
204        std::fs::write(outside.path().join("secret"), "top-secret").unwrap();
205
206        let base = TempDir::new().unwrap();
207        // base/link -> outside
208        std::os::unix::fs::symlink(outside.path(), base.path().join("link")).unwrap();
209
210        let err = safe_resolve(base.path(), Path::new("link/secret")).unwrap_err();
211        assert!(
212            matches!(err, PathError::EscapesBase(_)),
213            "expected EscapesBase, got {err:?}"
214        );
215    }
216
217    #[cfg(unix)]
218    #[test]
219    fn accepts_symlink_pointing_inside_base() {
220        let base = TempDir::new().unwrap();
221        std::fs::create_dir_all(base.path().join("real")).unwrap();
222        std::fs::write(base.path().join("real/data"), "ok").unwrap();
223        std::os::unix::fs::symlink(base.path().join("real"), base.path().join("alias")).unwrap();
224
225        let resolved = safe_resolve(base.path(), Path::new("alias/data")).unwrap();
226        // Must be inside the canonical base.
227        assert!(resolved.starts_with(base.path().canonicalize().unwrap()));
228    }
229
230    #[cfg(unix)]
231    #[test]
232    fn rejects_symlink_parent_with_nonexistent_leaf() {
233        // base/link -> /tmp/elsewhere; then user asks for `link/new.txt`.
234        // The leaf doesn't exist, but the parent resolves outside — must reject.
235        let outside = TempDir::new().unwrap();
236        let base = TempDir::new().unwrap();
237        std::os::unix::fs::symlink(outside.path(), base.path().join("link")).unwrap();
238
239        let err = safe_resolve(base.path(), Path::new("link/new.txt")).unwrap_err();
240        assert!(
241            matches!(err, PathError::EscapesBase(_)),
242            "expected EscapesBase, got {err:?}"
243        );
244    }
245
246    #[test]
247    fn empty_path_resolves_to_base() {
248        let base = TempDir::new().unwrap();
249        let resolved = safe_resolve(base.path(), Path::new("")).unwrap();
250        assert_eq!(resolved, base.path().canonicalize().unwrap());
251    }
252}