Skip to main content

evalbox_sandbox/notify/
virtual_fs.rs

1//! Virtual filesystem path translation.
2//!
3//! Maps paths from the child's perspective to real paths on the host.
4//! Used by the supervisor in `Virtualize` mode to translate filesystem
5//! syscalls to the correct workspace paths.
6//!
7//! ## Default Mappings
8//!
9//! | Child sees | Host path |
10//! |-----------|-----------|
11//! | `/work` | `{workspace}/work` |
12//! | `/tmp` | `{workspace}/tmp` |
13//! | `/home` | `{workspace}/home` |
14
15use std::collections::HashMap;
16use std::path::{Path, PathBuf};
17
18/// Virtual filesystem with path translation.
19#[derive(Debug, Clone)]
20pub struct VirtualFs {
21    /// Maps virtual prefix → real prefix.
22    mappings: HashMap<PathBuf, PathBuf>,
23}
24
25impl VirtualFs {
26    /// Create a new `VirtualFs` with default mappings for the given workspace root.
27    pub fn new(workspace_root: &Path) -> Self {
28        let mut mappings = HashMap::new();
29        mappings.insert(PathBuf::from("/work"), workspace_root.join("work"));
30        mappings.insert(PathBuf::from("/tmp"), workspace_root.join("tmp"));
31        mappings.insert(PathBuf::from("/home"), workspace_root.join("home"));
32        Self { mappings }
33    }
34
35    /// Create an empty `VirtualFs` with no mappings.
36    pub fn empty() -> Self {
37        Self {
38            mappings: HashMap::new(),
39        }
40    }
41
42    /// Add a path mapping.
43    pub fn add_mapping(&mut self, virtual_path: impl Into<PathBuf>, real_path: impl Into<PathBuf>) {
44        self.mappings.insert(virtual_path.into(), real_path.into());
45    }
46
47    /// Translate a path from child's view to host's view.
48    ///
49    /// Returns `Some(real_path)` if the path matches a mapping,
50    /// `None` if the path should be accessed as-is (passthrough).
51    pub fn translate(&self, path: &str) -> Option<PathBuf> {
52        let path = Path::new(path);
53        for (virtual_prefix, real_prefix) in &self.mappings {
54            if let Ok(suffix) = path.strip_prefix(virtual_prefix) {
55                return Some(real_prefix.join(suffix));
56            }
57        }
58        None
59    }
60
61    /// Check if a path is within any allowed scope.
62    ///
63    /// In `Virtualize` mode, only paths within mappings or system paths are allowed.
64    pub fn is_allowed(&self, path: &str) -> bool {
65        let path = Path::new(path);
66
67        // Check virtual mappings
68        for virtual_prefix in self.mappings.keys() {
69            if path.starts_with(virtual_prefix) {
70                return true;
71            }
72        }
73
74        // Allow common system paths (read-only, handled by Landlock)
75        let system_prefixes = ["/usr", "/bin", "/lib", "/lib64", "/etc", "/proc", "/dev"];
76        for prefix in &system_prefixes {
77            if path.starts_with(prefix) {
78                return true;
79            }
80        }
81
82        false
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn default_mappings() {
92        let vfs = VirtualFs::new(Path::new("/tmp/evalbox-abc123"));
93
94        assert_eq!(
95            vfs.translate("/work/main.py"),
96            Some(PathBuf::from("/tmp/evalbox-abc123/work/main.py"))
97        );
98        assert_eq!(
99            vfs.translate("/tmp/output.txt"),
100            Some(PathBuf::from("/tmp/evalbox-abc123/tmp/output.txt"))
101        );
102        assert_eq!(
103            vfs.translate("/home/.bashrc"),
104            Some(PathBuf::from("/tmp/evalbox-abc123/home/.bashrc"))
105        );
106    }
107
108    #[test]
109    fn no_translation_for_system_paths() {
110        let vfs = VirtualFs::new(Path::new("/tmp/evalbox-abc123"));
111        assert_eq!(vfs.translate("/usr/bin/python3"), None);
112        assert_eq!(vfs.translate("/etc/passwd"), None);
113    }
114
115    #[test]
116    fn is_allowed_checks() {
117        let vfs = VirtualFs::new(Path::new("/tmp/evalbox-abc123"));
118
119        assert!(vfs.is_allowed("/work/test.py"));
120        assert!(vfs.is_allowed("/tmp/output"));
121        assert!(vfs.is_allowed("/usr/bin/python3"));
122        assert!(vfs.is_allowed("/etc/passwd"));
123        assert!(vfs.is_allowed("/proc/self/status"));
124        assert!(!vfs.is_allowed("/root/.ssh/id_rsa"));
125        assert!(!vfs.is_allowed("/var/log/syslog"));
126    }
127
128    #[test]
129    fn custom_mapping() {
130        let mut vfs = VirtualFs::empty();
131        vfs.add_mapping("/data", "/mnt/shared/data");
132
133        assert_eq!(
134            vfs.translate("/data/file.csv"),
135            Some(PathBuf::from("/mnt/shared/data/file.csv"))
136        );
137        assert_eq!(vfs.translate("/work/test"), None);
138    }
139}