Skip to main content

ragfs_core/
path_jail.rs

1//! Path jail: keep agent-facing paths inside a source root.
2//!
3//! Absolute paths, `..` traversal, and symlink hops that leave the root are rejected.
4
5use std::path::{Component, Path, PathBuf};
6
7/// Resolve `path` so the result is inside `root`.
8///
9/// Relative paths are joined to `root`. Absolute paths are accepted only when
10/// they (after normalization and symlink resolution) stay under `root`.
11pub fn resolve_under_root(root: &Path, path: &Path) -> Result<PathBuf, String> {
12    let root = absolute_root(root)?;
13    let joined = if path.is_absolute() {
14        path.to_path_buf()
15    } else {
16        root.join(path)
17    };
18
19    let normalized = normalize_lexically(&joined);
20    let resolved = resolve_existing_prefix(&normalized)?;
21
22    if !resolved.starts_with(&root) {
23        return Err(format!("Path escapes source root: {}", path.display()));
24    }
25
26    Ok(resolved)
27}
28
29fn absolute_root(root: &Path) -> Result<PathBuf, String> {
30    if let Ok(canon) = root.canonicalize() {
31        return Ok(canon);
32    }
33
34    let abs = if root.is_absolute() {
35        normalize_lexically(root)
36    } else {
37        let cwd = std::env::current_dir().map_err(|e| format!("Invalid source root: {e}"))?;
38        normalize_lexically(&cwd.join(root))
39    };
40    Ok(abs)
41}
42
43fn normalize_lexically(path: &Path) -> PathBuf {
44    let mut out = PathBuf::new();
45    for component in path.components() {
46        match component {
47            Component::Prefix(prefix) => out.push(prefix.as_os_str()),
48            Component::RootDir => out.push(component),
49            Component::CurDir => {}
50            Component::ParentDir => {
51                out.pop();
52            }
53            Component::Normal(part) => out.push(part),
54        }
55    }
56    out
57}
58
59/// Canonicalize the longest existing prefix so symlink hops are visible.
60fn resolve_existing_prefix(path: &Path) -> Result<PathBuf, String> {
61    let mut current = path.to_path_buf();
62    let mut missing = Vec::new();
63
64    while !current.as_os_str().is_empty() && !current.exists() {
65        match current.file_name() {
66            Some(name) => {
67                missing.push(name.to_os_string());
68                match current.parent() {
69                    Some(parent) if parent != current => current = parent.to_path_buf(),
70                    _ => break,
71                }
72            }
73            None => break,
74        }
75    }
76
77    let mut resolved = if current.exists() {
78        current
79            .canonicalize()
80            .map_err(|e| format!("Failed to resolve path: {e}"))?
81    } else {
82        current
83    };
84
85    for part in missing.into_iter().rev() {
86        resolved.push(part);
87    }
88    Ok(resolved)
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use std::fs;
95    use tempfile::TempDir;
96
97    #[test]
98    fn relative_path_stays_inside_root() {
99        let root = TempDir::new().unwrap();
100        let resolved = resolve_under_root(root.path(), Path::new("a/b.txt")).unwrap();
101        assert_eq!(
102            resolved,
103            root.path().canonicalize().unwrap().join("a/b.txt")
104        );
105    }
106
107    #[test]
108    fn parent_dir_inside_root_is_allowed() {
109        let root = TempDir::new().unwrap();
110        fs::create_dir(root.path().join("sub")).unwrap();
111        let resolved = resolve_under_root(root.path(), Path::new("sub/../kept.txt")).unwrap();
112        assert_eq!(
113            resolved,
114            root.path().canonicalize().unwrap().join("kept.txt")
115        );
116    }
117
118    #[test]
119    fn parent_dir_escape_is_rejected() {
120        let root = TempDir::new().unwrap();
121        let err = resolve_under_root(root.path(), Path::new("../escape.txt")).unwrap_err();
122        assert!(err.contains("escapes"), "{err}");
123    }
124
125    #[test]
126    fn nested_parent_escape_is_rejected() {
127        let root = TempDir::new().unwrap();
128        let err = resolve_under_root(root.path(), Path::new("sub/../../escape.txt")).unwrap_err();
129        assert!(err.contains("escapes"), "{err}");
130    }
131
132    #[test]
133    fn absolute_path_inside_root_is_allowed() {
134        let root = TempDir::new().unwrap();
135        let inside = root.path().join("inside.txt");
136        let resolved = resolve_under_root(root.path(), &inside).unwrap();
137        assert_eq!(
138            resolved,
139            root.path().canonicalize().unwrap().join("inside.txt")
140        );
141    }
142
143    #[test]
144    fn absolute_path_outside_root_is_rejected() {
145        let root = TempDir::new().unwrap();
146        let outside = TempDir::new().unwrap();
147        let err = resolve_under_root(root.path(), &outside.path().join("x.txt")).unwrap_err();
148        assert!(err.contains("escapes"), "{err}");
149    }
150
151    #[test]
152    #[cfg(unix)]
153    fn symlink_escape_is_rejected() {
154        let root = TempDir::new().unwrap();
155        let outside = TempDir::new().unwrap();
156        std::os::unix::fs::symlink(outside.path(), root.path().join("out")).unwrap();
157        let err = resolve_under_root(root.path(), Path::new("out/secret.txt")).unwrap_err();
158        assert!(err.contains("escapes"), "{err}");
159    }
160}