path_rs/containment.rs
1//! Lexical root containment checks.
2//!
3//! # Security warning
4//!
5//! Lexical containment does **not** account for symlinks. A path that is
6//! lexically inside a root may still resolve outside it via symlink traversal.
7//! Do not use these helpers as a sole security boundary for untrusted paths.
8
9use crate::error::PathError;
10use crate::internal::components::starts_with_components;
11use crate::internal::validation::reject_nul_path;
12use crate::normalize::normalize;
13use std::path::{Path, PathBuf};
14
15/// Returns `true` if `path` is lexically equal to or beneath `root`.
16///
17/// Both paths should already be normalized for reliable results. Prefer
18/// [`ensure_inside`] which normalizes first.
19///
20/// # Filesystem access
21///
22/// **No.**
23pub fn is_lexically_inside(path: &Path, root: &Path) -> bool {
24 if path == root {
25 return true;
26 }
27 starts_with_components(path, root)
28 // Ensure we match on component boundaries, not as a string prefix of a longer name.
29 // `starts_with_components` already compares whole components.
30}
31
32/// Normalize both paths and ensure `path` stays inside `root`.
33///
34/// Returns the normalized path on success.
35///
36/// # Filesystem access
37///
38/// **No.**
39///
40/// # Security
41///
42/// Not symlink-safe. See module documentation.
43pub fn ensure_inside(root: impl AsRef<Path>, path: impl AsRef<Path>) -> Result<PathBuf, PathError> {
44 let root = normalize(root.as_ref())?;
45 let path = normalize(path.as_ref())?;
46 reject_nul_path(&root)?;
47 reject_nul_path(&path)?;
48
49 if is_lexically_inside(&path, &root) {
50 Ok(path)
51 } else {
52 Err(PathError::root_escape(path))
53 }
54}
55
56#[cfg(test)]
57mod tests {
58 use super::*;
59
60 #[test]
61 fn inside_and_escape() {
62 let root = Path::new("/repo");
63 assert!(is_lexically_inside(Path::new("/repo/src"), root));
64 assert!(is_lexically_inside(Path::new("/repo"), root));
65 assert!(!is_lexically_inside(Path::new("/etc"), root));
66 assert!(!is_lexically_inside(Path::new("/repo2"), root));
67 }
68}