Skip to main content

evalbox_sandbox/
plan.rs

1//! Sandbox execution plan.
2//!
3//! A `Plan` describes everything needed to run a command in the sandbox:
4//! the command, environment, files, mounts, and resource limits.
5//!
6//! ## Example
7//!
8//! ```ignore
9//! use evalbox_sandbox::{Plan, Mount};
10//!
11//! let plan = Plan::new(["python", "main.py"])
12//!     .env("PYTHONPATH", "/work")
13//!     .file("main.py", b"print('hello')")
14//!     .timeout(Duration::from_secs(10))
15//!     .memory(256 * 1024 * 1024);
16//! ```
17//!
18//! ## Advanced Security Configuration
19//!
20//! ```ignore
21//! use evalbox_sandbox::{Plan, Syscalls, Landlock};
22//!
23//! let plan = Plan::new(["python3", "-c", "code"])
24//!     .syscalls(Syscalls::default().allow(libc::SYS_openat))
25//!     .landlock(Landlock::default().allow_read("/etc"))
26//!     .network(false);
27//! ```
28//!
29//! ## Defaults
30//!
31//! | Field | Default |
32//! |-------|---------|
33//! | `timeout` | 30 seconds |
34//! | `memory` | 256 MiB |
35//! | `max_pids` | 64 processes |
36//! | `max_output` | 16 MiB |
37//! | `network` | false (blocked) |
38//! | `cwd` | `/work` |
39
40use std::collections::{HashMap, HashSet};
41use std::path::PathBuf;
42use std::time::Duration;
43
44/// Mount point configuration.
45///
46/// This is the canonical Mount type used throughout evalbox.
47#[derive(Debug, Clone)]
48pub struct Mount {
49    /// Path on the host filesystem.
50    pub source: PathBuf,
51    /// Path inside the sandbox.
52    pub target: PathBuf,
53    /// If false, mount is read-only (default).
54    pub writable: bool,
55    /// If true, executables can be run from this mount (for Landlock).
56    pub executable: bool,
57}
58
59impl Mount {
60    /// Read-only mount, same path inside/outside sandbox.
61    pub fn ro(path: impl Into<PathBuf>) -> Self {
62        let path = path.into();
63        Self {
64            source: path.clone(),
65            target: path,
66            writable: false,
67            executable: true,
68        }
69    }
70
71    /// Read-only mount without execute permission.
72    pub fn ro_noexec(path: impl Into<PathBuf>) -> Self {
73        let path = path.into();
74        Self {
75            source: path.clone(),
76            target: path,
77            writable: false,
78            executable: false,
79        }
80    }
81
82    /// Read-write mount, same path inside/outside sandbox.
83    pub fn rw(path: impl Into<PathBuf>) -> Self {
84        let path = path.into();
85        Self {
86            source: path.clone(),
87            target: path,
88            writable: true,
89            executable: true,
90        }
91    }
92
93    /// Mount with different host and sandbox paths (read-only by default).
94    pub fn bind(source: impl Into<PathBuf>, target: impl Into<PathBuf>) -> Self {
95        Self {
96            source: source.into(),
97            target: target.into(),
98            writable: false,
99            executable: true,
100        }
101    }
102
103    /// Make mount writable.
104    pub fn writable(mut self) -> Self {
105        self.writable = true;
106        self
107    }
108
109    /// Disable execute permission (for Landlock).
110    pub fn noexec(mut self) -> Self {
111        self.executable = false;
112        self
113    }
114}
115
116/// Syscall filtering configuration.
117///
118/// By default, a strict whitelist of ~40 safe syscalls is allowed.
119/// Use this to customize the allowed syscalls for specific use cases.
120///
121/// ## Example
122///
123/// ```ignore
124/// use evalbox_sandbox::Syscalls;
125///
126/// // Start with default whitelist, add specific syscalls
127/// let syscalls = Syscalls::default()
128///     .allow(libc::SYS_openat)
129///     .allow(libc::SYS_socket);
130///
131/// // Or deny specific syscalls (removes from whitelist)
132/// let syscalls = Syscalls::default()
133///     .deny(libc::SYS_clone);
134/// ```
135#[derive(Debug, Clone, Default)]
136pub struct Syscalls {
137    /// Additional syscalls to allow beyond the default whitelist.
138    pub allowed: HashSet<i64>,
139    /// Syscalls to deny (removes from whitelist).
140    pub denied: HashSet<i64>,
141}
142
143impl Syscalls {
144    /// Create a new Syscalls config (default whitelist).
145    pub fn new() -> Self {
146        Self::default()
147    }
148
149    /// Allow a specific syscall.
150    pub fn allow(mut self, syscall: i64) -> Self {
151        self.allowed.insert(syscall);
152        self.denied.remove(&syscall);
153        self
154    }
155
156    /// Deny a specific syscall (remove from whitelist).
157    pub fn deny(mut self, syscall: i64) -> Self {
158        self.denied.insert(syscall);
159        self.allowed.remove(&syscall);
160        self
161    }
162
163    /// Allow multiple syscalls.
164    pub fn allow_many(mut self, syscalls: impl IntoIterator<Item = i64>) -> Self {
165        for syscall in syscalls {
166            self.allowed.insert(syscall);
167            self.denied.remove(&syscall);
168        }
169        self
170    }
171
172    /// Deny multiple syscalls.
173    pub fn deny_many(mut self, syscalls: impl IntoIterator<Item = i64>) -> Self {
174        for syscall in syscalls {
175            self.denied.insert(syscall);
176            self.allowed.remove(&syscall);
177        }
178        self
179    }
180}
181
182/// Landlock filesystem and network access control configuration.
183///
184/// Landlock is a Linux security module (LSM) that provides fine-grained
185/// filesystem and network access control for unprivileged processes.
186///
187/// ## Example
188///
189/// ```ignore
190/// use evalbox_sandbox::Landlock;
191///
192/// let landlock = Landlock::default()
193///     .allow_read("/etc")
194///     .allow_read_write("/tmp/output")
195///     .allow_execute("/usr/bin");
196/// ```
197#[derive(Debug, Clone, Default)]
198pub struct Landlock {
199    /// Paths with read access.
200    pub read_paths: Vec<PathBuf>,
201    /// Paths with read-write access.
202    pub write_paths: Vec<PathBuf>,
203    /// Paths with execute access.
204    pub execute_paths: Vec<PathBuf>,
205}
206
207impl Landlock {
208    /// Create a new Landlock config.
209    pub fn new() -> Self {
210        Self::default()
211    }
212
213    /// Allow read access to a path.
214    pub fn allow_read(mut self, path: impl Into<PathBuf>) -> Self {
215        self.read_paths.push(path.into());
216        self
217    }
218
219    /// Allow read-write access to a path.
220    pub fn allow_read_write(mut self, path: impl Into<PathBuf>) -> Self {
221        self.write_paths.push(path.into());
222        self
223    }
224
225    /// Allow execute access to a path.
226    pub fn allow_execute(mut self, path: impl Into<PathBuf>) -> Self {
227        self.execute_paths.push(path.into());
228        self
229    }
230}
231
232/// File to write to workspace before execution.
233#[derive(Debug, Clone)]
234pub struct UserFile {
235    pub path: String,
236    pub content: Vec<u8>,
237    pub executable: bool,
238}
239
240impl UserFile {
241    pub fn new(path: impl Into<String>, content: impl Into<Vec<u8>>) -> Self {
242        Self { path: path.into(), content: content.into(), executable: false }
243    }
244
245    pub fn executable(mut self) -> Self {
246        self.executable = true;
247        self
248    }
249}
250
251/// Complete sandbox execution plan.
252///
253/// This is the low-level API for full control over sandbox execution.
254/// Most users should use the high-level `evalbox` crate instead.
255///
256/// ## Example
257///
258/// ```ignore
259/// use evalbox_sandbox::{Plan, Mount, Executor};
260///
261/// let plan = Plan::new(["python3", "-c", "print('hello')"])
262///     .mount(Mount::ro("/usr/lib"))
263///     .timeout(Duration::from_secs(60))
264///     .memory(256 * 1024 * 1024)
265///     .network(false);
266///
267/// let output = Executor::run(plan)?;
268/// ```
269#[derive(Debug, Clone)]
270pub struct Plan {
271    pub cmd: Vec<String>,
272    /// Pre-resolved binary path. If set, sandbox uses this instead of resolving cmd[0].
273    /// This allows evalbox to do binary resolution before calling sandbox.
274    pub binary_path: Option<PathBuf>,
275    pub env: HashMap<String, String>,
276    pub stdin: Option<Vec<u8>>,
277    pub cwd: String,
278    pub mounts: Vec<Mount>,
279    pub user_files: Vec<UserFile>,
280    pub workspace_size: u64,
281    pub timeout: Duration,
282    pub memory_limit: u64,
283    pub max_pids: u32,
284    pub max_output: u64,
285    pub network_blocked: bool,
286    /// Custom syscall filtering configuration.
287    pub syscalls: Option<Syscalls>,
288    /// Custom Landlock configuration.
289    pub landlock: Option<Landlock>,
290}
291
292/// Type alias for backwards compatibility.
293#[deprecated(since = "0.2.0", note = "Use `Plan` instead")]
294pub type SandboxPlan = Plan;
295
296impl Default for Plan {
297    fn default() -> Self {
298        Self {
299            cmd: Vec::new(),
300            binary_path: None,
301            env: default_env(),
302            stdin: None,
303            cwd: "/work".into(),
304            mounts: Vec::new(),
305            user_files: Vec::new(),
306            workspace_size: 64 * 1024 * 1024,
307            timeout: Duration::from_secs(30),
308            memory_limit: 256 * 1024 * 1024,
309            max_pids: 64,
310            max_output: 16 * 1024 * 1024,
311            network_blocked: true,
312            syscalls: None,
313            landlock: None,
314        }
315    }
316}
317
318impl Plan {
319    pub fn new(cmd: impl IntoIterator<Item = impl Into<String>>) -> Self {
320        Self { cmd: cmd.into_iter().map(Into::into).collect(), ..Default::default() }
321    }
322
323    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
324        self.env.insert(key.into(), value.into());
325        self
326    }
327
328    pub fn stdin(mut self, data: impl Into<Vec<u8>>) -> Self {
329        self.stdin = Some(data.into());
330        self
331    }
332
333    pub fn cwd(mut self, cwd: impl Into<String>) -> Self {
334        self.cwd = cwd.into();
335        self
336    }
337
338    pub fn mount(mut self, mount: Mount) -> Self {
339        self.mounts.push(mount);
340        self
341    }
342
343    /// Add multiple mounts from an iterator.
344    pub fn mounts(mut self, mounts: impl IntoIterator<Item = Mount>) -> Self {
345        self.mounts.extend(mounts);
346        self
347    }
348
349    /// Set pre-resolved binary path.
350    ///
351    /// When set, the sandbox uses this path directly instead of resolving `cmd[0]`.
352    /// This is used by evalbox to pre-resolve binaries before calling sandbox.
353    pub fn binary_path(mut self, path: impl Into<PathBuf>) -> Self {
354        self.binary_path = Some(path.into());
355        self
356    }
357
358    pub fn file(mut self, path: impl Into<String>, content: impl Into<Vec<u8>>) -> Self {
359        self.user_files.push(UserFile::new(path, content));
360        self
361    }
362
363    /// Add an executable binary to the workspace.
364    pub fn executable(mut self, path: impl Into<String>, content: impl Into<Vec<u8>>) -> Self {
365        self.user_files.push(UserFile::new(path, content).executable());
366        self
367    }
368
369    pub fn timeout(mut self, timeout: Duration) -> Self {
370        self.timeout = timeout;
371        self
372    }
373
374    pub fn memory_limit(mut self, limit: u64) -> Self {
375        self.memory_limit = limit;
376        self
377    }
378
379    pub fn max_pids(mut self, max: u32) -> Self {
380        self.max_pids = max;
381        self
382    }
383
384    pub fn max_output(mut self, max: u64) -> Self {
385        self.max_output = max;
386        self
387    }
388
389    pub fn network_blocked(mut self, blocked: bool) -> Self {
390        self.network_blocked = blocked;
391        self
392    }
393
394    /// Enable or disable network access.
395    ///
396    /// This is the inverse of `network_blocked`: `network(true)` enables network,
397    /// `network(false)` blocks network (default).
398    pub fn network(mut self, enabled: bool) -> Self {
399        self.network_blocked = !enabled;
400        self
401    }
402
403    /// Set memory limit (alias for `memory_limit`).
404    pub fn memory(self, limit: u64) -> Self {
405        self.memory_limit(limit)
406    }
407
408    /// Set custom syscall filtering configuration.
409    pub fn syscalls(mut self, syscalls: Syscalls) -> Self {
410        self.syscalls = Some(syscalls);
411        self
412    }
413
414    /// Set custom Landlock configuration.
415    pub fn landlock(mut self, landlock: Landlock) -> Self {
416        self.landlock = Some(landlock);
417        self
418    }
419
420    /// Execute this plan (convenience method).
421    ///
422    /// Equivalent to `Executor::run(self)`.
423    pub fn exec(self) -> Result<crate::Output, crate::ExecutorError> {
424        crate::Executor::run(self)
425    }
426}
427
428fn default_env() -> HashMap<String, String> {
429    // Default PATH covers common locations on FHS and NixOS systems.
430    // For NixOS, the caller (evalbox) should set PATH from SYSTEM_PATHS.
431    let default_path = if std::path::Path::new("/nix/store").exists() {
432        "/run/current-system/sw/bin:/nix/var/nix/profiles/default/bin:/usr/bin:/bin"
433    } else {
434        "/usr/local/bin:/usr/bin:/bin"
435    };
436
437    HashMap::from([
438        ("PATH".into(), default_path.into()),
439        ("HOME".into(), "/home".into()),
440        ("USER".into(), "sandbox".into()),
441        ("LANG".into(), "C.UTF-8".into()),
442        ("LC_ALL".into(), "C.UTF-8".into()),
443    ])
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449
450    #[test]
451    fn plan_new() {
452        let plan = Plan::new(["echo", "hello"]);
453        assert_eq!(plan.cmd, vec!["echo", "hello"]);
454        assert!(plan.network_blocked);
455    }
456
457    #[test]
458    fn plan_builder() {
459        let plan = Plan::new(["python", "main.py"])
460            .env("PYTHONPATH", "/work")
461            .stdin(b"input".to_vec())
462            .timeout(Duration::from_secs(10))
463            .file("main.py", b"print('hello')");
464
465        assert_eq!(plan.env.get("PYTHONPATH"), Some(&"/work".into()));
466        assert_eq!(plan.stdin, Some(b"input".to_vec()));
467        assert_eq!(plan.timeout, Duration::from_secs(10));
468        assert_eq!(plan.user_files.len(), 1);
469    }
470
471    #[test]
472    fn plan_network_methods() {
473        let plan = Plan::new(["echo"]).network(true);
474        assert!(!plan.network_blocked);
475
476        let plan = Plan::new(["echo"]).network(false);
477        assert!(plan.network_blocked);
478    }
479
480    #[test]
481    fn plan_syscalls_config() {
482        let syscalls = Syscalls::default()
483            .allow(1)
484            .allow(2)
485            .deny(3);
486
487        assert!(syscalls.allowed.contains(&1));
488        assert!(syscalls.allowed.contains(&2));
489        assert!(syscalls.denied.contains(&3));
490    }
491
492    #[test]
493    fn plan_landlock_config() {
494        let landlock = Landlock::new()
495            .allow_read("/etc")
496            .allow_read_write("/tmp");
497
498        assert_eq!(landlock.read_paths.len(), 1);
499        assert_eq!(landlock.write_paths.len(), 1);
500    }
501}