Skip to main content

packc/
path_safety.rs

1use std::path::{Component, Path, PathBuf};
2
3use anyhow::{Context, Result};
4
5/// Normalise a user-supplied path so it stays under `root`.
6/// Rejects absolute inputs and any traversal that would escape `root`.
7pub fn normalize_under_root(root: &Path, candidate: &Path) -> Result<PathBuf> {
8    if candidate.is_absolute() {
9        anyhow::bail!("absolute paths are not allowed: {}", candidate.display());
10    }
11
12    let canon_root = root
13        .canonicalize()
14        .with_context(|| format!("failed to canonicalize root {}", root.display()))?;
15    let mut normalized = canon_root.clone();
16    let root_depth = canon_root.components().count();
17
18    for comp in candidate.components() {
19        match comp {
20            Component::CurDir => {}
21            Component::Normal(part) => normalized.push(part),
22            Component::ParentDir => {
23                if root_depth == 0 || !normalized.pop() {
24                    anyhow::bail!(
25                        "path escapes root ({}): {}",
26                        canon_root.display(),
27                        candidate.display()
28                    );
29                }
30                // Prevent walking above the canonical root depth.
31                if normalized.components().count() < root_depth {
32                    anyhow::bail!(
33                        "path escapes root ({}): {}",
34                        canon_root.display(),
35                        candidate.display()
36                    );
37                }
38            }
39            Component::Prefix(_) | Component::RootDir => {
40                anyhow::bail!("invalid path component in {}", candidate.display());
41            }
42        }
43    }
44
45    if !normalized.starts_with(&canon_root) {
46        anyhow::bail!(
47            "path escapes root ({}): {}",
48            canon_root.display(),
49            candidate.display()
50        );
51    }
52
53    Ok(normalized)
54}
55
56#[cfg(test)]
57mod tests {
58    use super::normalize_under_root;
59    use std::path::Path;
60    use tempfile::tempdir;
61
62    #[test]
63    fn normalizes_relative_paths_with_parent_segments() {
64        let root = tempdir().expect("tempdir");
65        let canon_root = root.path().canonicalize().expect("canonical root");
66        let path = normalize_under_root(root.path(), Path::new("a/b/../c.txt"))
67            .expect("path within root should work");
68
69        assert_eq!(path, canon_root.join("a/c.txt"));
70    }
71
72    #[test]
73    fn rejects_absolute_paths() {
74        let root = tempdir().expect("tempdir");
75        let err = normalize_under_root(root.path(), Path::new("/tmp/file"))
76            .expect_err("absolute path should fail");
77
78        assert!(err.to_string().contains("absolute paths are not allowed"));
79    }
80
81    #[test]
82    fn rejects_paths_that_escape_root() {
83        let root = tempdir().expect("tempdir");
84        let err = normalize_under_root(root.path(), Path::new("../../file"))
85            .expect_err("escaping path should fail");
86
87        assert!(err.to_string().contains("path escapes root"));
88    }
89}