runner_core/
path_safety.rs1use 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
11pub 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(¤t_dir)?;
70 let relative_root = temp.path().strip_prefix(¤t_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 root = temp.path().canonicalize()?;
103 let file_path = root.join("pack.gtpack");
104 fs::write(&file_path, b"pack")?;
105
106 CANONICAL_ROOT_CACHE.with(|cache| cache.borrow_mut().clear());
107 normalize_under_root(&root, std::path::Path::new("pack.gtpack"))?;
108
109 let cached = CANONICAL_ROOT_CACHE.with(|cache| cache.borrow().get(&root).cloned());
110 assert_eq!(cached, Some(root));
111 Ok(())
112 }
113
114 #[test]
115 fn does_not_cache_relative_roots() -> Result<()> {
116 let current_dir = std::env::current_dir()?;
117 let temp = tempfile::tempdir_in(¤t_dir)?;
118 let relative_root = temp.path().strip_prefix(¤t_dir)?;
119 let file_path = temp.path().join("pack.gtpack");
120 fs::write(&file_path, b"pack")?;
121
122 CANONICAL_ROOT_CACHE.with(|cache| cache.borrow_mut().clear());
123 normalize_under_root(relative_root, std::path::Path::new("pack.gtpack"))?;
124
125 let cached = CANONICAL_ROOT_CACHE.with(|cache| cache.borrow().get(relative_root).cloned());
126 assert!(cached.is_none());
127 Ok(())
128 }
129}