Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ jobs:
# ${{ matrix.distro }} \
# "$TEST_BIN" --ignored --test-threads=1

cargo-deny:
name: Cargo Deny
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: EmbarkStudios/cargo-deny-action@v2

semver-check:
name: SemVer Check
runs-on: ubuntu-latest
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,9 @@
target/
bindings/

# Fuzz corpus (generated by cargo-fuzz)
fuzz/corpus/
fuzz/artifacts/

# Local cargo config
.cargo/
75 changes: 62 additions & 13 deletions crates/evalbox-sandbox/src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
//! }
//! ```

use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::ffi::CString;
use std::io::{self, Write as _};
use std::os::fd::{AsRawFd, OwnedFd, RawFd};
Expand All @@ -49,7 +49,7 @@ use rustix::process::{Pid, PidfdFlags, Signal, pidfd_open, pidfd_send_signal};
use thiserror::Error;

use evalbox_sys::seccomp::{
DEFAULT_WHITELIST, NOTIFY_FS_SYSCALLS, SockFprog, build_notify_filter, build_whitelist_filter,
SockFprog, build_notify_filter, build_whitelist_filter, default_whitelist, notify_fs_syscalls,
};
use evalbox_sys::seccomp_notify::seccomp_set_mode_filter_listener;
use evalbox_sys::{check, last_errno, seccomp::seccomp_set_mode_filter};
Expand Down Expand Up @@ -165,6 +165,27 @@ struct SpawnedSandbox {
workspace: std::mem::ManuallyDrop<Workspace>,
}

impl Drop for SpawnedSandbox {
fn drop(&mut self) {
// Close remaining pipe fds. Some may already be closed by
// close_parent_pipe_ends or the event loop (EBADF is harmless).
unsafe {
if self.stdin_fd >= 0 {
libc::close(self.stdin_fd);
}
if self.stdout_fd >= 0 {
libc::close(self.stdout_fd);
}
if self.stderr_fd >= 0 {
libc::close(self.stderr_fd);
}
}
// Clean up temp directory without dropping workspace (which would
// trigger OwnedFd double-close for fds already closed by libc::close).
let _ = std::fs::remove_dir_all(self.workspace.root());
}
}

/// Internal state for a running sandbox.
struct SandboxState {
spawned: SpawnedSandbox,
Expand Down Expand Up @@ -253,6 +274,7 @@ impl Executor {
None
};

// SAFETY: fork is safe, returns child pid in parent, 0 in child, or -1 on error.
let child_pid = unsafe { libc::fork() };
if child_pid < 0 {
return Err(ExecutorError::Fork(last_errno()));
Expand All @@ -270,6 +292,11 @@ impl Executor {
}
}

// SAFETY: child_pid is a valid, just-forked PID. There is a theoretical race
// between fork() and pidfd_open() if PIDs wrap around in microseconds, but this
// is extremely unlikely. The ideal fix is clone3(CLONE_PIDFD) which atomically
// returns a pidfd, but is complex to implement with the current fork-based
// architecture. Acceptable for v0.1.x.
let pid = unsafe { Pid::from_raw_unchecked(child_pid) };
let pidfd = pidfd_open(pid, PidfdFlags::empty()).map_err(ExecutorError::Pidfd)?;

Expand Down Expand Up @@ -395,6 +422,8 @@ impl Executor {
}

/// Write data to a sandbox's stdin.
// Cast is safe: libc::write returns bytes written which fits in usize on 64-bit.
#[allow(clippy::cast_sign_loss)]
pub fn write_stdin(&mut self, id: SandboxId, data: &[u8]) -> io::Result<usize> {
if let Some(state) = self.sandboxes.get(&id) {
let fd = state.spawned.stdin_fd;
Expand Down Expand Up @@ -435,6 +464,9 @@ impl Executor {
}
}

// Cast is safe: libc::read returns bytes read (positive) which fits in usize;
// max_output fits in usize on 64-bit.
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
fn read_pipe(&mut self, sandbox_id: SandboxId, is_stdout: bool, events: &mut Vec<Event>) {
let Some(state) = self.sandboxes.get_mut(&sandbox_id) else {
return;
Expand Down Expand Up @@ -495,6 +527,8 @@ impl Executor {
}
}

// Cast is safe: max_output fits in usize on 64-bit.
#[allow(clippy::cast_possible_truncation)]
fn check_completions(&mut self, events: &mut Vec<Event>) -> io::Result<()> {
let now = Instant::now();
let mut to_remove = Vec::new();
Expand Down Expand Up @@ -575,6 +609,7 @@ fn poll_or_kill(fd: RawFd, child_pid: libc::pid_t, msg: &str) -> Result<(), Exec
events: libc::POLLIN,
revents: 0,
};
// SAFETY: pollfd is valid, nfds=1, timeout in ms.
if unsafe { libc::poll(&mut pfd, 1, 30000) } <= 0 {
unsafe { libc::kill(child_pid, libc::SIGKILL) };
return Err(ExecutorError::ChildSetup(msg.into()));
Expand Down Expand Up @@ -638,6 +673,7 @@ fn spawn_sandbox(plan: Plan) -> Result<SpawnedSandbox, ExecutorError> {
None
};

// SAFETY: fork is safe, returns child pid in parent, 0 in child, or -1 on error.
let child_pid = unsafe { libc::fork() };
if child_pid < 0 {
return Err(ExecutorError::Fork(last_errno()));
Expand All @@ -654,6 +690,7 @@ fn spawn_sandbox(plan: Plan) -> Result<SpawnedSandbox, ExecutorError> {
}
}

// SAFETY: See comment in Executor::run() about fork/pidfd_open race window.
let pid = unsafe { Pid::from_raw_unchecked(child_pid) };
let pidfd = pidfd_open(pid, PidfdFlags::empty()).map_err(ExecutorError::Pidfd)?;

Expand Down Expand Up @@ -717,6 +754,9 @@ fn blocking_parent(
workspace: Workspace,
plan: Plan,
) -> Result<Output, ExecutorError> {
// ManuallyDrop prevents OwnedFd double-close: we close pipe fds via libc::close
// at specific points (stdin.write before monitor for EOF, rest after monitor),
// and OwnedFd::Drop must not run again on those same fds.
let workspace = std::mem::ManuallyDrop::new(workspace);

close_parent_pipe_ends(&workspace);
Expand All @@ -726,6 +766,7 @@ fn blocking_parent(
if let Some(ref stdin_data) = plan.stdin {
write_stdin(&workspace, stdin_data).map_err(ExecutorError::Monitor)?;
}
// Close stdin write end to signal EOF to child
unsafe { libc::close(workspace.pipes.stdin.write.as_raw_fd()) };

let result = monitor(pidfd, &workspace, &plan).map_err(ExecutorError::Monitor);
Expand All @@ -737,6 +778,10 @@ fn blocking_parent(
libc::close(workspace.pipes.sync.parent_done_fd());
}

// Clean up temp directory. We can't drop workspace normally because
// ManuallyDrop prevents it (intentionally, to avoid OwnedFd double-close).
let _ = std::fs::remove_dir_all(workspace.root());

result
}

Expand All @@ -752,6 +797,8 @@ fn blocking_parent(
/// 8. Wait for parent signal
/// 9. `close_range(3, MAX, 0)`
/// 10. execve
// Cast is safe: BPF filter length fits in u16 (max ~220 instructions).
#[allow(clippy::cast_possible_truncation)]
fn child_process(
workspace: &Workspace,
plan: &Plan,
Expand Down Expand Up @@ -786,7 +833,8 @@ fn child_process(

// 5. If notify mode != Disabled: install notify seccomp filter, send listener fd
if plan.notify_mode != NotifyMode::Disabled {
let notify_filter = build_notify_filter(NOTIFY_FS_SYSCALLS);
let nfs = notify_fs_syscalls();
let notify_filter = build_notify_filter(&nfs);
let fprog = SockFprog {
len: notify_filter.len() as u16,
filter: notify_filter.as_ptr(),
Expand Down Expand Up @@ -832,6 +880,7 @@ fn setup_stdio(workspace: &Workspace) -> Result<(), ExecutorError> {
let stdout_fd = workspace.pipes.stdout.write.as_raw_fd();
let stderr_fd = workspace.pipes.stderr.write.as_raw_fd();

// SAFETY: dup2 is safe with valid fds obtained from OwnedFd; close is always safe.
unsafe {
libc::close(0);
libc::close(1);
Expand All @@ -849,21 +898,21 @@ fn setup_stdio(workspace: &Workspace) -> Result<(), ExecutorError> {
Ok(())
}

// Cast is safe: filter length fits in u16 (max whitelist is 200 + ~20 overhead).
#[allow(clippy::cast_possible_truncation)]
fn apply_seccomp(plan: &Plan) -> Result<(), ExecutorError> {
let base = default_whitelist();
let whitelist: Vec<i64> = if let Some(ref syscalls) = plan.syscalls {
let mut wl: Vec<i64> = DEFAULT_WHITELIST
.iter()
.copied()
.filter(|s| !syscalls.denied.contains(s))
.collect();
let mut wl_set: HashSet<i64> = base.into_iter().collect();
for s in &syscalls.denied {
wl_set.remove(s);
}
for s in &syscalls.allowed {
if !wl.contains(s) {
wl.push(*s);
}
wl_set.insert(*s);
}
wl
wl_set.into_iter().collect()
} else {
DEFAULT_WHITELIST.to_vec()
base
};

let filter = build_whitelist_filter(&whitelist);
Expand Down
2 changes: 2 additions & 0 deletions crates/evalbox-sandbox/src/isolation/lockdown.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,8 @@ fn set_no_new_privs() -> Result<(), LockdownError> {
}

fn drop_all_caps() -> Result<(), LockdownError> {
// SAFETY: prctl with PR_CAP_AMBIENT_CLEAR_ALL and PR_CAPBSET_DROP is safe
// for any capability value 0..63. These calls only affect the current process.
unsafe {
libc::prctl(
libc::PR_CAP_AMBIENT,
Expand Down
3 changes: 0 additions & 3 deletions crates/evalbox-sandbox/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,6 @@
//! - Linux kernel 6.12+ (for Landlock ABI 5)
//! - Seccomp enabled in kernel

#![allow(clippy::cast_possible_truncation)]
#![allow(clippy::cast_sign_loss)]

pub mod executor;
pub mod isolation;
pub mod monitor;
Expand Down
20 changes: 16 additions & 4 deletions crates/evalbox-sandbox/src/monitor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
//! - `CLD_EXITED` - Normal exit with exit code
//! - `CLD_KILLED` / `CLD_DUMPED` - Killed by signal

use std::borrow::Cow;
use std::io;
use std::os::fd::{AsRawFd, OwnedFd, RawFd};
use std::time::{Duration, Instant};
Expand All @@ -30,6 +31,7 @@ use crate::plan::Plan;
use crate::workspace::Workspace;

/// Output from a sandboxed execution.
#[must_use]
#[derive(Debug, Clone)]
pub struct Output {
pub stdout: Vec<u8>,
Expand All @@ -47,13 +49,13 @@ impl Output {
}

#[inline]
pub fn stdout_str(&self) -> String {
String::from_utf8_lossy(&self.stdout).into_owned()
pub fn stdout_str(&self) -> Cow<'_, str> {
String::from_utf8_lossy(&self.stdout)
}

#[inline]
pub fn stderr_str(&self) -> String {
String::from_utf8_lossy(&self.stderr).into_owned()
pub fn stderr_str(&self) -> Cow<'_, str> {
String::from_utf8_lossy(&self.stderr)
}
}

Expand All @@ -67,6 +69,8 @@ pub enum Status {
}

/// Monitor the child process and collect output.
// Casts are safe: poll timeout capped at 100ms fits i32; max_output fits usize on 64-bit.
#[allow(clippy::cast_possible_truncation)]
pub fn monitor(pidfd: OwnedFd, workspace: &Workspace, plan: &Plan) -> io::Result<Output> {
let start = Instant::now();
let deadline = start + plan.timeout;
Expand Down Expand Up @@ -187,6 +191,8 @@ pub fn monitor(pidfd: OwnedFd, workspace: &Workspace, plan: &Plan) -> io::Result
}

/// Write stdin data to the child process.
// Cast is safe: libc::write returns bytes written which fits in usize on 64-bit.
#[allow(clippy::cast_sign_loss)]
pub fn write_stdin(workspace: &Workspace, data: &[u8]) -> io::Result<()> {
let fd = workspace.pipes.stdin.write.as_raw_fd();
let mut written = 0;
Expand Down Expand Up @@ -220,6 +226,8 @@ pub(crate) fn set_nonblocking(fd: RawFd) -> io::Result<()> {
}
}

// Cast is safe: libc::read returns bytes read which fits in usize on 64-bit.
#[allow(clippy::cast_sign_loss)]
#[inline]
fn read_nonblocking(fd: RawFd, buf: &mut [u8]) -> io::Result<usize> {
let ret = unsafe { libc::read(fd, buf.as_mut_ptr().cast::<libc::c_void>(), buf.len()) };
Expand All @@ -230,6 +238,8 @@ fn read_nonblocking(fd: RawFd, buf: &mut [u8]) -> io::Result<usize> {
}
}

// Cast is safe: max_output fits in usize on 64-bit.
#[allow(clippy::cast_possible_truncation)]
fn drain_remaining(fd: RawFd, output: &mut Vec<u8>, buf: &mut [u8], max_output: u64) {
let max = max_output as usize;
loop {
Expand All @@ -249,6 +259,8 @@ fn drain_remaining(fd: RawFd, output: &mut Vec<u8>, buf: &mut [u8], max_output:
}
}

// Cast is safe: pidfd (small fd number) fits in libc::id_t (u32).
#[allow(clippy::cast_sign_loss)]
pub(crate) fn wait_for_exit(pidfd: RawFd) -> io::Result<(Option<i32>, Option<i32>)> {
let mut siginfo: libc::siginfo_t = unsafe { std::mem::zeroed() };
let ret = unsafe {
Expand Down
4 changes: 4 additions & 0 deletions crates/evalbox-sandbox/src/notify/scm_rights.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ pub fn create_socketpair() -> io::Result<(OwnedFd, OwnedFd)> {
}

/// Send a file descriptor over a unix socket using `SCM_RIGHTS`.
// Casts are safe: size_of::<RawFd>() is 4 which fits u32; CMSG_SPACE returns small values fitting usize.
#[allow(clippy::cast_possible_truncation)]
pub fn send_fd(socket: RawFd, fd: RawFd) -> io::Result<()> {
let data = [0u8; 1];
let iov = libc::iovec {
Expand Down Expand Up @@ -72,6 +74,8 @@ pub fn send_fd(socket: RawFd, fd: RawFd) -> io::Result<()> {
}

/// Receive a file descriptor from a unix socket using `SCM_RIGHTS`.
// Casts are safe: size_of::<RawFd>() is 4 which fits u32; CMSG_SPACE returns small values fitting usize.
#[allow(clippy::cast_possible_truncation)]
pub fn recv_fd(socket: RawFd) -> io::Result<OwnedFd> {
let mut data = [0u8; 1];
let mut iov = libc::iovec {
Expand Down
Loading
Loading