Skip to main content

evalbox_sandbox/
workspace.rs

1//! Workspace and pipe management for sandboxed execution.
2//!
3//! The workspace is a temporary directory that becomes the sandbox root after `pivot_root`.
4//! It contains all the pipes for parent-child communication.
5//!
6//! ## Pipes
7//!
8//! - **stdin**: Parent writes → Child reads
9//! - **stdout**: Child writes → Parent reads
10//! - **stderr**: Child writes → Parent reads
11//! - **sync**: Eventfd pair for parent-child synchronization (UID map setup)
12//!
13//! ## Important: Pipe Hygiene
14//!
15//! After `fork()`, each side must close unused pipe ends:
16//! - Parent closes: stdin.read, stdout.write, stderr.write
17//! - Child closes: stdin.write, stdout.read, stderr.read
18//!
19//! This is required for `poll()` to work correctly - EOF is only signaled
20//! when ALL write ends are closed.
21
22use std::fs;
23use std::io;
24use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
25use std::path::{Path, PathBuf};
26
27use tempfile::TempDir;
28
29/// Unidirectional pipe.
30#[derive(Debug)]
31pub struct Pipe {
32    pub read: OwnedFd,
33    pub write: OwnedFd,
34}
35
36impl Pipe {
37    pub fn new() -> io::Result<Self> {
38        let mut fds = [0i32; 2];
39        // SAFETY: pipe2 writes to valid array.
40        if unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC) } != 0 {
41            return Err(io::Error::last_os_error());
42        }
43        // SAFETY: On success, fds are valid file descriptors.
44        Ok(Self {
45            read: unsafe { OwnedFd::from_raw_fd(fds[0]) },
46            write: unsafe { OwnedFd::from_raw_fd(fds[1]) },
47        })
48    }
49
50    #[inline]
51    pub fn read_fd(&self) -> RawFd { self.read.as_raw_fd() }
52
53    #[inline]
54    pub fn write_fd(&self) -> RawFd { self.write.as_raw_fd() }
55}
56
57/// Eventfd-based parent-child synchronization.
58#[derive(Debug)]
59pub struct SyncPair {
60    pub child_ready: OwnedFd,
61    pub parent_done: OwnedFd,
62}
63
64impl SyncPair {
65    pub fn new() -> io::Result<Self> {
66        let child_ready = unsafe { libc::eventfd(0, 0) };
67        if child_ready < 0 {
68            return Err(io::Error::last_os_error());
69        }
70        let parent_done = unsafe { libc::eventfd(0, 0) };
71        if parent_done < 0 {
72            unsafe { libc::close(child_ready) };
73            return Err(io::Error::last_os_error());
74        }
75        Ok(Self {
76            child_ready: unsafe { OwnedFd::from_raw_fd(child_ready) },
77            parent_done: unsafe { OwnedFd::from_raw_fd(parent_done) },
78        })
79    }
80
81    #[inline]
82    pub fn child_ready_fd(&self) -> RawFd { self.child_ready.as_raw_fd() }
83
84    #[inline]
85    pub fn parent_done_fd(&self) -> RawFd { self.parent_done.as_raw_fd() }
86}
87
88/// All pipes for sandbox I/O.
89#[derive(Debug)]
90pub struct Pipes {
91    pub stdin: Pipe,
92    pub stdout: Pipe,
93    pub stderr: Pipe,
94    pub sync: SyncPair,
95}
96
97impl Pipes {
98    pub fn new() -> io::Result<Self> {
99        Ok(Self {
100            stdin: Pipe::new()?,
101            stdout: Pipe::new()?,
102            stderr: Pipe::new()?,
103            sync: SyncPair::new()?,
104        })
105    }
106}
107
108/// Temporary workspace for sandbox execution.
109#[derive(Debug)]
110pub struct Workspace {
111    root: PathBuf,
112    pub pipes: Pipes,
113    _tempdir: TempDir,
114}
115
116impl Workspace {
117    pub fn new() -> io::Result<Self> {
118        Self::with_prefix("evalbox-")
119    }
120
121    pub fn with_prefix(prefix: &str) -> io::Result<Self> {
122        let tempdir = TempDir::with_prefix(prefix)?;
123        Ok(Self {
124            root: tempdir.path().to_path_buf(),
125            pipes: Pipes::new()?,
126            _tempdir: tempdir,
127        })
128    }
129
130    #[inline]
131    pub fn root(&self) -> &Path { &self.root }
132
133    pub fn write_file(&self, path: &str, content: &[u8], executable: bool) -> io::Result<PathBuf> {
134        use std::os::unix::fs::PermissionsExt;
135
136        let full = self.root.join(path);
137        if let Some(parent) = full.parent() {
138            fs::create_dir_all(parent)?;
139        }
140        fs::write(&full, content)?;
141
142        if executable {
143            // Set executable permission (rwxr-xr-x)
144            fs::set_permissions(&full, fs::Permissions::from_mode(0o755))?;
145        }
146
147        Ok(full)
148    }
149
150    pub fn create_dir(&self, path: &str) -> io::Result<PathBuf> {
151        let full = self.root.join(path);
152        fs::create_dir_all(&full)?;
153        Ok(full)
154    }
155
156    pub fn setup_sandbox_dirs(&self) -> io::Result<()> {
157        for dir in ["proc", "dev", "tmp", "home", "work", "usr", "bin", "lib", "lib64", "etc"] {
158            self.create_dir(dir)?;
159        }
160        self.setup_minimal_etc()?;
161        Ok(())
162    }
163
164    /// Create minimal /etc files to prevent information leakage.
165    ///
166    /// Instead of mounting the host's /etc (which contains sensitive info like
167    /// /etc/passwd, /etc/shadow), we create a minimal /etc with only essential files.
168    pub fn setup_minimal_etc(&self) -> io::Result<()> {
169        let etc = self.root.join("etc");
170
171        // Minimal /etc/passwd - just nobody user
172        fs::write(
173            etc.join("passwd"),
174            "nobody:x:65534:65534:Unprivileged user:/nonexistent:/usr/sbin/nologin\n",
175        )?;
176
177        // Minimal /etc/group - just nobody group
178        fs::write(
179            etc.join("group"),
180            "nogroup:x:65534:\n",
181        )?;
182
183        // Minimal /etc/hosts - localhost only
184        fs::write(
185            etc.join("hosts"),
186            "127.0.0.1 localhost\n::1 localhost\n",
187        )?;
188
189        // Minimal /etc/nsswitch.conf - required for name resolution
190        fs::write(
191            etc.join("nsswitch.conf"),
192            "passwd: files\ngroup: files\nhosts: files dns\n",
193        )?;
194
195        // Copy /etc/ld.so.cache from host if it exists (needed for dynamic linking)
196        let host_ldcache = Path::new("/etc/ld.so.cache");
197        if host_ldcache.exists() {
198            if let Ok(content) = fs::read(host_ldcache) {
199                fs::write(etc.join("ld.so.cache"), content)?;
200            }
201        }
202
203        // Create /etc/ssl directory for certificates
204        let ssl_dir = etc.join("ssl");
205        fs::create_dir_all(&ssl_dir)?;
206
207        // Minimal /etc/resolv.conf - empty (network is blocked by default)
208        // When network is enabled, Landlock will allow DNS
209        fs::write(etc.join("resolv.conf"), "# DNS disabled in sandbox\n")?;
210
211        Ok(())
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn pipe_creation() {
221        let pipe = Pipe::new().unwrap();
222        assert!(pipe.read_fd() >= 0);
223        assert_ne!(pipe.read_fd(), pipe.write_fd());
224    }
225
226    #[test]
227    fn workspace_creation() {
228        let ws = Workspace::new().unwrap();
229        assert!(ws.root().exists());
230    }
231
232    #[test]
233    fn workspace_write_file() {
234        let ws = Workspace::new().unwrap();
235        let path = ws.write_file("test.txt", b"hello", false).unwrap();
236        assert!(path.exists());
237    }
238
239    #[test]
240    fn workspace_write_executable() {
241        use std::os::unix::fs::PermissionsExt;
242
243        let ws = Workspace::new().unwrap();
244        let path = ws.write_file("binary", b"\x7fELF", true).unwrap();
245        assert!(path.exists());
246        let perms = std::fs::metadata(&path).unwrap().permissions();
247        assert_eq!(perms.mode() & 0o777, 0o755);
248    }
249}