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