Skip to main content

runner_core/
path_safety.rs

1use std::cell::RefCell;
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result};
6
7thread_local! {
8    static CANONICAL_ROOT_CACHE: RefCell<HashMap<PathBuf, PathBuf>> = RefCell::new(HashMap::new());
9}
10
11/// Normalize a user-supplied path and ensure it stays within an allowed root.
12/// Rejects absolute candidates and any that escape the root via `..`.
13pub fn normalize_under_root(root: &Path, candidate: &Path) -> Result<PathBuf> {
14    if candidate.is_absolute() {
15        anyhow::bail!("absolute paths are not allowed: {}", candidate.display());
16    }
17
18    let root = canonicalize_cached(root)?;
19    let joined = root.join(candidate);
20    let canon = joined
21        .canonicalize()
22        .with_context(|| format!("failed to canonicalize {}", joined.display()))?;
23
24    if !canon.starts_with(&root) {
25        anyhow::bail!(
26            "path escapes root ({}): {}",
27            root.display(),
28            canon.display()
29        );
30    }
31
32    Ok(canon)
33}
34
35fn canonicalize_cached(root: &Path) -> Result<PathBuf> {
36    if !root.is_absolute() {
37        return root
38            .canonicalize()
39            .with_context(|| format!("failed to canonicalize {}", root.display()));
40    }
41
42    if let Some(cached) = CANONICAL_ROOT_CACHE.with(|cache| cache.borrow().get(root).cloned()) {
43        return Ok(cached);
44    }
45
46    let canonical = root
47        .canonicalize()
48        .with_context(|| format!("failed to canonicalize {}", root.display()))?;
49    if canonical == root {
50        CANONICAL_ROOT_CACHE.with(|cache| {
51            cache
52                .borrow_mut()
53                .insert(root.to_path_buf(), canonical.clone());
54        });
55    }
56    Ok(canonical)
57}
58
59#[cfg(test)]
60mod tests {
61    use super::{CANONICAL_ROOT_CACHE, normalize_under_root};
62    use anyhow::Result;
63    use std::fs;
64    use tempfile::TempDir;
65
66    #[test]
67    fn normalizes_relative_roots_before_comparing() -> Result<()> {
68        let current_dir = std::env::current_dir()?;
69        let temp = tempfile::tempdir_in(&current_dir)?;
70        let relative_root = temp.path().strip_prefix(&current_dir)?;
71        let file_path = temp.path().join("pack.gtpack");
72        fs::write(&file_path, b"pack")?;
73
74        let normalized = normalize_under_root(relative_root, std::path::Path::new("pack.gtpack"))?;
75
76        assert_eq!(normalized, file_path.canonicalize()?);
77        Ok(())
78    }
79
80    #[test]
81    fn rejects_parent_escape() -> Result<()> {
82        let temp = TempDir::new()?;
83        let sibling = temp
84            .path()
85            .parent()
86            .expect("tempdir parent")
87            .join("escape.gtpack");
88        fs::write(&sibling, b"escape")?;
89
90        let err = normalize_under_root(temp.path(), std::path::Path::new("../escape.gtpack"))
91            .expect_err("parent traversal should be rejected");
92
93        assert!(err.to_string().contains("path escapes root"));
94        Ok(())
95    }
96
97    #[test]
98    fn caches_root_canonicalization_per_thread() -> Result<()> {
99        let temp = TempDir::new()?;
100        let file_path = temp.path().join("pack.gtpack");
101        fs::write(&file_path, b"pack")?;
102
103        CANONICAL_ROOT_CACHE.with(|cache| cache.borrow_mut().clear());
104        normalize_under_root(temp.path(), std::path::Path::new("pack.gtpack"))?;
105
106        let cached = CANONICAL_ROOT_CACHE.with(|cache| cache.borrow().get(temp.path()).cloned());
107        assert_eq!(cached, Some(temp.path().canonicalize()?));
108        Ok(())
109    }
110
111    #[test]
112    fn does_not_cache_relative_roots() -> Result<()> {
113        let current_dir = std::env::current_dir()?;
114        let temp = tempfile::tempdir_in(&current_dir)?;
115        let relative_root = temp.path().strip_prefix(&current_dir)?;
116        let file_path = temp.path().join("pack.gtpack");
117        fs::write(&file_path, b"pack")?;
118
119        CANONICAL_ROOT_CACHE.with(|cache| cache.borrow_mut().clear());
120        normalize_under_root(relative_root, std::path::Path::new("pack.gtpack"))?;
121
122        let cached = CANONICAL_ROOT_CACHE.with(|cache| cache.borrow().get(relative_root).cloned());
123        assert!(cached.is_none());
124        Ok(())
125    }
126}