From fde8d83e2bde7b522f83642832790772f848529c Mon Sep 17 00:00:00 2001 From: dzerik Date: Mon, 29 Jun 2026 00:37:42 +0300 Subject: [PATCH] =?UTF-8?q?feat(core):=20streaming-stdio=20popen=20?= =?UTF-8?q?=E2=86=92=20Process=20with=20per-stream=20StdioMode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Sandbox::popen, the first piece of the streaming-stdio process API (RFC #67). It generalizes the do_create capture/inherit boolean into a per-stream StdioMode { Inherit, Piped, Null } and returns a live Process whose Piped stream ends the caller owns (take_stdin/stdout/stderr), so a confined process can be driven over stdio while alive (MCP/LSP/REPL/ JSON-RPC) — which capture-mode run cannot express. - StdioMode is #[repr(u32)] with pinned discriminants so the FFI/Python bindings (later PRs) bind a stable C enum. - do_create is now a thin wrapper over do_create_stdio, so run/spawn/create and their interactive variants keep byte-identical capture/inherit behavior. - Child-side fd wiring is collision-safe. The post-fork path must survive a supervisor started with 0/1/2 closed (common for daemons): pipe2 can then allocate a pipe end onto a std fd, so a naive dup2 would alias the target, let a sibling stream clobber an end still needed, or act on a stale raw-fd snapshot. Each Piped source is first relocated to a fresh high fd (>= 3) via F_DUPFD_CLOEXEC, then dup2'd down onto its 0/1/2 target through one audited helper; the original O_CLOEXEC ends are mem::forget'd (they close at execve) so their Drop can't close a fd we reassigned. dup2 failure fails closed. - Null wires the stream to /dev/null and fails closed: if it cannot be opened the fd is closed, never left inherited from the supervisor. - Piped stdin adds a parent-held write end; wait() drops an untaken one so a child reading stdin still reaches EOF instead of hanging. A *taken* stdin the caller must close before wait() (documented; same contract as std). - popen does not poll for execve (a streaming reader blocks until bytes arrive, and the poll would spuriously time out on a fast-exiting child). - Process borrows the Sandbox (it does not own the process like std::process::Child); the child is killed and reaped by Sandbox::drop or Process::kill (whole process group). #[must_use] flags an abandoned handle. Folding the legacy spawn/wait into Process is deferred to a later phase. Tests (multi-threaded runtime — a blocking pipe read must not starve the seccomp supervisor, documented on popen): stdin+stdout roundtrip, stdout-only, stderr-only, all-three piped, Null stdout proven to be /dev/null, Null stdin EOF, untaken-stdin EOF, explicit kill + non-success status, whole-group kill-on-drop reaching a forked grandchild (polls for ESRCH to avoid racing zombie reaping), second-popen-on-used-Sandbox rejected, and a regression that capture-mode run still buffers stdout. Refs #67 --- crates/sandlock-core/src/lib.rs | 2 +- crates/sandlock-core/src/sandbox.rs | 329 ++++++++++++++++-- crates/sandlock-core/src/sandbox/tests.rs | 30 ++ crates/sandlock-core/tests/integration.rs | 3 + .../tests/integration/test_popen.rs | 234 +++++++++++++ 5 files changed, 564 insertions(+), 34 deletions(-) create mode 100644 crates/sandlock-core/tests/integration/test_popen.rs diff --git a/crates/sandlock-core/src/lib.rs b/crates/sandlock-core/src/lib.rs index a0ac86c1..b4330768 100644 --- a/crates/sandlock-core/src/lib.rs +++ b/crates/sandlock-core/src/lib.rs @@ -35,7 +35,7 @@ pub use error::SandlockError; pub use sys::structs::{SeccompData, SeccompNotif}; pub use checkpoint::Checkpoint; pub use protection::{Protection, ProtectionState, ProtectionPolicy, ProtectionStatus}; -pub use sandbox::{Confinement, ConfinementBuilder, Sandbox, SandboxBuilder}; +pub use sandbox::{Confinement, ConfinementBuilder, Process, Sandbox, SandboxBuilder, StdioMode}; pub use result::{RunResult, ExitStatus}; pub use pipeline::{Stage, Pipeline, Gather}; pub use dry_run::{Change, ChangeKind, DryRunResult}; diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index 2439bb65..ca99f9f5 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -201,6 +201,47 @@ pub enum BranchAction { // Runtime — private heap-allocated state, present only while running // ============================================================ +/// How one of a child's standard streams (stdin/stdout/stderr) is wired. +/// +/// `Inherit` shares the supervisor's own fd (the child writes to the same +/// terminal/file the parent has). `Piped` creates a pipe whose caller-side end +/// is handed out via [`Process`] so the caller can stream to/from the live +/// process. `Null` connects the stream to `/dev/null`. +/// +/// The discriminants are a stable contract: the FFI/Python bindings pass them +/// as a `u32`, so they are pinned with `#[repr(u32)]`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u32)] +pub enum StdioMode { + /// Inherit the supervisor's corresponding fd. + Inherit = 0, + /// Connect to a pipe; the caller owns the other end (see [`Process`]). + Piped = 1, + /// Connect to `/dev/null`. + Null = 2, +} + +/// Per-stream stdio wiring for a child process. +#[derive(Debug, Clone, Copy)] +struct StdioSpec { + stdin: StdioMode, + stdout: StdioMode, + stderr: StdioMode, +} + +impl StdioSpec { + /// Capture mode used by `run`/`spawn`: stdin inherited, stdout/stderr piped + /// and drained into the `RunResult` by `wait`. + fn capture() -> Self { + StdioSpec { stdin: StdioMode::Inherit, stdout: StdioMode::Piped, stderr: StdioMode::Piped } + } + + /// Interactive mode: every stream inherits the supervisor's fd. + fn inherit() -> Self { + StdioSpec { stdin: StdioMode::Inherit, stdout: StdioMode::Inherit, stderr: StdioMode::Inherit } + } +} + /// Private runtime state. Only allocated after `start()` / `run()` is /// called; `None` for config-only `Sandbox` instances. struct Runtime { @@ -213,6 +254,9 @@ struct Runtime { loadavg_handle: Option>, _stdout_read: Option, _stderr_read: Option, + // Parent-held write end of a piped stdin (popen). The caller takes it via + // `Process::take_stdin`; closing it signals EOF to the child. + _stdin_write: Option, seccomp_cow: Option, supervisor_resource: Option>>, supervisor_cow: Option>>, @@ -660,6 +704,11 @@ impl Sandbox { }); } + // Deliver EOF to a piped stdin the caller never took: otherwise a child + // that reads stdin (e.g. `cat`) blocks forever and this wait never + // returns. A taken stdin is already None here (the caller owns it). + drop(self.rt_mut()._stdin_write.take()); + // Wait for the top-level child to exit. Prefer the child's pidfd via // `AsyncFd`: pidfd readiness fires only on *exit*, so — unlike a // `waitpid` loop — it never consumes the child's ptrace-stops, which @@ -730,6 +779,39 @@ impl Sandbox { self.wait_until_exec().await } + /// Spawn `cmd` with per-stream stdio wiring and return a live [`Process`]. + /// + /// Unlike `run` (which buffers stdout/stderr into a `RunResult` only after + /// the process exits), `popen` hands the caller the pipe end of every + /// [`StdioMode::Piped`] stream so it can drive the process's stdio while it + /// is alive — MCP/LSP servers, REPLs, any request/response protocol over + /// stdio. The child is released to `execve` before this returns and runs + /// under the full confinement. It is owned by this `Sandbox`: dropping the + /// `Sandbox` (or calling [`Sandbox::kill`] / [`Process::kill`]) sends SIGKILL + /// to its process group and reaps it. + /// + /// Note: the seccomp-notify supervisor runs as a task on the async runtime, + /// and a confined child only makes progress while that supervisor is pumped. + /// Do not block the runtime's executor on a piped stream — read/write the + /// `Process` fds from a separate thread (or async IO), and run on a + /// multi-threaded runtime. A blocking pipe read on a single-threaded runtime + /// starves the supervisor and deadlocks the child. + pub async fn popen( + &mut self, + cmd: &[&str], + stdin: StdioMode, + stdout: StdioMode, + stderr: StdioMode, + ) -> Result, crate::error::SandlockError> { + self.do_create_stdio(cmd, StdioSpec { stdin, stdout, stderr }).await?; + // No wait_until_exec here: a streaming caller does not need the child to + // have reached user code (a reader naturally blocks until bytes arrive), + // and the exec poll would spuriously time out on a process that exits + // before it is observed. `start` releases the child to execve. + self.start()?; + Ok(Process { sandbox: self }) + } + /// Restore a checkpoint into a fresh, fully-sandboxed process. /// /// Reuses the normal create path to fork a child with the saved policy and the @@ -1161,6 +1243,7 @@ impl Sandbox { loadavg_handle: None, _stdout_read: None, _stderr_read: None, + _stdin_write: None, seccomp_cow: None, supervisor_resource: None, supervisor_cow: None, @@ -1254,6 +1337,7 @@ impl Sandbox { loadavg_handle: None, _stdout_read: None, _stderr_read: None, + _stdin_write: None, seccomp_cow: None, supervisor_resource: None, supervisor_cow: None, @@ -1296,7 +1380,14 @@ impl Sandbox { // ready_r read, awaiting do_start to release it to execve). // ================================================================ + /// Thin compatibility wrapper: `capture` selects between the capture stdio + /// spec (stdin inherited, stdout/stderr piped-and-drained) and full inherit. async fn do_create(&mut self, cmd: &[&str], capture: bool) -> Result<(), crate::error::SandlockError> { + let stdio = if capture { StdioSpec::capture() } else { StdioSpec::inherit() }; + self.do_create_stdio(cmd, stdio).await + } + + async fn do_create_stdio(&mut self, cmd: &[&str], stdio: StdioSpec) -> Result<(), crate::error::SandlockError> { use std::ffi::CString; use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; use crate::error::SandboxRuntimeError; @@ -1437,31 +1528,25 @@ impl Sandbox { &handler_syscalls, ); - let (stdout_r, stderr_r) = if capture { - let mut stdout_fds = [0i32; 2]; - let mut stderr_fds = [0i32; 2]; - if unsafe { libc::pipe2(stdout_fds.as_mut_ptr(), libc::O_CLOEXEC) } < 0 { - return Err(SandboxRuntimeError::Io(std::io::Error::last_os_error()).into()); - } - if unsafe { libc::pipe2(stderr_fds.as_mut_ptr(), libc::O_CLOEXEC) } < 0 { - unsafe { - libc::close(stdout_fds[0]); - libc::close(stdout_fds[1]); - } - return Err(SandboxRuntimeError::Io(std::io::Error::last_os_error()).into()); - } - ( - Some(( - unsafe { OwnedFd::from_raw_fd(stdout_fds[0]) }, - unsafe { OwnedFd::from_raw_fd(stdout_fds[1]) }, - )), - Some(( - unsafe { OwnedFd::from_raw_fd(stderr_fds[0]) }, - unsafe { OwnedFd::from_raw_fd(stderr_fds[1]) }, - )), - ) + // Per-stream stdio wiring. Each Piped stream gets a CLOEXEC pipe whose + // parent-side end we keep: the caller writes the child's stdin and reads + // its stdout/stderr (see `popen` / `Process`). `pipe2` returns + // (read=fds[0], write=fds[1]); for stdin the child reads, so the parent + // keeps the write end, and vice-versa for stdout/stderr. + let stdin_p = if stdio.stdin == StdioMode::Piped { + Some(make_cloexec_pipe().map_err(SandboxRuntimeError::Io)?) } else { - (None, None) + None + }; + let stdout_p = if stdio.stdout == StdioMode::Piped { + Some(make_cloexec_pipe().map_err(SandboxRuntimeError::Io)?) + } else { + None + }; + let stderr_p = if stdio.stderr == StdioMode::Piped { + Some(make_cloexec_pipe().map_err(SandboxRuntimeError::Io)?) + } else { + None }; // Capture our PID before fork so the child can detect parent death @@ -1487,14 +1572,41 @@ impl Sandbox { unsafe { libc::dup2(source_fd, target_fd) }; } - if let Some((_, ref stdout_w)) = stdout_r { - unsafe { libc::dup2(stdout_w.as_raw_fd(), 1) }; - } - if let Some((_, ref stderr_w)) = stderr_r { - unsafe { libc::dup2(stderr_w.as_raw_fd(), 2) }; + // Wire stdin/stdout/stderr per their modes. This is a post-fork path + // with a real class of fd hazards: when the supervisor was started + // with a std fd (0/1/2) closed, `pipe2` can allocate a pipe end onto + // that very fd, so a naive `dup2(end, std)` either aliases the target + // or a sibling stream's later dup2 clobbers an end still needed, and a + // raw-fd snapshot taken before the dup2s goes stale. To make wiring + // order-independent, first relocate each Piped source to a fresh high + // fd (>= 3, disjoint from the 0/1/2 targets), then dup2 it down. + // + // The original OwnedFd ends are O_CLOEXEC, so they close on their own + // at execve; `mem::forget` them so their Drop cannot close a fd number + // we have since reassigned to 0/1/2. + let safe_in = if stdio.stdin == StdioMode::Piped { + stdin_p.as_ref().map(|(r, _)| unsafe { relocate_high(r.as_raw_fd()) }) + } else { + None + }; + let safe_out = if stdio.stdout == StdioMode::Piped { + stdout_p.as_ref().map(|(_, w)| unsafe { relocate_high(w.as_raw_fd()) }) + } else { + None + }; + let safe_err = if stdio.stderr == StdioMode::Piped { + stderr_p.as_ref().map(|(_, w)| unsafe { relocate_high(w.as_raw_fd()) }) + } else { + None + }; + std::mem::forget(stdin_p); + std::mem::forget(stdout_p); + std::mem::forget(stderr_p); + unsafe { + wire_child_stdio(stdio.stdin, 0, safe_in, libc::O_RDONLY); + wire_child_stdio(stdio.stdout, 1, safe_out, libc::O_WRONLY); + wire_child_stdio(stdio.stderr, 2, safe_err, libc::O_WRONLY); } - drop(stdout_r); - drop(stderr_r); let gather_keep_fds: Vec = extra_fds_copy.iter().map(|&(target, _)| target).collect(); @@ -1526,8 +1638,9 @@ impl Sandbox { drop(pipes.notif_w); drop(pipes.ready_r); - self.rt_mut()._stdout_read = stdout_r.map(|(r, _w)| r); - self.rt_mut()._stderr_read = stderr_r.map(|(r, _w)| r); + self.rt_mut()._stdin_write = stdin_p.map(|(_r, w)| w); + self.rt_mut()._stdout_read = stdout_p.map(|(r, _w)| r); + self.rt_mut()._stderr_read = stderr_p.map(|(r, _w)| r); self.rt_mut().child_pid = Some(pid); // State remains `Created` until `do_start` writes ready_w to release @@ -1805,6 +1918,76 @@ impl Sandbox { } } +// ================================================================ +// ================================================================ +// Process — a live process with caller-owned stdio (popen) +// ================================================================ + +/// A live sandboxed process with caller-owned stdio streams, returned by +/// [`Sandbox::popen`]. +/// +/// `take_stdin` / `take_stdout` / `take_stderr` move out the pipe end of each +/// stream opened with [`StdioMode::Piped`] (each available once); the caller +/// reads/writes those while the process runs. Unlike `std::process::Child`, +/// this *borrows* the originating [`Sandbox`] rather than owning the process: +/// the process is killed and reaped when that `Sandbox` is dropped, or eagerly +/// via [`Process::kill`]. +/// +/// A `Process` that is dropped without [`Process::wait`] leaves the child +/// running until the `Sandbox` is dropped — call `wait` (or `kill`) to end it. +#[must_use = "a Process is a live confined child; call wait() (or kill()) or it runs until the Sandbox is dropped"] +pub struct Process<'a> { + sandbox: &'a mut Sandbox, +} + +impl Process<'_> { + /// Take the write end of a `Piped` stdin. The caller writes the child's + /// input; closing this fd signals EOF. `None` if stdin was not piped or was + /// already taken. + /// + /// Deadlock warning (as with `std::process::Child`): if you take stdin you + /// own it — drop/close it before [`Process::wait`], or a child that reads to + /// EOF (e.g. `cat`) never exits and `wait` blocks forever. (An *untaken* + /// piped stdin is closed by `wait` for you.) + pub fn take_stdin(&mut self) -> Option { + self.sandbox.rt_mut()._stdin_write.take() + } + + /// Take the read end of a `Piped` stdout. `None` if stdout was not piped or + /// was already taken. + pub fn take_stdout(&mut self) -> Option { + self.sandbox.rt_mut()._stdout_read.take() + } + + /// Take the read end of a `Piped` stderr. `None` if stderr was not piped or + /// was already taken. + pub fn take_stderr(&mut self) -> Option { + self.sandbox.rt_mut()._stderr_read.take() + } + + /// The child PID, or `None` if not spawned. Remains `Some` after the child + /// exits (until the `Sandbox` is dropped). + pub fn pid(&self) -> Option { + self.sandbox.pid() + } + + /// Send SIGKILL to the child's *entire process group* (every process the + /// workload spawned, not just the top-level child). Idempotent — a process + /// that already exited is not an error. + pub fn kill(&mut self) -> Result<(), crate::error::SandlockError> { + self.sandbox.kill() + } + + /// Wait for the child to exit. Any `Piped` stdout/stderr the caller did not + /// take is drained into the returned `RunResult`; taken streams are `None` + /// because the caller owns them. An untaken piped stdin is closed here so the + /// child sees EOF; a *taken* stdin the caller must close itself first (see + /// [`Process::take_stdin`]) or this blocks forever. + pub async fn wait(self) -> Result { + self.sandbox.wait().await + } +} + // ================================================================ // Drop for Sandbox — kills and reaps child if still running // ================================================================ @@ -1903,6 +2086,86 @@ fn sandbox_read_exact(fd: i32, buf: &mut [u8]) { } } +/// Create a `O_CLOEXEC` pipe, returning `(read_end, write_end)` as owned fds. +/// `pipe2` yields `fds[0]` = read, `fds[1]` = write. +fn make_cloexec_pipe() -> Result<(std::os::fd::OwnedFd, std::os::fd::OwnedFd), std::io::Error> { + use std::os::fd::{FromRawFd, OwnedFd}; + let mut fds = [0i32; 2]; + if unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC) } < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(unsafe { (OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])) }) +} + +/// Duplicate `src` to a fresh fd `>= 3` with `O_CLOEXEC`, in the forked child. +/// Used to move a pipe end out of the 0/1/2 target range before wiring so that +/// `dup2(_, std)` can never alias the target or clobber a sibling stream's end. +/// Best-effort: returns the original `src` if the dup fails (caller still wires +/// it; a failure here only degrades to the legacy hazard, never a leak). +/// +/// # Safety +/// Must run in the forked child; `src` must be a valid fd. +unsafe fn relocate_high(src: i32) -> i32 { + let hi = libc::fcntl(src, libc::F_DUPFD_CLOEXEC, 3); + if hi >= 0 { + hi + } else { + src + } +} + +/// Wire one of the child's std fds (`target` = 0/1/2) according to `mode`, in +/// the forked child just before execve. Single audited path for all three +/// streams (async-signal-safe: only open/dup2/close/fcntl). +/// +/// For `Piped`, `pipe_src` is a relocated high fd (see `relocate_high`), so +/// `src != target` in the normal case and `dup2` clears `O_CLOEXEC` on the +/// target (it survives execve); the relocated copy is then closed. The +/// `src == target` arm is only reached if relocation failed (fd exhaustion). +/// +/// # Safety +/// Must run in the forked child before execve; `pipe_src` (if any) must be a +/// valid fd. `devnull_flags` is `O_RDONLY` for stdin, `O_WRONLY` for stdout/err. +unsafe fn wire_child_stdio(mode: StdioMode, target: i32, pipe_src: Option, devnull_flags: i32) { + match mode { + StdioMode::Inherit => {} + StdioMode::Piped => { + if let Some(src) = pipe_src { + if src == target { + // Relocation failed and the end sits on `target`; dup2 would + // no-op and leave O_CLOEXEC set, so clear it so the fd + // survives execve. Do not close it — it *is* the target. + let flags = libc::fcntl(target, libc::F_GETFD); + if flags >= 0 { + libc::fcntl(target, libc::F_SETFD, flags & !libc::FD_CLOEXEC); + } + } else if libc::dup2(src, target) < 0 { + // Fail closed (mirror Null): never leave the supervisor's fd. + libc::close(target); + libc::close(src); + } else { + libc::close(src); + } + } + } + StdioMode::Null => { + // Opened without O_CLOEXEC so it survives execve. + let fd = libc::open(b"/dev/null\0".as_ptr() as *const libc::c_char, devnull_flags); + if fd < 0 { + // Fail closed: workload gets EBADF, never the supervisor's fd. + libc::close(target); + } else if fd == target { + // /dev/null landed on the (previously closed) target — done. + } else if libc::dup2(fd, target) < 0 { + libc::close(target); + libc::close(fd); + } else { + libc::close(fd); + } + } + } +} + fn sandbox_read_fd_to_end(fd: std::os::fd::OwnedFd) -> Vec { use std::io::Read; use std::os::fd::IntoRawFd; diff --git a/crates/sandlock-core/src/sandbox/tests.rs b/crates/sandlock-core/src/sandbox/tests.rs index c6223b8e..a9cc4d39 100644 --- a/crates/sandlock-core/src/sandbox/tests.rs +++ b/crates/sandlock-core/src/sandbox/tests.rs @@ -245,3 +245,33 @@ fn net_deny_resolves_to_denylist_policies() { assert!(!set.tcp.allows("10.0.0.5".parse().unwrap(), 443)); assert!(set.tcp.allows("8.8.8.8".parse().unwrap(), 443)); } + +// Keystone of the popen child-side fd wiring: relocate_high must move a pipe +// end to a fd >= 3 (disjoint from the 0/1/2 stdio targets) so dup2 onto a +// target can never alias or clobber it — the regression that lost a stream +// when a pipe end was allocated onto a std fd. Lock the invariant here; the +// full closed-std-fd trigger is not deterministically reproducible (the +// control PipePair claims the low fds first), so the value is this unit check +// plus the end-to-end popen tests exercising the relocate path. +#[test] +fn relocate_high_moves_fd_above_stdio_range() { + use std::os::fd::AsRawFd; + let f = std::fs::File::open("/dev/null").unwrap(); + let src = f.as_raw_fd(); + let hi = unsafe { relocate_high(src) }; + assert!(hi >= 3, "relocated fd must be >= 3 (disjoint from 0/1/2), got {hi}"); + assert_ne!(hi, src, "relocate must produce a new fd"); + + // It is a dup of the same description, not a fresh open. + let mut a: libc::stat = unsafe { std::mem::zeroed() }; + let mut b: libc::stat = unsafe { std::mem::zeroed() }; + assert_eq!(unsafe { libc::fstat(src, &mut a) }, 0); + assert_eq!(unsafe { libc::fstat(hi, &mut b) }, 0); + assert_eq!((a.st_dev, a.st_ino), (b.st_dev, b.st_ino)); + + // And it carries CLOEXEC (F_DUPFD_CLOEXEC), so it won't leak past execve. + let flags = unsafe { libc::fcntl(hi, libc::F_GETFD) }; + assert!(flags >= 0 && (flags & libc::FD_CLOEXEC) != 0, "relocated fd must be CLOEXEC"); + + unsafe { libc::close(hi) }; +} diff --git a/crates/sandlock-core/tests/integration.rs b/crates/sandlock-core/tests/integration.rs index 329253bb..fe2c629a 100644 --- a/crates/sandlock-core/tests/integration.rs +++ b/crates/sandlock-core/tests/integration.rs @@ -66,3 +66,6 @@ mod test_http_inject_ca; #[path = "integration/test_restore.rs"] mod test_restore; + +#[path = "integration/test_popen.rs"] +mod test_popen; diff --git a/crates/sandlock-core/tests/integration/test_popen.rs b/crates/sandlock-core/tests/integration/test_popen.rs new file mode 100644 index 00000000..cca48059 --- /dev/null +++ b/crates/sandlock-core/tests/integration/test_popen.rs @@ -0,0 +1,234 @@ +//! Integration tests for the streaming-stdio `popen` API (RFC #67). +//! +//! `popen` generalizes the capture/inherit seam into a per-stream +//! `StdioMode`, returning a `Process` whose piped streams the caller owns and +//! drains while the process is alive — the thing capture-mode `run` cannot do. + +use std::fs::File; +use std::io::{BufRead, BufReader, Read, Write}; + +use sandlock_core::{Sandbox, StdioMode}; + +fn base() -> Sandbox { + Sandbox::builder() + .fs_read("/usr") + .fs_read("/lib") + .fs_read_if_exists("/lib64") + .fs_read("/bin") + .fs_read("/etc") + .fs_read("/proc") + .fs_read("/dev") + .build() + .unwrap() +} + +/// stdin + stdout piped: write to the child and read its streamed output +/// back while it is alive. `cat` echoes stdin to stdout, so the bytes we +/// write must come back, then EOF on stdin lets it exit cleanly. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn test_popen_pipe_roundtrip() { + let mut sb = base().with_name("popen-cat"); + let mut child = sb + .popen(&["cat"], StdioMode::Piped, StdioMode::Piped, StdioMode::Inherit) + .await + .unwrap(); + + let mut stdin = File::from(child.take_stdin().expect("stdin pipe")); + let mut stdout = File::from(child.take_stdout().expect("stdout pipe")); + assert!(child.take_stderr().is_none(), "stderr was Inherit, not piped"); + + stdin.write_all(b"hello\n").unwrap(); + drop(stdin); // EOF → cat exits and closes stdout + + let mut out = String::new(); + stdout.read_to_string(&mut out).unwrap(); + assert_eq!(out, "hello\n"); + + let res = child.wait().await.unwrap(); + assert!(res.success()); +} + +/// Only stdout piped (stdin/stderr inherit): a plain capture-equivalent +/// where the caller drains the stream directly. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn test_popen_stdout_only() { + let mut sb = base().with_name("popen-echo"); + let mut child = sb + .popen(&["echo", "hi"], StdioMode::Inherit, StdioMode::Piped, StdioMode::Inherit) + .await + .unwrap(); + assert!(child.take_stdin().is_none(), "stdin was Inherit"); + let mut stdout = File::from(child.take_stdout().expect("stdout pipe")); + let mut out = String::new(); + stdout.read_to_string(&mut out).unwrap(); + assert_eq!(out, "hi\n"); + assert!(child.wait().await.unwrap().success()); +} + +/// Null stdout is actually wired to `/dev/null`, not left inherited. The child +/// stats its own fd 1 and exits 0 only if it is the null device (char 1:3), so +/// the test fails if `Null` silently fell back to inherit or to a pipe. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn test_popen_null_stdout_is_dev_null() { + let mut sb = base().with_name("popen-null"); + let child = sb + .popen( + // Stat the *shell's* fd 1 via $$ — `$(...)` would rebind fd 1 for the + // inner stat itself, so /proc/self/fd/1 there is the substitution pipe. + &["sh", "-c", r#"[ "$(stat -L -c %t:%T /proc/$$/fd/1)" = 1:3 ]"#], + StdioMode::Inherit, + StdioMode::Null, + StdioMode::Inherit, + ) + .await + .unwrap(); + let res = child.wait().await.unwrap(); + assert!(res.success(), "fd 1 was not /dev/null (1:3): exit={:?}", res.exit_status); +} + +/// stderr piped independently of stdout: the stderr match arm must dup the +/// pipe onto fd 2 (not 1), which a copy-paste swap would break. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn test_popen_stderr_piped() { + let mut sb = base().with_name("popen-stderr"); + let mut child = sb + .popen(&["sh", "-c", "echo oops 1>&2"], StdioMode::Inherit, StdioMode::Inherit, StdioMode::Piped) + .await + .unwrap(); + assert!(child.take_stdout().is_none(), "stdout was Inherit"); + let mut stderr = File::from(child.take_stderr().expect("stderr pipe")); + let mut out = String::new(); + stderr.read_to_string(&mut out).unwrap(); + assert_eq!(out, "oops\n"); + assert!(child.wait().await.unwrap().success()); +} + +/// A piped stdin the caller never takes must still reach EOF at `wait`, or a +/// child that reads stdin (`cat`) would block forever. Exercises wait()'s +/// drop of the untaken stdin write end. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn test_popen_untaken_stdin_reaches_eof() { + let mut sb = base().with_name("popen-stdin-eof"); + let child = sb + .popen(&["cat"], StdioMode::Piped, StdioMode::Null, StdioMode::Inherit) + .await + .unwrap(); + // Deliberately do not take stdin. cat reads to EOF; wait must deliver it. + let res = child.wait().await.unwrap(); + assert!(res.success(), "cat did not exit — untaken stdin never EOF'd"); +} + +/// Dropping the owning Sandbox kills the whole process group and reaps it, +/// including a grandchild the workload forked. The grandchild reparents to +/// init on death, so `kill(pid, 0)` returns ESRCH cleanly (no zombie). +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn test_popen_group_killed_on_drop() { + let gc_pid: i32; + { + let mut sb = base().with_name("popen-group"); + let mut child = sb + .popen( + &["sh", "-c", "sleep 100 & echo $! ; wait"], + StdioMode::Inherit, + StdioMode::Piped, + StdioMode::Inherit, + ) + .await + .unwrap(); + let stdout = File::from(child.take_stdout().expect("stdout pipe")); + let mut line = String::new(); + BufReader::new(stdout).read_line(&mut line).unwrap(); + gc_pid = line.trim().parse().expect("grandchild pid"); + assert!(unsafe { libc::kill(gc_pid, 0) } == 0, "grandchild should be alive"); + // sb (and child borrow) dropped here → Sandbox::drop kills the group. + } + // Poll for ESRCH rather than a fixed sleep: after the group SIGKILL the + // grandchild is a zombie until init/subreaper reaps it (kill(pid,0) returns + // 0 for a zombie), and reaping can take >200ms on a loaded box. + let mut reaped = false; + for _ in 0..100 { + if unsafe { libc::kill(gc_pid, 0) } != 0 { + reaped = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + assert!(reaped, "grandchild {gc_pid} should be dead+reaped after group kill"); +} + +/// Regression: capture-mode `run` still buffers stdout into the RunResult +/// after the do_create stdio refactor. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn test_run_still_captures_stdout() { + let res = base().with_name("run-capture").run(&["echo", "captured"]).await.unwrap(); + assert!(res.success()); + assert_eq!(res.stdout_str(), Some("captured")); +} + +/// All three streams piped at once — exercises the relocate/wire path for the +/// full set (a fd collision or wrong ordering would cross or drop a stream). +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn test_popen_all_three_piped() { + let mut sb = base().with_name("popen-3pipe"); + // cat echoes stdin→stdout; then a marker is written to stderr. + let mut child = sb + .popen(&["sh", "-c", "cat; echo err 1>&2"], StdioMode::Piped, StdioMode::Piped, StdioMode::Piped) + .await + .unwrap(); + let mut stdin = File::from(child.take_stdin().expect("stdin")); + let mut stdout = File::from(child.take_stdout().expect("stdout")); + let mut stderr = File::from(child.take_stderr().expect("stderr")); + + stdin.write_all(b"data\n").unwrap(); + drop(stdin); // EOF → cat finishes, sh runs the echo, then exits + + let mut out = String::new(); + stdout.read_to_string(&mut out).unwrap(); + let mut err = String::new(); + stderr.read_to_string(&mut err).unwrap(); + assert_eq!(out, "data\n"); + assert_eq!(err, "err\n"); + assert!(child.wait().await.unwrap().success()); +} + +/// Null stdin delivers immediate EOF: `cat` reading /dev/null exits 0 at once. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn test_popen_null_stdin_is_eof() { + let mut sb = base().with_name("popen-null-stdin"); + let child = sb + .popen(&["cat"], StdioMode::Null, StdioMode::Null, StdioMode::Inherit) + .await + .unwrap(); + assert!(child.wait().await.unwrap().success()); +} + +/// Explicit Process::kill terminates the child; wait still collects a +/// non-success exit status. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn test_popen_explicit_kill_then_wait() { + let mut sb = base().with_name("popen-kill"); + let mut child = sb + .popen(&["sleep", "100"], StdioMode::Inherit, StdioMode::Inherit, StdioMode::Inherit) + .await + .unwrap(); + child.kill().expect("kill"); + let res = child.wait().await.unwrap(); + assert!(!res.success(), "a killed process must not report success"); +} + +/// A Sandbox runs one process: popen on an already-used Sandbox is rejected +/// rather than silently corrupting runtime state. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn test_popen_rejects_second_spawn() { + let mut sb = base().with_name("popen-twice"); + let first = sb + .popen(&["echo", "first"], StdioMode::Inherit, StdioMode::Inherit, StdioMode::Inherit) + .await + .unwrap(); + first.wait().await.unwrap(); // consume the Process → releases the &mut borrow + + let second = sb + .popen(&["echo", "second"], StdioMode::Inherit, StdioMode::Inherit, StdioMode::Inherit) + .await; + assert!(second.is_err(), "second popen on a used Sandbox must error, not reuse state"); +}