Skip to main content

evalbox_sandbox/
executor.rs

1//! Sandbox executor for both blocking and concurrent execution.
2//!
3//! This module provides the unified API for sandbox execution:
4//!
5//! - `Executor::run()` - Blocking execution (single sandbox)
6//! - `Executor::spawn()` + `poll()` - Concurrent execution (multiple sandboxes)
7//!
8//! ## Blocking Example
9//!
10//! ```ignore
11//! use evalbox_sandbox::{Executor, Plan};
12//!
13//! let output = Executor::run(Plan::new(["echo", "hello"]))?;
14//! assert_eq!(output.stdout, b"hello\n");
15//! ```
16//!
17//! ## Concurrent Example
18//!
19//! ```ignore
20//! use evalbox_sandbox::{Executor, Plan, Event};
21//!
22//! let mut executor = Executor::new()?;
23//! let id = executor.spawn(Plan::new(["echo", "hello"]))?;
24//!
25//! let mut events = Vec::new();
26//! while executor.active_count() > 0 {
27//!     executor.poll(&mut events, None)?;
28//!     for event in events.drain(..) {
29//!         match event {
30//!             Event::Completed { id, output } => println!("Done: {:?}", output),
31//!             Event::Stdout { id, data } => print!("{}", String::from_utf8_lossy(&data)),
32//!             _ => {}
33//!         }
34//!     }
35//! }
36//! ```
37
38use std::collections::{HashMap, HashSet};
39use std::ffi::CString;
40use std::io::{self, Write as _};
41use std::os::fd::{AsRawFd, OwnedFd, RawFd};
42use std::path::PathBuf;
43use std::time::{Duration, Instant};
44
45use mio::unix::SourceFd;
46use mio::{Events as MioEvents, Interest, Poll, Token};
47use rustix::io::Errno;
48use rustix::process::{Pid, PidfdFlags, Signal, pidfd_open, pidfd_send_signal};
49use thiserror::Error;
50
51use evalbox_sys::seccomp::{
52    SockFprog, build_notify_filter, build_whitelist_filter, default_whitelist, notify_fs_syscalls,
53};
54use evalbox_sys::seccomp_notify::seccomp_set_mode_filter_listener;
55use evalbox_sys::{check, last_errno, seccomp::seccomp_set_mode_filter};
56
57use crate::isolation::{LockdownError, close_extra_fds, lockdown};
58use crate::monitor::{Output, Status, monitor, set_nonblocking, wait_for_exit, write_stdin};
59use crate::notify::scm_rights;
60use crate::plan::{Mount, NotifyMode, Plan};
61use crate::resolve::{ResolvedBinary, resolve_binary};
62use crate::validate::validate_cmd;
63use crate::workspace::Workspace;
64
65/// Error during sandbox execution.
66#[derive(Debug, Error)]
67pub enum ExecutorError {
68    #[error("system check: {0}")]
69    SystemCheck(String),
70
71    #[error("validation: {0}")]
72    Validation(#[from] crate::validate::ValidationError),
73
74    #[error("workspace: {0}")]
75    Workspace(io::Error),
76
77    #[error("fork: {0}")]
78    Fork(Errno),
79
80    #[error("lockdown: {0}")]
81    Lockdown(#[from] LockdownError),
82
83    #[error("exec: {0}")]
84    Exec(Errno),
85
86    #[error("monitor: {0}")]
87    Monitor(io::Error),
88
89    #[error("child setup: {0}")]
90    ChildSetup(String),
91
92    #[error("pidfd: {0}")]
93    Pidfd(Errno),
94
95    #[error("command not found: {0}")]
96    CommandNotFound(String),
97
98    #[error("seccomp notify: {0}")]
99    SeccompNotify(String),
100
101    #[error("io: {0}")]
102    Io(#[from] io::Error),
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
106pub struct SandboxId(pub usize);
107
108impl std::fmt::Display for SandboxId {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        write!(f, "Sandbox({})", self.0)
111    }
112}
113
114/// Events emitted by the Executor.
115#[derive(Debug)]
116pub enum Event {
117    /// Sandbox completed execution.
118    Completed { id: SandboxId, output: Output },
119    /// Sandbox timed out and was killed.
120    Timeout { id: SandboxId, output: Output },
121    /// Stdout data available (streaming mode).
122    Stdout { id: SandboxId, data: Vec<u8> },
123    /// Stderr data available (streaming mode).
124    Stderr { id: SandboxId, data: Vec<u8> },
125}
126
127struct ExecutionInfo {
128    binary_path: PathBuf,
129    extra_mounts: Vec<Mount>,
130}
131
132impl ExecutionInfo {
133    fn from_resolved(resolved: ResolvedBinary) -> Self {
134        let extra_mounts = resolved
135            .required_mounts
136            .into_iter()
137            .map(|m| Mount::bind(&m.source, &m.target))
138            .collect();
139        Self {
140            binary_path: resolved.path,
141            extra_mounts,
142        }
143    }
144
145    fn from_plan(plan: &Plan) -> Option<Self> {
146        plan.binary_path.as_ref().map(|path| Self {
147            binary_path: path.clone(),
148            extra_mounts: Vec::new(),
149        })
150    }
151}
152
153/// A spawned sandbox that hasn't been waited on yet.
154///
155/// Some fields are never read but kept alive for RAII (fd lifetime, temp dir cleanup).
156#[allow(dead_code)]
157struct SpawnedSandbox {
158    pidfd: OwnedFd,
159    stdin_fd: RawFd,
160    stdout_fd: RawFd,
161    stderr_fd: RawFd,
162    /// Seccomp listener fd kept alive for RAII; future supervisor integration.
163    notify_fd: Option<OwnedFd>,
164    /// Workspace kept alive so temp directory isn't deleted while sandbox runs.
165    workspace: std::mem::ManuallyDrop<Workspace>,
166}
167
168impl Drop for SpawnedSandbox {
169    fn drop(&mut self) {
170        // Close remaining pipe fds. Some may already be closed by
171        // close_parent_pipe_ends or the event loop (EBADF is harmless).
172        unsafe {
173            if self.stdin_fd >= 0 {
174                libc::close(self.stdin_fd);
175            }
176            if self.stdout_fd >= 0 {
177                libc::close(self.stdout_fd);
178            }
179            if self.stderr_fd >= 0 {
180                libc::close(self.stderr_fd);
181            }
182        }
183        // Clean up temp directory without dropping workspace (which would
184        // trigger OwnedFd double-close for fds already closed by libc::close).
185        let _ = std::fs::remove_dir_all(self.workspace.root());
186    }
187}
188
189/// Internal state for a running sandbox.
190struct SandboxState {
191    spawned: SpawnedSandbox,
192    deadline: Instant,
193    start: Instant,
194    stdout: Vec<u8>,
195    stderr: Vec<u8>,
196    max_output: u64,
197    pidfd_ready: bool,
198    stdout_closed: bool,
199    stderr_closed: bool,
200}
201
202impl SandboxState {
203    fn is_done(&self) -> bool {
204        self.pidfd_ready && self.stdout_closed && self.stderr_closed
205    }
206}
207
208// Token encoding: [sandbox_id: 20 bits][type: 2 bits]
209const TOKEN_TYPE_BITS: usize = 2;
210const TOKEN_TYPE_MASK: usize = 0b11;
211const TOKEN_TYPE_PIDFD: usize = 0;
212const TOKEN_TYPE_STDOUT: usize = 1;
213const TOKEN_TYPE_STDERR: usize = 2;
214
215fn encode_token(sandbox_id: usize, token_type: usize) -> Token {
216    Token((sandbox_id << TOKEN_TYPE_BITS) | token_type)
217}
218
219fn decode_token(token: Token) -> (SandboxId, usize) {
220    let raw = token.0;
221    (SandboxId(raw >> TOKEN_TYPE_BITS), raw & TOKEN_TYPE_MASK)
222}
223
224pub struct Executor {
225    poll: Poll,
226    sandboxes: HashMap<SandboxId, SandboxState>,
227    next_id: usize,
228    mio_events: MioEvents,
229}
230
231impl Executor {
232    pub fn new() -> io::Result<Self> {
233        Ok(Self {
234            poll: Poll::new()?,
235            sandboxes: HashMap::new(),
236            next_id: 0,
237            mio_events: MioEvents::with_capacity(64),
238        })
239    }
240
241    /// Execute a sandbox and wait for completion (blocking).
242    pub fn run(plan: Plan) -> Result<Output, ExecutorError> {
243        let cmd_refs: Vec<&str> = plan.cmd.iter().map(|s| s.as_str()).collect();
244        validate_cmd(&cmd_refs).map_err(ExecutorError::Validation)?;
245
246        if let Err(e) = check::check() {
247            return Err(ExecutorError::SystemCheck(e.to_string()));
248        }
249
250        let exec_info = if let Some(info) = ExecutionInfo::from_plan(&plan) {
251            info
252        } else {
253            let resolved = resolve_binary(&plan.cmd[0])
254                .map_err(|e| ExecutorError::CommandNotFound(e.to_string()))?;
255            ExecutionInfo::from_resolved(resolved)
256        };
257
258        let workspace = Workspace::with_prefix("evalbox-").map_err(ExecutorError::Workspace)?;
259
260        workspace
261            .setup_sandbox_dirs()
262            .map_err(ExecutorError::Workspace)?;
263        for file in &plan.user_files {
264            let work_path = format!("work/{}", file.path);
265            workspace
266                .write_file(&work_path, &file.content, file.executable)
267                .map_err(ExecutorError::Workspace)?;
268        }
269
270        // Create socketpair for notify fd transfer (if needed)
271        let notify_sockets = if plan.notify_mode != NotifyMode::Disabled {
272            Some(scm_rights::create_socketpair().map_err(ExecutorError::Workspace)?)
273        } else {
274            None
275        };
276
277        // SAFETY: fork is safe, returns child pid in parent, 0 in child, or -1 on error.
278        let child_pid = unsafe { libc::fork() };
279        if child_pid < 0 {
280            return Err(ExecutorError::Fork(last_errno()));
281        }
282
283        if child_pid == 0 {
284            // In child: close parent's socket end
285            let child_socket = notify_sockets.map(|(_, child)| child);
286            match child_process(&workspace, &plan, &exec_info, child_socket.as_ref()) {
287                Ok(()) => unsafe { libc::_exit(127) },
288                Err(e) => {
289                    writeln!(io::stderr(), "sandbox error: {e}").ok();
290                    unsafe { libc::_exit(126) }
291                }
292            }
293        }
294
295        // SAFETY: child_pid is a valid, just-forked PID. There is a theoretical race
296        // between fork() and pidfd_open() if PIDs wrap around in microseconds, but this
297        // is extremely unlikely. The ideal fix is clone3(CLONE_PIDFD) which atomically
298        // returns a pidfd, but is complex to implement with the current fork-based
299        // architecture. Acceptable for v0.1.x.
300        let pid = unsafe { Pid::from_raw_unchecked(child_pid) };
301        let pidfd = pidfd_open(pid, PidfdFlags::empty()).map_err(ExecutorError::Pidfd)?;
302
303        // Parent: receive notify fd if applicable
304        let notify_fd = if let Some((parent_socket, _)) = notify_sockets {
305            poll_or_kill(
306                parent_socket.as_raw_fd(),
307                child_pid,
308                "timeout waiting for notify fd",
309            )?;
310            Some(
311                scm_rights::recv_fd(parent_socket.as_raw_fd())
312                    .map_err(|e| ExecutorError::SeccompNotify(e.to_string()))?,
313            )
314        } else {
315            None
316        };
317
318        blocking_parent(child_pid, pidfd, notify_fd, workspace, plan)
319    }
320
321    /// Spawn a new sandbox. Returns immediately with a [`SandboxId`].
322    pub fn spawn(&mut self, plan: Plan) -> Result<SandboxId, ExecutorError> {
323        let id = SandboxId(self.next_id);
324        self.next_id += 1;
325
326        let timeout = plan.timeout;
327        let max_output = plan.max_output;
328
329        let spawned = spawn_sandbox(plan)?;
330
331        // Register with mio
332        let pidfd_token = encode_token(id.0, TOKEN_TYPE_PIDFD);
333        let stdout_token = encode_token(id.0, TOKEN_TYPE_STDOUT);
334        let stderr_token = encode_token(id.0, TOKEN_TYPE_STDERR);
335
336        self.poll.registry().register(
337            &mut SourceFd(&spawned.pidfd.as_raw_fd()),
338            pidfd_token,
339            Interest::READABLE,
340        )?;
341        self.poll.registry().register(
342            &mut SourceFd(&spawned.stdout_fd),
343            stdout_token,
344            Interest::READABLE,
345        )?;
346        self.poll.registry().register(
347            &mut SourceFd(&spawned.stderr_fd),
348            stderr_token,
349            Interest::READABLE,
350        )?;
351
352        let state = SandboxState {
353            spawned,
354            deadline: Instant::now() + timeout,
355            start: Instant::now(),
356            stdout: Vec::new(),
357            stderr: Vec::new(),
358            max_output,
359            pidfd_ready: false,
360            stdout_closed: false,
361            stderr_closed: false,
362        };
363
364        self.sandboxes.insert(id, state);
365        Ok(id)
366    }
367
368    /// Poll for events. Blocks until events are available or timeout expires.
369    pub fn poll(&mut self, events: &mut Vec<Event>, timeout: Option<Duration>) -> io::Result<()> {
370        events.clear();
371
372        if self.sandboxes.is_empty() {
373            return Ok(());
374        }
375
376        let effective_timeout = self.calculate_timeout(timeout);
377        self.poll.poll(&mut self.mio_events, effective_timeout)?;
378
379        let mut pidfd_ready: Vec<SandboxId> = Vec::new();
380        let mut read_stdout: Vec<SandboxId> = Vec::new();
381        let mut read_stderr: Vec<SandboxId> = Vec::new();
382
383        for mio_event in &self.mio_events {
384            let (sandbox_id, token_type) = decode_token(mio_event.token());
385            if self.sandboxes.contains_key(&sandbox_id) {
386                match token_type {
387                    TOKEN_TYPE_PIDFD => pidfd_ready.push(sandbox_id),
388                    TOKEN_TYPE_STDOUT => read_stdout.push(sandbox_id),
389                    TOKEN_TYPE_STDERR => read_stderr.push(sandbox_id),
390                    _ => {}
391                }
392            }
393        }
394
395        for id in pidfd_ready {
396            if let Some(state) = self.sandboxes.get_mut(&id) {
397                state.pidfd_ready = true;
398            }
399        }
400
401        for id in read_stdout {
402            self.read_pipe(id, true, events);
403        }
404
405        for id in read_stderr {
406            self.read_pipe(id, false, events);
407        }
408
409        self.check_completions(events)?;
410        Ok(())
411    }
412
413    pub fn active_count(&self) -> usize {
414        self.sandboxes.len()
415    }
416
417    pub fn kill(&mut self, id: SandboxId) -> io::Result<()> {
418        if let Some(state) = self.sandboxes.get(&id) {
419            pidfd_send_signal(&state.spawned.pidfd, Signal::KILL)?;
420        }
421        Ok(())
422    }
423
424    /// Write data to a sandbox's stdin.
425    // Cast is safe: libc::write returns bytes written which fits in usize on 64-bit.
426    #[allow(clippy::cast_sign_loss)]
427    pub fn write_stdin(&mut self, id: SandboxId, data: &[u8]) -> io::Result<usize> {
428        if let Some(state) = self.sandboxes.get(&id) {
429            let fd = state.spawned.stdin_fd;
430            if fd < 0 {
431                return Err(io::Error::new(io::ErrorKind::BrokenPipe, "stdin closed"));
432            }
433            let ret = unsafe { libc::write(fd, data.as_ptr().cast(), data.len()) };
434            if ret < 0 {
435                Err(io::Error::last_os_error())
436            } else {
437                Ok(ret as usize)
438            }
439        } else {
440            Err(io::Error::new(io::ErrorKind::NotFound, "sandbox not found"))
441        }
442    }
443
444    /// Close a sandbox's stdin (signal EOF).
445    pub fn close_stdin(&mut self, id: SandboxId) -> io::Result<()> {
446        if let Some(state) = self.sandboxes.get_mut(&id) {
447            if state.spawned.stdin_fd >= 0 {
448                unsafe { libc::close(state.spawned.stdin_fd) };
449                state.spawned.stdin_fd = -1;
450            }
451        }
452        Ok(())
453    }
454
455    fn calculate_timeout(&self, user_timeout: Option<Duration>) -> Option<Duration> {
456        let now = Instant::now();
457        let nearest_deadline = self.sandboxes.values().map(|s| s.deadline).min();
458
459        match (user_timeout, nearest_deadline) {
460            (Some(user), Some(deadline)) => Some(user.min(deadline.saturating_duration_since(now))),
461            (Some(user), None) => Some(user),
462            (None, Some(deadline)) => Some(deadline.saturating_duration_since(now)),
463            (None, None) => None,
464        }
465    }
466
467    // Cast is safe: libc::read returns bytes read (positive) which fits in usize;
468    // max_output fits in usize on 64-bit.
469    #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
470    fn read_pipe(&mut self, sandbox_id: SandboxId, is_stdout: bool, events: &mut Vec<Event>) {
471        let Some(state) = self.sandboxes.get_mut(&sandbox_id) else {
472            return;
473        };
474
475        let fd = if is_stdout {
476            state.spawned.stdout_fd
477        } else {
478            state.spawned.stderr_fd
479        };
480
481        let mut buf = [0u8; 4096];
482        loop {
483            let ret = unsafe { libc::read(fd, buf.as_mut_ptr().cast(), buf.len()) };
484
485            if ret < 0 {
486                let err = io::Error::last_os_error();
487                if err.kind() == io::ErrorKind::WouldBlock {
488                    break;
489                }
490                if is_stdout {
491                    state.stdout_closed = true;
492                } else {
493                    state.stderr_closed = true;
494                }
495                break;
496            } else if ret == 0 {
497                if is_stdout {
498                    state.stdout_closed = true;
499                } else {
500                    state.stderr_closed = true;
501                }
502                break;
503            } else {
504                let n = ret as usize;
505                let data = buf[..n].to_vec();
506
507                if is_stdout {
508                    state.stdout.extend_from_slice(&data);
509                    events.push(Event::Stdout {
510                        id: sandbox_id,
511                        data,
512                    });
513                } else {
514                    state.stderr.extend_from_slice(&data);
515                    events.push(Event::Stderr {
516                        id: sandbox_id,
517                        data,
518                    });
519                }
520
521                let total = state.stdout.len() + state.stderr.len();
522                if total > state.max_output as usize {
523                    pidfd_send_signal(&state.spawned.pidfd, Signal::KILL).ok();
524                    break;
525                }
526            }
527        }
528    }
529
530    // Cast is safe: max_output fits in usize on 64-bit.
531    #[allow(clippy::cast_possible_truncation)]
532    fn check_completions(&mut self, events: &mut Vec<Event>) -> io::Result<()> {
533        let now = Instant::now();
534        let mut to_remove = Vec::new();
535
536        for (&id, state) in &mut self.sandboxes {
537            if now >= state.deadline && !state.pidfd_ready {
538                pidfd_send_signal(&state.spawned.pidfd, Signal::KILL).ok();
539                state.pidfd_ready = true;
540            }
541            if state.is_done() {
542                to_remove.push(id);
543            }
544        }
545
546        for id in to_remove {
547            if let Some(state) = self.sandboxes.remove(&id) {
548                self.poll
549                    .registry()
550                    .deregister(&mut SourceFd(&state.spawned.pidfd.as_raw_fd()))
551                    .ok();
552                self.poll
553                    .registry()
554                    .deregister(&mut SourceFd(&state.spawned.stdout_fd))
555                    .ok();
556                self.poll
557                    .registry()
558                    .deregister(&mut SourceFd(&state.spawned.stderr_fd))
559                    .ok();
560
561                let (exit_code, signal) = wait_for_exit(state.spawned.pidfd.as_raw_fd())?;
562                let duration = state.start.elapsed();
563                let timed_out = Instant::now() >= state.deadline;
564
565                let status = if timed_out {
566                    Status::Timeout
567                } else if signal.is_some() {
568                    Status::Signaled
569                } else if state.stdout.len() + state.stderr.len() > state.max_output as usize {
570                    Status::OutputLimitExceeded
571                } else {
572                    Status::Exited
573                };
574
575                let output = Output {
576                    stdout: state.stdout,
577                    stderr: state.stderr,
578                    status,
579                    duration,
580                    exit_code,
581                    signal,
582                };
583
584                if timed_out {
585                    events.push(Event::Timeout { id, output });
586                } else {
587                    events.push(Event::Completed { id, output });
588                }
589            }
590        }
591
592        Ok(())
593    }
594}
595
596/// Close the parent-side pipe ends that the child uses (stdin read, stdout write, stderr write).
597fn close_parent_pipe_ends(workspace: &Workspace) {
598    unsafe {
599        libc::close(workspace.pipes.stdin.read.as_raw_fd());
600        libc::close(workspace.pipes.stdout.write.as_raw_fd());
601        libc::close(workspace.pipes.stderr.write.as_raw_fd());
602    }
603}
604
605/// Poll an fd with a 30-second timeout; kill the child on timeout or error.
606fn poll_or_kill(fd: RawFd, child_pid: libc::pid_t, msg: &str) -> Result<(), ExecutorError> {
607    let mut pfd = libc::pollfd {
608        fd,
609        events: libc::POLLIN,
610        revents: 0,
611    };
612    // SAFETY: pollfd is valid, nfds=1, timeout in ms.
613    if unsafe { libc::poll(&mut pfd, 1, 30000) } <= 0 {
614        unsafe { libc::kill(child_pid, libc::SIGKILL) };
615        return Err(ExecutorError::ChildSetup(msg.into()));
616    }
617    Ok(())
618}
619
620/// Wait for the child to signal readiness via eventfd, then signal back.
621fn sync_with_child(workspace: &Workspace, child_pid: libc::pid_t) -> Result<(), ExecutorError> {
622    let child_ready_fd = workspace.pipes.sync.child_ready_fd();
623    poll_or_kill(child_ready_fd, child_pid, "timeout waiting for child")?;
624
625    let mut value: u64 = 0;
626    if unsafe { libc::read(child_ready_fd, (&mut value as *mut u64).cast(), 8) } != 8 {
627        unsafe { libc::kill(child_pid, libc::SIGKILL) };
628        return Err(ExecutorError::ChildSetup("eventfd read failed".into()));
629    }
630
631    let parent_done_fd = workspace.pipes.sync.parent_done_fd();
632    let signal_value: u64 = 1;
633    if unsafe { libc::write(parent_done_fd, (&signal_value as *const u64).cast(), 8) } != 8 {
634        unsafe { libc::kill(child_pid, libc::SIGKILL) };
635        return Err(ExecutorError::ChildSetup("eventfd write failed".into()));
636    }
637
638    Ok(())
639}
640
641fn spawn_sandbox(plan: Plan) -> Result<SpawnedSandbox, ExecutorError> {
642    let cmd_refs: Vec<&str> = plan.cmd.iter().map(|s| s.as_str()).collect();
643    validate_cmd(&cmd_refs).map_err(ExecutorError::Validation)?;
644
645    if let Err(e) = check::check() {
646        return Err(ExecutorError::SystemCheck(e.to_string()));
647    }
648
649    let exec_info = if let Some(info) = ExecutionInfo::from_plan(&plan) {
650        info
651    } else {
652        let resolved = resolve_binary(&plan.cmd[0])
653            .map_err(|e| ExecutorError::CommandNotFound(e.to_string()))?;
654        ExecutionInfo::from_resolved(resolved)
655    };
656
657    let workspace = Workspace::with_prefix("evalbox-").map_err(ExecutorError::Workspace)?;
658
659    workspace
660        .setup_sandbox_dirs()
661        .map_err(ExecutorError::Workspace)?;
662    for file in &plan.user_files {
663        let work_path = format!("work/{}", file.path);
664        workspace
665            .write_file(&work_path, &file.content, file.executable)
666            .map_err(ExecutorError::Workspace)?;
667    }
668
669    // Create socketpair for notify fd transfer (if needed)
670    let notify_sockets = if plan.notify_mode != NotifyMode::Disabled {
671        Some(scm_rights::create_socketpair().map_err(ExecutorError::Workspace)?)
672    } else {
673        None
674    };
675
676    // SAFETY: fork is safe, returns child pid in parent, 0 in child, or -1 on error.
677    let child_pid = unsafe { libc::fork() };
678    if child_pid < 0 {
679        return Err(ExecutorError::Fork(last_errno()));
680    }
681
682    if child_pid == 0 {
683        let child_socket = notify_sockets.map(|(_, child)| child);
684        match child_process(&workspace, &plan, &exec_info, child_socket.as_ref()) {
685            Ok(()) => unsafe { libc::_exit(127) },
686            Err(e) => {
687                writeln!(io::stderr(), "sandbox error: {e}").ok();
688                unsafe { libc::_exit(126) }
689            }
690        }
691    }
692
693    // SAFETY: See comment in Executor::run() about fork/pidfd_open race window.
694    let pid = unsafe { Pid::from_raw_unchecked(child_pid) };
695    let pidfd = pidfd_open(pid, PidfdFlags::empty()).map_err(ExecutorError::Pidfd)?;
696
697    let stdin_write_fd = workspace.pipes.stdin.write.as_raw_fd();
698    let stdout_read_fd = workspace.pipes.stdout.read.as_raw_fd();
699    let stderr_read_fd = workspace.pipes.stderr.read.as_raw_fd();
700
701    close_parent_pipe_ends(&workspace);
702
703    // Receive notify fd from child if applicable
704    let notify_fd = if let Some((parent_socket, _)) = notify_sockets {
705        poll_or_kill(
706            parent_socket.as_raw_fd(),
707            child_pid,
708            "timeout waiting for notify fd",
709        )?;
710        Some(
711            scm_rights::recv_fd(parent_socket.as_raw_fd())
712                .map_err(|e| ExecutorError::SeccompNotify(e.to_string()))?,
713        )
714    } else {
715        None
716    };
717
718    sync_with_child(&workspace, child_pid)?;
719
720    // Write stdin if provided
721    if let Some(ref stdin_data) = plan.stdin {
722        write_stdin(&workspace, stdin_data).map_err(ExecutorError::Monitor)?;
723        unsafe { libc::close(stdin_write_fd) };
724    }
725
726    // Set non-blocking for async reading
727    set_nonblocking(stdout_read_fd).map_err(ExecutorError::Monitor)?;
728    set_nonblocking(stderr_read_fd).map_err(ExecutorError::Monitor)?;
729
730    // Close sync fds
731    unsafe {
732        libc::close(workspace.pipes.sync.child_ready_fd());
733        libc::close(workspace.pipes.sync.parent_done_fd());
734    }
735
736    Ok(SpawnedSandbox {
737        pidfd,
738        stdin_fd: if plan.stdin.is_some() {
739            -1
740        } else {
741            stdin_write_fd
742        },
743        stdout_fd: stdout_read_fd,
744        stderr_fd: stderr_read_fd,
745        notify_fd,
746        workspace: std::mem::ManuallyDrop::new(workspace),
747    })
748}
749
750fn blocking_parent(
751    child_pid: libc::pid_t,
752    pidfd: OwnedFd,
753    _notify_fd: Option<OwnedFd>,
754    workspace: Workspace,
755    plan: Plan,
756) -> Result<Output, ExecutorError> {
757    // ManuallyDrop prevents OwnedFd double-close: we close pipe fds via libc::close
758    // at specific points (stdin.write before monitor for EOF, rest after monitor),
759    // and OwnedFd::Drop must not run again on those same fds.
760    let workspace = std::mem::ManuallyDrop::new(workspace);
761
762    close_parent_pipe_ends(&workspace);
763
764    sync_with_child(&workspace, child_pid)?;
765
766    if let Some(ref stdin_data) = plan.stdin {
767        write_stdin(&workspace, stdin_data).map_err(ExecutorError::Monitor)?;
768    }
769    // Close stdin write end to signal EOF to child
770    unsafe { libc::close(workspace.pipes.stdin.write.as_raw_fd()) };
771
772    let result = monitor(pidfd, &workspace, &plan).map_err(ExecutorError::Monitor);
773
774    unsafe {
775        libc::close(workspace.pipes.stdout.read.as_raw_fd());
776        libc::close(workspace.pipes.stderr.read.as_raw_fd());
777        libc::close(workspace.pipes.sync.child_ready_fd());
778        libc::close(workspace.pipes.sync.parent_done_fd());
779    }
780
781    // Clean up temp directory. We can't drop workspace normally because
782    // ManuallyDrop prevents it (intentionally, to avoid OwnedFd double-close).
783    let _ = std::fs::remove_dir_all(workspace.root());
784
785    result
786}
787
788/// Child process flow (runs after fork in the child).
789///
790/// 1. Close parent pipe ends
791/// 2. Setup stdio (dup2 stdin/stdout/stderr)
792/// 3. chdir(workspace/work)
793/// 4. Landlock v5 + rlimits + securebits + drop caps (lockdown)
794/// 5. If `notify_mode` != Disabled: install notify filter, send listener fd
795/// 6. Install kill seccomp filter (whitelist)
796/// 7. Signal parent readiness
797/// 8. Wait for parent signal
798/// 9. `close_range(3, MAX, 0)`
799/// 10. execve
800// Cast is safe: BPF filter length fits in u16 (max ~220 instructions).
801#[allow(clippy::cast_possible_truncation)]
802fn child_process(
803    workspace: &Workspace,
804    plan: &Plan,
805    exec_info: &ExecutionInfo,
806    notify_socket: Option<&OwnedFd>,
807) -> Result<(), ExecutorError> {
808    // 1. Close parent pipe ends
809    unsafe {
810        libc::close(workspace.pipes.stdin.write.as_raw_fd());
811        libc::close(workspace.pipes.stdout.read.as_raw_fd());
812        libc::close(workspace.pipes.stderr.read.as_raw_fd());
813    }
814
815    // 2. Setup stdio
816    setup_stdio(workspace)?;
817
818    // 3. chdir to workspace/work
819    let work_dir = workspace.root().join("work");
820    let work_cstr = CString::new(work_dir.to_string_lossy().as_bytes())
821        .map_err(|_| ExecutorError::Exec(Errno::INVAL))?;
822    if unsafe { libc::chdir(work_cstr.as_ptr()) } != 0 {
823        return Err(ExecutorError::Exec(last_errno()));
824    }
825
826    // 4. Apply lockdown (Landlock v5 + rlimits + securebits + drop caps)
827    let extra_paths: Vec<&str> = exec_info
828        .extra_mounts
829        .iter()
830        .filter_map(|m| m.source.to_str())
831        .collect();
832    lockdown(plan, workspace.root(), &extra_paths).map_err(ExecutorError::Lockdown)?;
833
834    // 5. If notify mode != Disabled: install notify seccomp filter, send listener fd
835    if plan.notify_mode != NotifyMode::Disabled {
836        let nfs = notify_fs_syscalls();
837        let notify_filter = build_notify_filter(&nfs);
838        let fprog = SockFprog {
839            len: notify_filter.len() as u16,
840            filter: notify_filter.as_ptr(),
841        };
842        let listener_fd = unsafe { seccomp_set_mode_filter_listener(&fprog) }.map_err(|e| {
843            ExecutorError::SeccompNotify(format!("failed to install notify filter: {e}"))
844        })?;
845
846        // Send listener fd to parent via SCM_RIGHTS
847        if let Some(sock) = notify_socket {
848            scm_rights::send_fd(sock.as_raw_fd(), listener_fd.as_raw_fd()).map_err(|e| {
849                ExecutorError::SeccompNotify(format!("failed to send listener fd: {e}"))
850            })?;
851        }
852    }
853
854    // 6. Install kill seccomp filter (whitelist)
855    apply_seccomp(plan)?;
856
857    // 7. Signal parent readiness
858    let child_ready_fd = workspace.pipes.sync.child_ready_fd();
859    let signal_value: u64 = 1;
860    if unsafe { libc::write(child_ready_fd, (&signal_value as *const u64).cast(), 8) } != 8 {
861        return Err(ExecutorError::ChildSetup("eventfd write failed".into()));
862    }
863
864    // 8. Wait for parent signal
865    let parent_done_fd = workspace.pipes.sync.parent_done_fd();
866    let mut value: u64 = 0;
867    if unsafe { libc::read(parent_done_fd, (&mut value as *mut u64).cast(), 8) } != 8 {
868        return Err(ExecutorError::ChildSetup("eventfd read failed".into()));
869    }
870
871    // 9. Close all fds except 0,1,2
872    close_extra_fds();
873
874    // 10. execve
875    exec_command(plan, exec_info)
876}
877
878fn setup_stdio(workspace: &Workspace) -> Result<(), ExecutorError> {
879    let stdin_fd = workspace.pipes.stdin.read.as_raw_fd();
880    let stdout_fd = workspace.pipes.stdout.write.as_raw_fd();
881    let stderr_fd = workspace.pipes.stderr.write.as_raw_fd();
882
883    // SAFETY: dup2 is safe with valid fds obtained from OwnedFd; close is always safe.
884    unsafe {
885        libc::close(0);
886        libc::close(1);
887        libc::close(2);
888        if libc::dup2(stdin_fd, 0) < 0 {
889            return Err(ExecutorError::Exec(last_errno()));
890        }
891        if libc::dup2(stdout_fd, 1) < 0 {
892            return Err(ExecutorError::Exec(last_errno()));
893        }
894        if libc::dup2(stderr_fd, 2) < 0 {
895            return Err(ExecutorError::Exec(last_errno()));
896        }
897    }
898    Ok(())
899}
900
901// Cast is safe: filter length fits in u16 (max whitelist is 200 + ~20 overhead).
902#[allow(clippy::cast_possible_truncation)]
903fn apply_seccomp(plan: &Plan) -> Result<(), ExecutorError> {
904    let base = default_whitelist();
905    let whitelist: Vec<i64> = if let Some(ref syscalls) = plan.syscalls {
906        let mut wl_set: HashSet<i64> = base.into_iter().collect();
907        for s in &syscalls.denied {
908            wl_set.remove(s);
909        }
910        for s in &syscalls.allowed {
911            wl_set.insert(*s);
912        }
913        wl_set.into_iter().collect()
914    } else {
915        base
916    };
917
918    let filter = build_whitelist_filter(&whitelist);
919    let fprog = SockFprog {
920        len: filter.len() as u16,
921        filter: filter.as_ptr(),
922    };
923    unsafe { seccomp_set_mode_filter(&fprog) }
924        .map_err(|e| ExecutorError::Lockdown(LockdownError::Seccomp(e)))?;
925    Ok(())
926}
927
928fn exec_command(plan: &Plan, exec_info: &ExecutionInfo) -> Result<(), ExecutorError> {
929    let cmd_path = CString::new(exec_info.binary_path.to_string_lossy().as_bytes())
930        .map_err(|_| ExecutorError::Exec(Errno::INVAL))?;
931
932    let mut argv: Vec<CString> = Vec::with_capacity(plan.cmd.len());
933    argv.push(cmd_path.clone());
934    for arg in plan.cmd.iter().skip(1) {
935        argv.push(CString::new(arg.as_bytes()).map_err(|_| ExecutorError::Exec(Errno::INVAL))?);
936    }
937
938    let argv_ptrs: Vec<*const libc::c_char> = argv
939        .iter()
940        .map(|s| s.as_ptr())
941        .chain(std::iter::once(std::ptr::null()))
942        .collect();
943
944    let envp: Vec<CString> = plan
945        .env
946        .iter()
947        .map(|(k, v)| CString::new(format!("{k}={v}")))
948        .collect::<Result<Vec<_>, _>>()
949        .map_err(|_| ExecutorError::Exec(Errno::INVAL))?;
950
951    let envp_ptrs: Vec<*const libc::c_char> = envp
952        .iter()
953        .map(|s| s.as_ptr())
954        .chain(std::iter::once(std::ptr::null()))
955        .collect();
956
957    unsafe { libc::execve(cmd_path.as_ptr(), argv_ptrs.as_ptr(), envp_ptrs.as_ptr()) };
958
959    Err(ExecutorError::Exec(last_errno()))
960}
961
962#[cfg(test)]
963mod tests {
964    use super::*;
965
966    #[test]
967    fn token_encoding() {
968        let token = encode_token(42, TOKEN_TYPE_STDOUT);
969        let (id, ty) = decode_token(token);
970        assert_eq!(id.0, 42);
971        assert_eq!(ty, TOKEN_TYPE_STDOUT);
972    }
973
974    #[test]
975    fn sandbox_id_display() {
976        let id = SandboxId(123);
977        assert_eq!(format!("{id}"), "Sandbox(123)");
978    }
979}