From 90d015312e2922a4c87423063a79ab74b7ba4be7 Mon Sep 17 00:00:00 2001 From: dzerik Date: Tue, 12 May 2026 20:22:55 +0300 Subject: [PATCH 01/24] ffi: scaffold handler/notif_repr modules Add empty modules that the upcoming Handler ABI tasks will fill in. The placeholder integration test wires the module path so later tasks can extend it without churning the test file's discovery. --- crates/sandlock-ffi/Cargo.toml | 2 +- crates/sandlock-ffi/src/handler.rs | 11 +++++++++++ crates/sandlock-ffi/src/lib.rs | 3 +++ crates/sandlock-ffi/src/notif_repr.rs | 4 ++++ crates/sandlock-ffi/tests/handler_smoke.rs | 9 +++++++++ 5 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 crates/sandlock-ffi/src/handler.rs create mode 100644 crates/sandlock-ffi/src/notif_repr.rs create mode 100644 crates/sandlock-ffi/tests/handler_smoke.rs diff --git a/crates/sandlock-ffi/Cargo.toml b/crates/sandlock-ffi/Cargo.toml index 2c1505b..764a852 100644 --- a/crates/sandlock-ffi/Cargo.toml +++ b/crates/sandlock-ffi/Cargo.toml @@ -9,7 +9,7 @@ readme = "../../README.md" description = "C ABI for sandlock process sandbox" [lib] -crate-type = ["cdylib", "staticlib"] +crate-type = ["cdylib", "staticlib", "rlib"] [dependencies] sandlock-core = { version = "0.7.0", path = "../sandlock-core" } diff --git a/crates/sandlock-ffi/src/handler.rs b/crates/sandlock-ffi/src/handler.rs new file mode 100644 index 0000000..e873ada --- /dev/null +++ b/crates/sandlock-ffi/src/handler.rs @@ -0,0 +1,11 @@ +//! FFI surface for the sandlock `Handler` trait. See `docs/extension-handlers.md`. +//! +//! This module is intentionally split out of `lib.rs` because it contains a +//! self-contained adapter pair: a C-callable surface (`sandlock_handler_*`, +//! `sandlock_action_*`, `sandlock_mem_*`, `sandlock_run_with_handlers`) and a +//! Rust-internal type (`FfiHandler`) that implements `Handler` by calling +//! through to a C function pointer. + +/// Sentinel symbol used by build smoke tests to confirm the module is +/// reachable from the cdylib's public surface. +pub const SANDLOCK_HANDLER_MODULE_BUILT: bool = true; diff --git a/crates/sandlock-ffi/src/lib.rs b/crates/sandlock-ffi/src/lib.rs index edf2917..fcf8594 100644 --- a/crates/sandlock-ffi/src/lib.rs +++ b/crates/sandlock-ffi/src/lib.rs @@ -12,6 +12,9 @@ use sandlock_core::pipeline::Stage; use sandlock_core::sandbox::{BranchAction, ByteSize, FsIsolation, SandboxBuilder}; use sandlock_core::{Sandbox, RunResult}; +pub mod handler; +pub mod notif_repr; + // ---------------------------------------------------------------- // Opaque wrapper types // ---------------------------------------------------------------- diff --git a/crates/sandlock-ffi/src/notif_repr.rs b/crates/sandlock-ffi/src/notif_repr.rs new file mode 100644 index 0000000..9326206 --- /dev/null +++ b/crates/sandlock-ffi/src/notif_repr.rs @@ -0,0 +1,4 @@ +//! `repr(C)` snapshot of a `SeccompNotif`. The C side reads this struct +//! by value; no pointers into Rust memory live past the callback return. + +// Filled in by Task 2. diff --git a/crates/sandlock-ffi/tests/handler_smoke.rs b/crates/sandlock-ffi/tests/handler_smoke.rs new file mode 100644 index 0000000..799757f --- /dev/null +++ b/crates/sandlock-ffi/tests/handler_smoke.rs @@ -0,0 +1,9 @@ +//! Integration smoke test for the FFI handler ABI introduced in PR 1. +//! Subsequent tasks expand this file as the surface is built up. + +#[test] +fn handler_module_is_exposed() { + // This forces the `handler` module to be referenced from the cdylib + // public surface. Replaced by real tests in later tasks. + let _ = sandlock_ffi::handler::SANDLOCK_HANDLER_MODULE_BUILT; +} From 8158a89877a098a8ef46671aa70452b3902d5502 Mon Sep 17 00:00:00 2001 From: dzerik Date: Tue, 12 May 2026 20:31:56 +0300 Subject: [PATCH 02/24] ffi: add sandlock_notif_data_t snapshot Stable repr(C) projection of SeccompNotif/SeccompData so external handler implementations can read the kernel notification without binding to the unstable internal struct layout. Also re-export SeccompNotif/SeccompData from sandlock_core's crate root. They are already part of the public Handler-trait contract through HandlerCtx.notif; re-exporting them makes the path stable without widening the `sys` module visibility. --- crates/sandlock-core/src/lib.rs | 1 + crates/sandlock-ffi/include/sandlock.h | 16 ++++++++++ crates/sandlock-ffi/src/notif_repr.rs | 35 +++++++++++++++++++++- crates/sandlock-ffi/tests/handler_smoke.rs | 35 ++++++++++++++++++++++ 4 files changed, 86 insertions(+), 1 deletion(-) diff --git a/crates/sandlock-core/src/lib.rs b/crates/sandlock-core/src/lib.rs index c9d272d..19323ae 100644 --- a/crates/sandlock-core/src/lib.rs +++ b/crates/sandlock-core/src/lib.rs @@ -28,6 +28,7 @@ pub mod dry_run; pub(crate) mod http_acl; pub use error::SandlockError; +pub use sys::structs::{SeccompData, SeccompNotif}; pub use checkpoint::Checkpoint; pub use sandbox::{Confinement, ConfinementBuilder, Sandbox, SandboxBuilder}; pub use result::{RunResult, ExitStatus}; diff --git a/crates/sandlock-ffi/include/sandlock.h b/crates/sandlock-ffi/include/sandlock.h index d98d9f2..39a19f1 100644 --- a/crates/sandlock-ffi/include/sandlock.h +++ b/crates/sandlock-ffi/include/sandlock.h @@ -118,6 +118,22 @@ sandlock_result_t *sandlock_pipeline_run(sandlock_pipeline_t *pipe, uint64_t tim void sandlock_pipeline_free(sandlock_pipeline_t *pipe); +/* ---------------------------------------------------------------- + * Handler ABI — extension handlers for seccomp-notif syscalls. + * ---------------------------------------------------------------- */ + +/** Snapshot of a kernel seccomp notification. Field layout must stay + * in lock-step with `sandlock_ffi::notif_repr::sandlock_notif_data_t`. */ +typedef struct sandlock_notif_data_t { + uint64_t id; + uint32_t pid; + uint32_t flags; + int32_t syscall_nr; + uint32_t arch; + uint64_t instruction_pointer; + uint64_t args[6]; +} sandlock_notif_data_t; + #ifdef __cplusplus } #endif diff --git a/crates/sandlock-ffi/src/notif_repr.rs b/crates/sandlock-ffi/src/notif_repr.rs index 9326206..60c7e96 100644 --- a/crates/sandlock-ffi/src/notif_repr.rs +++ b/crates/sandlock-ffi/src/notif_repr.rs @@ -1,4 +1,37 @@ //! `repr(C)` snapshot of a `SeccompNotif`. The C side reads this struct //! by value; no pointers into Rust memory live past the callback return. -// Filled in by Task 2. +use sandlock_core::SeccompNotif; + +/// Stable wire-layout snapshot of a seccomp notification. +/// +/// Field order, types, and padding must match `sandlock.h` exactly. The +/// size assertion in `tests/handler_smoke.rs` guards against accidental +/// drift; if a new field is added, bump the documented size and update +/// the C header in the same commit. +#[repr(C)] +#[derive(Clone, Copy)] +#[allow(non_camel_case_types)] +pub struct sandlock_notif_data_t { + pub id: u64, + pub pid: u32, + pub flags: u32, + pub syscall_nr: i32, + pub arch: u32, + pub instruction_pointer: u64, + pub args: [u64; 6], +} + +impl From<&SeccompNotif> for sandlock_notif_data_t { + fn from(n: &SeccompNotif) -> Self { + Self { + id: n.id, + pid: n.pid, + flags: n.flags, + syscall_nr: n.data.nr, + arch: n.data.arch, + instruction_pointer: n.data.instruction_pointer, + args: n.data.args, + } + } +} diff --git a/crates/sandlock-ffi/tests/handler_smoke.rs b/crates/sandlock-ffi/tests/handler_smoke.rs index 799757f..f6675fc 100644 --- a/crates/sandlock-ffi/tests/handler_smoke.rs +++ b/crates/sandlock-ffi/tests/handler_smoke.rs @@ -7,3 +7,38 @@ fn handler_module_is_exposed() { // public surface. Replaced by real tests in later tasks. let _ = sandlock_ffi::handler::SANDLOCK_HANDLER_MODULE_BUILT; } + +use sandlock_ffi::notif_repr::sandlock_notif_data_t; + +#[test] +fn notif_data_layout_matches_documented_size() { + // 8 + 4 + 4 + 4 + 4 + 8 + 6*8 = 80 bytes. If this changes, the C header + // and any external consumers need to be updated together. + assert_eq!(std::mem::size_of::(), 80); + assert_eq!(std::mem::align_of::(), 8); +} + +#[test] +fn notif_data_from_seccomp_notif_copies_all_fields() { + use sandlock_core::{SeccompData, SeccompNotif}; + + let notif = SeccompNotif { + id: 0xDEAD_BEEF_CAFE_F00D, + pid: 4242, + flags: 7, + data: SeccompData { + nr: 21, // SYS_access on x86_64 + arch: 0xC000_003E, + instruction_pointer: 0x7FFF_FFFF_AAAA, + args: [1, 2, 3, 4, 5, 6], + }, + }; + let snap = sandlock_notif_data_t::from(¬if); + assert_eq!(snap.id, 0xDEAD_BEEF_CAFE_F00D); + assert_eq!(snap.pid, 4242); + assert_eq!(snap.flags, 7); + assert_eq!(snap.syscall_nr, 21); + assert_eq!(snap.arch, 0xC000_003E); + assert_eq!(snap.instruction_pointer, 0x7FFF_FFFF_AAAA); + assert_eq!(snap.args, [1, 2, 3, 4, 5, 6]); +} From a85074851bdf2b6dc094a2fbb50c368f54286b0e Mon Sep 17 00:00:00 2001 From: dzerik Date: Tue, 12 May 2026 20:43:35 +0300 Subject: [PATCH 03/24] ffi: add sandlock_mem_handle_t with read_cstr/read/write Opaque child-memory accessor that wraps the existing TOCTOU-safe helpers in sandlock-core. The handle's lifetime is bounded by a single callback invocation; the C side must not retain it past the return. --- crates/sandlock-ffi/include/sandlock.h | 23 ++++ crates/sandlock-ffi/src/handler.rs | 122 +++++++++++++++++++-- crates/sandlock-ffi/tests/handler_smoke.rs | 30 +++++ 3 files changed, 166 insertions(+), 9 deletions(-) diff --git a/crates/sandlock-ffi/include/sandlock.h b/crates/sandlock-ffi/include/sandlock.h index 39a19f1..638c048 100644 --- a/crates/sandlock-ffi/include/sandlock.h +++ b/crates/sandlock-ffi/include/sandlock.h @@ -134,6 +134,29 @@ typedef struct sandlock_notif_data_t { uint64_t args[6]; } sandlock_notif_data_t; +/** Opaque child-memory accessor (lifetime: single callback invocation). */ +typedef struct sandlock_mem_handle_t sandlock_mem_handle_t; + +/** Read a NUL-terminated string. Returns 0 on success, -1 on failure. + * On success the buffer is NUL-terminated and `*out_len` holds the byte + * count copied (excluding NUL); `max_len` must be at least 1 to fit the + * NUL. */ +int sandlock_mem_read_cstr(const sandlock_mem_handle_t *handle, + uint64_t addr, + uint8_t *buf, size_t max_len, + size_t *out_len); + +/** Raw memory read. Returns 0/-1; `*out_len` holds actual bytes copied. */ +int sandlock_mem_read(const sandlock_mem_handle_t *handle, + uint64_t addr, + uint8_t *buf, size_t len, + size_t *out_len); + +/** Raw memory write. Returns 0/-1. */ +int sandlock_mem_write(const sandlock_mem_handle_t *handle, + uint64_t addr, + const uint8_t *buf, size_t len); + #ifdef __cplusplus } #endif diff --git a/crates/sandlock-ffi/src/handler.rs b/crates/sandlock-ffi/src/handler.rs index e873ada..96f7701 100644 --- a/crates/sandlock-ffi/src/handler.rs +++ b/crates/sandlock-ffi/src/handler.rs @@ -1,11 +1,115 @@ //! FFI surface for the sandlock `Handler` trait. See `docs/extension-handlers.md`. -//! -//! This module is intentionally split out of `lib.rs` because it contains a -//! self-contained adapter pair: a C-callable surface (`sandlock_handler_*`, -//! `sandlock_action_*`, `sandlock_mem_*`, `sandlock_run_with_handlers`) and a -//! Rust-internal type (`FfiHandler`) that implements `Handler` by calling -//! through to a C function pointer. - -/// Sentinel symbol used by build smoke tests to confirm the module is -/// reachable from the cdylib's public surface. + +use std::os::unix::io::RawFd; +use std::slice; + +use sandlock_core::seccomp::notif::{read_child_cstr, read_child_mem, write_child_mem}; + pub const SANDLOCK_HANDLER_MODULE_BUILT: bool = true; + +/// Opaque child-memory accessor handed to a C handler callback. +/// +/// Constructed on the stack inside the Rust adapter just before the +/// callback fires, invalidated when the callback returns. C handlers +/// must not store the pointer beyond the callback's return. +#[repr(C)] +#[allow(non_camel_case_types)] +pub struct sandlock_mem_handle_t { + notif_fd: RawFd, + notif_id: u64, + pid: u32, +} + +impl sandlock_mem_handle_t { + pub(crate) fn new(notif_fd: RawFd, notif_id: u64, pid: u32) -> Self { + Self { notif_fd, notif_id, pid } + } +} + +/// Read up to `max_len-1` bytes of a NUL-terminated string at `addr` from the +/// traced child. On success the destination buffer is NUL-terminated and +/// `*out_len` holds the byte count copied (excluding the NUL); returns 0. +/// On failure returns -1 and leaves `*out_len` untouched. `max_len` must be +/// at least 1 to fit the NUL terminator. +/// +/// # Safety +/// `handle` must point to a live `sandlock_mem_handle_t` provided by the +/// supervisor; `buf` must be writable for `max_len` bytes; `out_len` must +/// be a valid `size_t*`. +#[no_mangle] +pub unsafe extern "C" fn sandlock_mem_read_cstr( + handle: *const sandlock_mem_handle_t, + addr: u64, + buf: *mut u8, + max_len: usize, + out_len: *mut usize, +) -> i32 { + if handle.is_null() || buf.is_null() || out_len.is_null() || max_len == 0 { + return -1; + } + let h = &*handle; + // `max_len` is the caller-supplied buffer size including space for the + // trailing NUL; reserve one byte so we can always terminate the string. + let cap = max_len - 1; + let s = match read_child_cstr(h.notif_fd, h.notif_id, h.pid, addr, cap) { + Some(s) => s, + None => return -1, + }; + let bytes = s.as_bytes(); + let n = bytes.len().min(cap); + std::ptr::copy_nonoverlapping(bytes.as_ptr(), buf, n); + *buf.add(n) = 0; + *out_len = n; + 0 +} + +/// Raw byte read at `addr` of exactly `len` bytes. Writes byte count +/// actually read to `*out_len`. Returns 0 on success, -1 on failure. +/// +/// # Safety +/// Same constraints as `sandlock_mem_read_cstr`. +#[no_mangle] +pub unsafe extern "C" fn sandlock_mem_read( + handle: *const sandlock_mem_handle_t, + addr: u64, + buf: *mut u8, + len: usize, + out_len: *mut usize, +) -> i32 { + if handle.is_null() || buf.is_null() || out_len.is_null() { + return -1; + } + let h = &*handle; + let v = match read_child_mem(h.notif_fd, h.notif_id, h.pid, addr, len) { + Ok(v) => v, + Err(_) => return -1, + }; + let n = v.len(); + std::ptr::copy_nonoverlapping(v.as_ptr(), buf, n); + *out_len = n; + 0 +} + +/// Write `len` bytes from `buf` into the child at `addr`. Returns 0 on +/// success, -1 on failure. +/// +/// # Safety +/// Same constraints as `sandlock_mem_read_cstr`; `buf` must be readable +/// for `len` bytes. +#[no_mangle] +pub unsafe extern "C" fn sandlock_mem_write( + handle: *const sandlock_mem_handle_t, + addr: u64, + buf: *const u8, + len: usize, +) -> i32 { + if handle.is_null() || buf.is_null() { + return -1; + } + let h = &*handle; + let data = slice::from_raw_parts(buf, len); + match write_child_mem(h.notif_fd, h.notif_id, h.pid, addr, data) { + Ok(()) => 0, + Err(_) => -1, + } +} diff --git a/crates/sandlock-ffi/tests/handler_smoke.rs b/crates/sandlock-ffi/tests/handler_smoke.rs index f6675fc..b5abbb8 100644 --- a/crates/sandlock-ffi/tests/handler_smoke.rs +++ b/crates/sandlock-ffi/tests/handler_smoke.rs @@ -42,3 +42,33 @@ fn notif_data_from_seccomp_notif_copies_all_fields() { assert_eq!(snap.instruction_pointer, 0x7FFF_FFFF_AAAA); assert_eq!(snap.args, [1, 2, 3, 4, 5, 6]); } + +use sandlock_ffi::handler::{ + sandlock_mem_read, sandlock_mem_read_cstr, sandlock_mem_write, +}; + +#[test] +fn mem_accessors_reject_null_arguments() { + // Verifies the null-pointer guards in each accessor. Happy-path + // coverage comes in Task 7 with a live notif_fd. + let mut buf = [0u8; 4]; + let mut out_len: usize = 0; + let p = std::ptr::null(); + unsafe { + assert_eq!( + sandlock_mem_read_cstr(p, 0, buf.as_mut_ptr(), buf.len(), &mut out_len), + -1, + "read_cstr should reject null handle", + ); + assert_eq!( + sandlock_mem_read(p, 0, buf.as_mut_ptr(), buf.len(), &mut out_len), + -1, + "read should reject null handle", + ); + assert_eq!( + sandlock_mem_write(p, 0, buf.as_ptr(), buf.len()), + -1, + "write should reject null handle", + ); + } +} From 571e6c0155f2f4e71748085ef8c757721dbe530f Mon Sep 17 00:00:00 2001 From: dzerik Date: Tue, 12 May 2026 20:54:25 +0300 Subject: [PATCH 04/24] ffi: add sandlock_action_out_t with setters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Output-parameter approach for the NotifAction surface — the C handler writes its decision via setter calls instead of returning a tagged union by value. Makes future variant additions backwards-compatible without breaking C ABI. --- Cargo.lock | 1 + crates/sandlock-ffi/Cargo.toml | 3 + crates/sandlock-ffi/include/sandlock.h | 53 ++++++ crates/sandlock-ffi/src/handler.rs | 185 +++++++++++++++++++++ crates/sandlock-ffi/tests/handler_smoke.rs | 38 +++++ 5 files changed, 280 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index bc21bec..9a79638 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1601,6 +1601,7 @@ dependencies = [ name = "sandlock-ffi" version = "0.7.0" dependencies = [ + "libc", "sandlock-core", "serde_json", "tokio", diff --git a/crates/sandlock-ffi/Cargo.toml b/crates/sandlock-ffi/Cargo.toml index 764a852..0a83175 100644 --- a/crates/sandlock-ffi/Cargo.toml +++ b/crates/sandlock-ffi/Cargo.toml @@ -16,3 +16,6 @@ sandlock-core = { version = "0.7.0", path = "../sandlock-core" } serde_json = "1" tokio = { version = "1", features = ["rt-multi-thread"] } +[dev-dependencies] +libc = "0.2" + diff --git a/crates/sandlock-ffi/include/sandlock.h b/crates/sandlock-ffi/include/sandlock.h index 638c048..40461fc 100644 --- a/crates/sandlock-ffi/include/sandlock.h +++ b/crates/sandlock-ffi/include/sandlock.h @@ -157,6 +157,59 @@ int sandlock_mem_write(const sandlock_mem_handle_t *handle, uint64_t addr, const uint8_t *buf, size_t len); +typedef enum sandlock_action_kind { + SANDLOCK_ACTION_UNSET = 0, + SANDLOCK_ACTION_CONTINUE = 1, + SANDLOCK_ACTION_ERRNO = 2, + SANDLOCK_ACTION_RETURN_VALUE = 3, + SANDLOCK_ACTION_INJECT_FD_SEND = 4, + SANDLOCK_ACTION_INJECT_FD_SEND_TRACKED = 5, + SANDLOCK_ACTION_HOLD = 6, + SANDLOCK_ACTION_KILL = 7, +} sandlock_action_kind_t; + +typedef struct { int32_t sig; int32_t pgid; } sandlock_action_kill_t; + +typedef struct { + int32_t srcfd; + uint32_t newfd_flags; +} sandlock_action_inject_t; + +typedef uint64_t sandlock_inject_tracker_t; + +typedef struct { + int32_t srcfd; + uint32_t newfd_flags; + sandlock_inject_tracker_t tracker; +} sandlock_action_inject_tracked_t; + +typedef union { + uint64_t none; + int32_t errno_value; + int64_t return_value; + sandlock_action_inject_t inject_send; + sandlock_action_inject_tracked_t inject_send_tracked; + sandlock_action_kill_t kill; +} sandlock_action_payload_t; + +typedef struct sandlock_action_out_t { + uint32_t kind; /* sandlock_action_kind_t */ + sandlock_action_payload_t payload; +} sandlock_action_out_t; + +/* Setters — exactly one tag is written; the payload is filled in + * accordingly. Calling a setter overwrites any prior setting. */ +void sandlock_action_set_continue(sandlock_action_out_t *out); +void sandlock_action_set_errno(sandlock_action_out_t *out, int32_t errno_value); +void sandlock_action_set_return_value(sandlock_action_out_t *out, int64_t value); +void sandlock_action_set_inject_fd_send(sandlock_action_out_t *out, + int32_t srcfd, uint32_t newfd_flags); +void sandlock_action_set_inject_fd_send_tracked(sandlock_action_out_t *out, + int32_t srcfd, uint32_t newfd_flags, + sandlock_inject_tracker_t tracker); +void sandlock_action_set_hold(sandlock_action_out_t *out); +void sandlock_action_set_kill(sandlock_action_out_t *out, int32_t sig, int32_t pgid); + #ifdef __cplusplus } #endif diff --git a/crates/sandlock-ffi/src/handler.rs b/crates/sandlock-ffi/src/handler.rs index 96f7701..ee96c53 100644 --- a/crates/sandlock-ffi/src/handler.rs +++ b/crates/sandlock-ffi/src/handler.rs @@ -113,3 +113,188 @@ pub unsafe extern "C" fn sandlock_mem_write( Err(_) => -1, } } + +/// Tag distinguishing payload variants of `sandlock_action_out_t`. +#[repr(u32)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[allow(non_camel_case_types)] +pub enum sandlock_action_kind_t { + /// No action set yet; the supervisor treats this as "fall through to + /// the handler's on_exception policy" (Task 6 wires this up). + Unset = 0, + Continue = 1, + Errno = 2, + ReturnValue = 3, + InjectFdSend = 4, + InjectFdSendTracked = 5, + Hold = 6, + Kill = 7, +} + +#[repr(C)] +#[derive(Clone, Copy)] +#[allow(non_camel_case_types)] +pub struct sandlock_action_kill_t { + pub sig: i32, + pub pgid: i32, +} + +#[repr(C)] +#[derive(Clone, Copy)] +#[allow(non_camel_case_types)] +pub struct sandlock_action_inject_t { + /// Owned by the C caller; ownership transfers to the supervisor on + /// successful invocation of the corresponding setter. + pub srcfd: i32, + pub newfd_flags: u32, +} + +/// Token used by `InjectFdSendTracked` so the C side can correlate the +/// callback that fires after `SECCOMP_IOCTL_NOTIF_ADDFD` returns the +/// child-side fd number. Wired through to a Rust `OnInjectSuccess` +/// closure built in Task 6. +#[allow(non_camel_case_types)] +pub type sandlock_inject_tracker_t = u64; + +#[repr(C)] +#[derive(Clone, Copy)] +#[allow(non_camel_case_types)] +pub struct sandlock_action_inject_tracked_t { + pub srcfd: i32, + pub newfd_flags: u32, + pub tracker: sandlock_inject_tracker_t, +} + +#[repr(C)] +#[allow(non_camel_case_types)] +pub union sandlock_action_payload_t { + pub none: u64, + pub errno: i32, + pub return_value: i64, + pub inject_send: sandlock_action_inject_t, + pub inject_send_tracked: sandlock_action_inject_tracked_t, + pub kill: sandlock_action_kill_t, +} + +#[repr(C)] +#[allow(non_camel_case_types)] +pub struct sandlock_action_out_t { + pub kind: u32, + pub payload: sandlock_action_payload_t, +} + +impl sandlock_action_out_t { + /// Construct an `Unset` action with all payload bytes zero. The payload + /// union has variants up to 16 bytes; this ensures all bytes are + /// initialised before the C handler writes its decision. + pub fn zeroed() -> Self { + // Safety: `sandlock_action_payload_t` is `#[repr(C)]` with only + // integer-and-integer-aggregate variants; the zero bit-pattern is + // valid for all of them. + Self { + kind: sandlock_action_kind_t::Unset as u32, + payload: unsafe { std::mem::MaybeUninit::zeroed().assume_init() }, + } + } +} + +/// Mark the action as `Continue` (let the syscall proceed unchanged). +/// +/// # Safety +/// `out` must be a valid pointer to a `sandlock_action_out_t` writable +/// for the duration of the call, or null (in which case the call is a +/// no-op). +#[no_mangle] +pub unsafe extern "C" fn sandlock_action_set_continue(out: *mut sandlock_action_out_t) { + if out.is_null() { return; } + (*out).kind = sandlock_action_kind_t::Continue as u32; +} + +/// Fail the syscall with `errno`. +/// +/// # Safety +/// Same constraints as `sandlock_action_set_continue`. +#[no_mangle] +pub unsafe extern "C" fn sandlock_action_set_errno(out: *mut sandlock_action_out_t, errno: i32) { + if out.is_null() { return; } + (*out).kind = sandlock_action_kind_t::Errno as u32; + (*out).payload.errno = errno; +} + +/// Return a specific value from the syscall without entering the kernel. +/// +/// # Safety +/// Same constraints as `sandlock_action_set_continue`. +#[no_mangle] +pub unsafe extern "C" fn sandlock_action_set_return_value( + out: *mut sandlock_action_out_t, + value: i64, +) { + if out.is_null() { return; } + (*out).kind = sandlock_action_kind_t::ReturnValue as u32; + (*out).payload.return_value = value; +} + +/// Inject the supervisor-side fd `srcfd` into the traced child as a new +/// fd (number chosen by the kernel via `SECCOMP_IOCTL_NOTIF_ADDFD`). +/// +/// # Safety +/// Same constraints as `sandlock_action_set_continue`; `srcfd` must be +/// a valid open fd in the supervisor process at the moment of the +/// supervisor's dispatch. +#[no_mangle] +pub unsafe extern "C" fn sandlock_action_set_inject_fd_send( + out: *mut sandlock_action_out_t, + srcfd: RawFd, + newfd_flags: u32, +) { + if out.is_null() { return; } + (*out).kind = sandlock_action_kind_t::InjectFdSend as u32; + (*out).payload.inject_send = sandlock_action_inject_t { srcfd, newfd_flags }; +} + +/// Tracked variant of `sandlock_action_set_inject_fd_send` — the +/// supervisor will fire a Rust-side callback identified by `tracker` +/// once the kernel reports the child-side fd number. +/// +/// # Safety +/// Same constraints as `sandlock_action_set_inject_fd_send`. +#[no_mangle] +pub unsafe extern "C" fn sandlock_action_set_inject_fd_send_tracked( + out: *mut sandlock_action_out_t, + srcfd: RawFd, + newfd_flags: u32, + tracker: sandlock_inject_tracker_t, +) { + if out.is_null() { return; } + (*out).kind = sandlock_action_kind_t::InjectFdSendTracked as u32; + (*out).payload.inject_send_tracked = sandlock_action_inject_tracked_t { + srcfd, newfd_flags, tracker, + }; +} + +/// Hold the syscall pending until the supervisor explicitly releases it. +/// +/// # Safety +/// Same constraints as `sandlock_action_set_continue`. +#[no_mangle] +pub unsafe extern "C" fn sandlock_action_set_hold(out: *mut sandlock_action_out_t) { + if out.is_null() { return; } + (*out).kind = sandlock_action_kind_t::Hold as u32; +} + +/// Kill the target (`pgid > 0` for the whole process group, or the pid +/// the supervisor records for the notification) with signal `sig`. +/// +/// # Safety +/// Same constraints as `sandlock_action_set_continue`. +#[no_mangle] +pub unsafe extern "C" fn sandlock_action_set_kill( + out: *mut sandlock_action_out_t, + sig: i32, + pgid: i32, +) { + if out.is_null() { return; } + (*out).kind = sandlock_action_kind_t::Kill as u32; + (*out).payload.kill = sandlock_action_kill_t { sig, pgid }; +} diff --git a/crates/sandlock-ffi/tests/handler_smoke.rs b/crates/sandlock-ffi/tests/handler_smoke.rs index b5abbb8..8d8806e 100644 --- a/crates/sandlock-ffi/tests/handler_smoke.rs +++ b/crates/sandlock-ffi/tests/handler_smoke.rs @@ -72,3 +72,41 @@ fn mem_accessors_reject_null_arguments() { ); } } + +use sandlock_ffi::handler::{ + sandlock_action_kind_t, sandlock_action_out_t, sandlock_action_set_continue, + sandlock_action_set_errno, sandlock_action_set_hold, sandlock_action_set_kill, + sandlock_action_set_return_value, +}; + +#[test] +fn action_setters_record_kind_and_payload() { + let mut a = sandlock_action_out_t::zeroed(); + unsafe { sandlock_action_set_continue(&mut a) }; + assert_eq!(a.kind, sandlock_action_kind_t::Continue as u32); + + unsafe { sandlock_action_set_errno(&mut a, 13) }; + assert_eq!(a.kind, sandlock_action_kind_t::Errno as u32); + assert_eq!(unsafe { a.payload.errno }, 13); + + unsafe { sandlock_action_set_return_value(&mut a, -1) }; + assert_eq!(a.kind, sandlock_action_kind_t::ReturnValue as u32); + assert_eq!(unsafe { a.payload.return_value }, -1); + + unsafe { sandlock_action_set_hold(&mut a) }; + assert_eq!(a.kind, sandlock_action_kind_t::Hold as u32); + + unsafe { sandlock_action_set_kill(&mut a, libc::SIGKILL, 4321) }; + assert_eq!(a.kind, sandlock_action_kind_t::Kill as u32); + assert_eq!(unsafe { a.payload.kill.sig }, libc::SIGKILL); + assert_eq!(unsafe { a.payload.kill.pgid }, 4321); +} + +#[test] +fn action_out_layout_is_stable() { + // kind(4) + pad(4) + payload(16) = 24 bytes; alignment driven by the + // u64 inside the union. Layout drift between Rust and the C header + // would corrupt caller-allocated buffers. + assert_eq!(std::mem::size_of::(), 24); + assert_eq!(std::mem::align_of::(), 8); +} From 9421eb82de4bb925cf8f82a999e9dc1711441f95 Mon Sep 17 00:00:00 2001 From: dzerik Date: Tue, 12 May 2026 21:10:33 +0300 Subject: [PATCH 05/24] ffi: add sandlock_handler_t opaque container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps a C handler function pointer, opaque user-data slot with an optional destructor, and per-handler exception policy (defaulting to Kill — fail-closed). --- crates/sandlock-ffi/include/sandlock.h | 29 +++++ crates/sandlock-ffi/src/handler.rs | 130 +++++++++++++++++++++ crates/sandlock-ffi/tests/handler_smoke.rs | 51 ++++++++ 3 files changed, 210 insertions(+) diff --git a/crates/sandlock-ffi/include/sandlock.h b/crates/sandlock-ffi/include/sandlock.h index 40461fc..9fdeb45 100644 --- a/crates/sandlock-ffi/include/sandlock.h +++ b/crates/sandlock-ffi/include/sandlock.h @@ -210,6 +210,35 @@ void sandlock_action_set_inject_fd_send_tracked(sandlock_action_out_t *out, void sandlock_action_set_hold(sandlock_action_out_t *out); void sandlock_action_set_kill(sandlock_action_out_t *out, int32_t sig, int32_t pgid); +typedef enum sandlock_exception_policy { + SANDLOCK_EXCEPTION_KILL = 0, + SANDLOCK_EXCEPTION_DENY_EPERM = 1, + SANDLOCK_EXCEPTION_CONTINUE = 2, +} sandlock_exception_policy_t; + +typedef struct sandlock_handler_t sandlock_handler_t; + +/** C handler signature. Return 0 on success; a non-zero return triggers + * the handler's exception policy. The callee MUST call exactly one + * sandlock_action_set_*() on `out` before returning 0. */ +typedef int (*sandlock_handler_fn_t)(void *ud, + const sandlock_notif_data_t *notif, + sandlock_mem_handle_t *mem, + sandlock_action_out_t *out); + +typedef void (*sandlock_handler_ud_drop_t)(void *ud); + +/** Allocate a handler container. Returns NULL when `handler_fn` is NULL + * or when `on_exception` is not one of the documented `SANDLOCK_EXCEPTION_*` + * values. */ +sandlock_handler_t *sandlock_handler_new(sandlock_handler_fn_t handler_fn, + void *ud, + sandlock_handler_ud_drop_t ud_drop, + sandlock_exception_policy_t on_exception); + +/** Free a handler container that has not been handed to the supervisor. */ +void sandlock_handler_free(sandlock_handler_t *h); + #ifdef __cplusplus } #endif diff --git a/crates/sandlock-ffi/src/handler.rs b/crates/sandlock-ffi/src/handler.rs index ee96c53..1f967d4 100644 --- a/crates/sandlock-ffi/src/handler.rs +++ b/crates/sandlock-ffi/src/handler.rs @@ -298,3 +298,133 @@ pub unsafe extern "C" fn sandlock_action_set_kill( (*out).kind = sandlock_action_kind_t::Kill as u32; (*out).payload.kill = sandlock_action_kill_t { sig, pgid }; } + +/// Exception policy applied when the handler callback fails to set a +/// valid action (returns non-zero rc, leaves `kind == Unset`, or panics +/// across the FFI boundary). +#[repr(u32)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[allow(non_camel_case_types)] +pub enum sandlock_exception_policy_t { + /// Treat the failure as `NotifAction::Kill { sig: SIGKILL, pgid: child_pgid }`. + /// Default; "fail-closed" — the safe option. + Kill = 0, + /// Treat the failure as `NotifAction::Errno(EPERM)`. Useful for + /// audit-style handlers where the syscall is what failed rather than + /// the supervisor. + DenyEperm = 1, + /// Treat the failure as `NotifAction::Continue`. Explicit fail-open; + /// only safe when the syscall is *also* allowed by the BPF filter and + /// Landlock layer (e.g. observability handlers). + Continue = 2, +} + +/// C-callable handler entry point. +/// +/// Returns 0 on success (and must have called exactly one setter on +/// `out`). Returns non-zero to signal a handler-internal error; the +/// supervisor then applies the configured exception policy. +#[allow(non_camel_case_types)] +pub type sandlock_handler_fn_t = extern "C" fn( + ud: *mut std::ffi::c_void, + notif: *const crate::notif_repr::sandlock_notif_data_t, + mem: *mut sandlock_mem_handle_t, + out: *mut sandlock_action_out_t, +) -> i32; + +/// Optional destructor invoked when the container is freed. +#[allow(non_camel_case_types)] +pub type sandlock_handler_ud_drop_t = extern "C" fn(ud: *mut std::ffi::c_void); + +/// Opaque handler container (B4 — opaque box). +#[repr(C)] +#[allow(non_camel_case_types)] +pub struct sandlock_handler_t { + pub(crate) handler_fn: Option, + pub(crate) ud: *mut std::ffi::c_void, + pub(crate) ud_drop: Option, + pub(crate) on_exception: sandlock_exception_policy_t, +} + +// Safety: +// +// `Send`: required so the supervisor can move the handler container into a +// `tokio::task::spawn_blocking` closure. The struct contains only pointers +// (function pointer + `void*` user-data) and a `#[repr(u32)]` enum, all of +// which are `Send`-safe to move across threads. +// +// `Sync`: required so `sandlock_handler_t` can live inside the +// `Arc` storage that the dispatch table uses. This is a +// stronger claim than `Send` — the supervisor may dispatch concurrent +// notifications for the same syscall on different worker threads, meaning +// the same `&sandlock_handler_t` (and the same `ud` pointer) may be read +// from multiple threads at the same time. The C caller is responsible for +// ensuring `ud` is either immutable or guarded by thread-safe state of its +// own (atomics, mutex, etc.) — Rust offers no synchronization for opaque +// `void*` user-data. +unsafe impl Send for sandlock_handler_t {} +unsafe impl Sync for sandlock_handler_t {} + +impl Drop for sandlock_handler_t { + fn drop(&mut self) { + if let Some(drop_fn) = self.ud_drop.take() { + if !self.ud.is_null() { + (drop_fn)(self.ud); + self.ud = std::ptr::null_mut(); + } + } + } +} + +/// Allocate a handler container. `handler_fn` must be non-null; passing +/// `ud_drop = None` is legal when `ud` does not require cleanup. +/// +/// # Safety +/// `ud` is opaque to Rust — the caller guarantees that the pointer +/// remains valid until either (a) `sandlock_handler_free` is called or +/// (b) the supervisor takes ownership via `sandlock_run_with_handlers` +/// and the run completes. +/// If `on_exception` does not match a defined `sandlock_exception_policy_t` +/// discriminant (0, 1, or 2), the call returns null and no allocation occurs. +#[no_mangle] +pub unsafe extern "C" fn sandlock_handler_new( + handler_fn: Option, + ud: *mut std::ffi::c_void, + ud_drop: Option, + on_exception: u32, +) -> *mut sandlock_handler_t { + if handler_fn.is_none() { + return std::ptr::null_mut(); + } + let on_exception = match on_exception { + 0 => sandlock_exception_policy_t::Kill, + 1 => sandlock_exception_policy_t::DenyEperm, + 2 => sandlock_exception_policy_t::Continue, + // Reject out-of-range discriminants at the FFI boundary so we never + // store an invalid enum value into the struct — reading one later + // via `match` would be undefined behaviour. + _ => return std::ptr::null_mut(), + }; + let h = Box::new(sandlock_handler_t { + handler_fn, + ud, + ud_drop, + on_exception, + }); + Box::into_raw(h) +} + +/// Free a handler container that has *not* been registered with a +/// sandbox. After successful registration the supervisor owns the +/// handler; calling this on a registered handler is undefined behaviour +/// (the supervisor's later free would double-free). +/// +/// # Safety +/// `h` must be either null or a pointer previously returned by +/// `sandlock_handler_new` that has not yet been registered with the +/// supervisor and has not already been freed. +#[no_mangle] +pub unsafe extern "C" fn sandlock_handler_free(h: *mut sandlock_handler_t) { + if h.is_null() { return; } + drop(Box::from_raw(h)); +} diff --git a/crates/sandlock-ffi/tests/handler_smoke.rs b/crates/sandlock-ffi/tests/handler_smoke.rs index 8d8806e..8708e14 100644 --- a/crates/sandlock-ffi/tests/handler_smoke.rs +++ b/crates/sandlock-ffi/tests/handler_smoke.rs @@ -110,3 +110,54 @@ fn action_out_layout_is_stable() { assert_eq!(std::mem::size_of::(), 24); assert_eq!(std::mem::align_of::(), 8); } + +use sandlock_ffi::handler::{ + sandlock_exception_policy_t, sandlock_handler_free, sandlock_handler_fn_t, + sandlock_handler_new, sandlock_handler_t, +}; + +extern "C" fn test_handler( + _ud: *mut std::ffi::c_void, + _notif: *const sandlock_ffi::notif_repr::sandlock_notif_data_t, + _mem: *mut sandlock_ffi::handler::sandlock_mem_handle_t, + out: *mut sandlock_ffi::handler::sandlock_action_out_t, +) -> i32 { + unsafe { sandlock_ffi::handler::sandlock_action_set_continue(out) }; + 0 +} + +extern "C" fn dropper(ud: *mut std::ffi::c_void) { + // Reconstitute the Box we leaked in the test below. + unsafe { drop(Box::from_raw(ud as *mut u32)); } +} + +#[test] +fn handler_new_and_free_round_trip() { + let ud = Box::into_raw(Box::new(0xABCDu32)) as *mut std::ffi::c_void; + let on_ex = sandlock_exception_policy_t::Kill as u32; + let h: *mut sandlock_handler_t = unsafe { + sandlock_handler_new( + Some(test_handler as sandlock_handler_fn_t), + ud, + Some(dropper), + on_ex, + ) + }; + assert!(!h.is_null()); + unsafe { sandlock_handler_free(h) }; + // `dropper` runs and frees the Box; if it does not, leak-sanitizer + // (when enabled) will flag this test. +} + +#[test] +fn handler_new_rejects_invalid_exception_policy() { + let h = unsafe { + sandlock_handler_new( + Some(test_handler as sandlock_handler_fn_t), + std::ptr::null_mut(), + None, + 99u32, // out of range + ) + }; + assert!(h.is_null(), "expected null handle on invalid on_exception"); +} From 43e2a8ec52743da2523b637ef1ba71ae7388a467 Mon Sep 17 00:00:00 2001 From: dzerik Date: Tue, 12 May 2026 21:26:59 +0300 Subject: [PATCH 06/24] ffi: implement FfiHandler bridging C ABI to Handler trait Translates a sandlock_handler_t into a Handler impl by snapshotting the notification, allocating a mem handle and action out struct, then offloading the synchronous C callback to spawn_blocking. Failures (rc, unset action, or panic across FFI) trigger the configured exception policy. --- crates/sandlock-ffi/Cargo.toml | 3 +- crates/sandlock-ffi/include/sandlock.h | 8 ++ crates/sandlock-ffi/src/handler.rs | 157 +++++++++++++++++++++ crates/sandlock-ffi/tests/handler_smoke.rs | 67 +++++++++ 4 files changed, 234 insertions(+), 1 deletion(-) diff --git a/crates/sandlock-ffi/Cargo.toml b/crates/sandlock-ffi/Cargo.toml index 0a83175..8722299 100644 --- a/crates/sandlock-ffi/Cargo.toml +++ b/crates/sandlock-ffi/Cargo.toml @@ -12,10 +12,11 @@ description = "C ABI for sandlock process sandbox" crate-type = ["cdylib", "staticlib", "rlib"] [dependencies] +libc = "0.2" sandlock-core = { version = "0.7.0", path = "../sandlock-core" } serde_json = "1" tokio = { version = "1", features = ["rt-multi-thread"] } [dev-dependencies] -libc = "0.2" +tokio = { version = "1", features = ["macros"] } diff --git a/crates/sandlock-ffi/include/sandlock.h b/crates/sandlock-ffi/include/sandlock.h index 9fdeb45..df2799f 100644 --- a/crates/sandlock-ffi/include/sandlock.h +++ b/crates/sandlock-ffi/include/sandlock.h @@ -204,10 +204,18 @@ void sandlock_action_set_errno(sandlock_action_out_t *out, int32_t errno_value); void sandlock_action_set_return_value(sandlock_action_out_t *out, int64_t value); void sandlock_action_set_inject_fd_send(sandlock_action_out_t *out, int32_t srcfd, uint32_t newfd_flags); +/** NOTE: PR 1 of this feature accepts the tracker token for ABI + * completeness but the tracker callback is not yet wired and will + * not fire — the supervisor degrades this to a plain InjectFdSend. + * Do not synchronously wait on a tracker callback. */ void sandlock_action_set_inject_fd_send_tracked(sandlock_action_out_t *out, int32_t srcfd, uint32_t newfd_flags, sandlock_inject_tracker_t tracker); void sandlock_action_set_hold(sandlock_action_out_t *out); +/** Kill action setter. `pgid == 0` is a sentinel — the supervisor + * substitutes the child process's own pid as a best-effort pgid + * while the real-pgid wiring is being completed. To target a + * specific group, pass an explicit non-zero pgid. */ void sandlock_action_set_kill(sandlock_action_out_t *out, int32_t sig, int32_t pgid); typedef enum sandlock_exception_policy { diff --git a/crates/sandlock-ffi/src/handler.rs b/crates/sandlock-ffi/src/handler.rs index 1f967d4..09009ab 100644 --- a/crates/sandlock-ffi/src/handler.rs +++ b/crates/sandlock-ffi/src/handler.rs @@ -428,3 +428,160 @@ pub unsafe extern "C" fn sandlock_handler_free(h: *mut sandlock_handler_t) { if h.is_null() { return; } drop(Box::from_raw(h)); } + +use sandlock_core::seccomp::dispatch::{Handler, HandlerCtx}; +use sandlock_core::seccomp::notif::NotifAction; +use std::future::Future; +use std::os::unix::io::FromRawFd; +use std::pin::Pin; + +/// Rust adapter wrapping an owned `sandlock_handler_t` and implementing +/// `Handler`. Constructed when the supervisor accepts handlers passed +/// through `sandlock_run_with_handlers` (Task 7). +pub struct FfiHandler { + inner: Box, +} + +impl FfiHandler { + /// Take ownership of a raw `sandlock_handler_t*` produced by + /// `sandlock_handler_new`. + /// + /// # Safety + /// `raw` must be a non-null pointer returned by `sandlock_handler_new` + /// and never freed via `sandlock_handler_free`. After this call the + /// supervisor owns the container. + pub unsafe fn from_raw(raw: *mut sandlock_handler_t) -> Self { + assert!(!raw.is_null(), "FfiHandler::from_raw on null pointer"); + Self { inner: Box::from_raw(raw) } + } + + fn exception_action(&self, child_pgid: i32) -> NotifAction { + match self.inner.on_exception { + sandlock_exception_policy_t::Kill => { + NotifAction::Kill { sig: libc::SIGKILL, pgid: child_pgid } + } + sandlock_exception_policy_t::DenyEperm => NotifAction::Errno(libc::EPERM), + sandlock_exception_policy_t::Continue => NotifAction::Continue, + } + } +} + +/// `Send`/`Sync` wrapper around the C user-data pointer so it can travel +/// into `spawn_blocking`. The same `Send`/`Sync` reasoning that +/// justifies `unsafe impl Send/Sync for sandlock_handler_t` applies here +/// — the C caller is responsible for the pointer's thread-safety. +struct UdPtr(*mut std::ffi::c_void); +// Safety: see `sandlock_handler_t` Send/Sync rationale above. +unsafe impl Send for UdPtr {} +// Safety: see `sandlock_handler_t` Send/Sync rationale above. +unsafe impl Sync for UdPtr {} + +impl Handler for FfiHandler { + fn handle<'a>( + &'a self, + cx: &'a HandlerCtx, + ) -> Pin + Send + 'a>> { + // Capture the pieces we need by value so spawn_blocking can run + // the C callback on a worker thread without &self lifetime games. + let notif_snap = crate::notif_repr::sandlock_notif_data_t::from(&cx.notif); + let notif_fd = cx.notif_fd; + let notif_id = cx.notif.id; + let pid = cx.notif.pid; + let child_pgid = cx.notif.pid as i32; // best-effort; supervisor + // wires real pgid in Task 7. + let handler_fn = self.inner.handler_fn; + let ud = UdPtr(self.inner.ud); + let on_exception_fallback = self.exception_action(child_pgid); + + Box::pin(async move { + let join = tokio::task::spawn_blocking(move || { + // Rust 2021 disjoint closure captures (RFC 2229) would + // otherwise capture `ud.0` (a bare `*mut c_void`, not + // `Send`) rather than the whole `UdPtr`. Binding `ud` to + // a fresh local at the top of the closure forces a + // whole-struct capture so the `Send` impl on `UdPtr` + // applies to the outer closure. + let ud = ud; + let UdPtr(ud_raw) = ud; + let mut mem = sandlock_mem_handle_t::new(notif_fd, notif_id, pid); + let mut out = sandlock_action_out_t::zeroed(); + let rc = match handler_fn { + Some(f) => std::panic::catch_unwind(std::panic::AssertUnwindSafe( + || f(ud_raw, ¬if_snap, &mut mem, &mut out), + )), + None => Ok(-1), + }; + (rc, out) + }).await; + + let (rc_or_panic, out) = match join { + Ok(pair) => pair, + Err(_join_err) => return on_exception_fallback, + }; + + match rc_or_panic { + Ok(0) => translate_action(out, child_pgid) + .unwrap_or(on_exception_fallback), + _ => on_exception_fallback, + } + }) + } +} + +/// Convert the C-side decision into a `NotifAction`. Returns `None` if +/// the kind is `Unset` or unknown — the caller then falls back to the +/// exception policy. +fn translate_action(out: sandlock_action_out_t, child_pgid: i32) -> Option { + use sandlock_action_kind_t as K; + let kind = match out.kind { + x if x == K::Continue as u32 => K::Continue, + x if x == K::Errno as u32 => K::Errno, + x if x == K::ReturnValue as u32 => K::ReturnValue, + x if x == K::InjectFdSend as u32 => K::InjectFdSend, + x if x == K::InjectFdSendTracked as u32 => K::InjectFdSendTracked, + x if x == K::Hold as u32 => K::Hold, + x if x == K::Kill as u32 => K::Kill, + _ => return None, // Unset or unknown + }; + + // Safety: the `out.payload` union variant matched here was just + // selected by the `kind` discriminant above. The C action setters + // documented in this module pair each `kind` value with exactly one + // payload variant, so reading that variant is the only legal access. + // For `InjectFdSend{,Tracked}` the documented contract on + // `sandlock_action_set_inject_fd_send{,_tracked}` transfers + // ownership of `srcfd` to the supervisor; wrapping it in an + // `OwnedFd` here is what materialises that transfer. + let action = unsafe { + match kind { + K::Continue => NotifAction::Continue, + K::Errno => NotifAction::Errno(out.payload.errno), + K::ReturnValue => NotifAction::ReturnValue(out.payload.return_value), + K::Hold => NotifAction::Hold, + K::Kill => { + let pgid = if out.payload.kill.pgid == 0 { + child_pgid + } else { + out.payload.kill.pgid + }; + NotifAction::Kill { sig: out.payload.kill.sig, pgid } + } + K::InjectFdSend => NotifAction::InjectFdSend { + srcfd: std::os::unix::io::OwnedFd::from_raw_fd(out.payload.inject_send.srcfd), + newfd_flags: out.payload.inject_send.newfd_flags, + }, + K::InjectFdSendTracked => { + // Tracker callbacks are not yet wired — PR 1 accepts the + // variant for ABI completeness but degrades to plain + // InjectFdSend until Task 7 adds the tracker registry. + NotifAction::InjectFdSend { + srcfd: std::os::unix::io::OwnedFd::from_raw_fd( + out.payload.inject_send_tracked.srcfd), + newfd_flags: out.payload.inject_send_tracked.newfd_flags, + } + } + K::Unset => unreachable!(), + } + }; + Some(action) +} diff --git a/crates/sandlock-ffi/tests/handler_smoke.rs b/crates/sandlock-ffi/tests/handler_smoke.rs index 8708e14..fddab24 100644 --- a/crates/sandlock-ffi/tests/handler_smoke.rs +++ b/crates/sandlock-ffi/tests/handler_smoke.rs @@ -161,3 +161,70 @@ fn handler_new_rejects_invalid_exception_policy() { }; assert!(h.is_null(), "expected null handle on invalid on_exception"); } + +use sandlock_core::seccomp::dispatch::{Handler, HandlerCtx}; +use sandlock_core::seccomp::notif::NotifAction; +use sandlock_core::{SeccompData, SeccompNotif}; +use sandlock_ffi::handler::FfiHandler; + +fn fake_ctx() -> HandlerCtx { + HandlerCtx { + notif: SeccompNotif { + id: 1, pid: std::process::id(), flags: 0, + data: SeccompData { nr: 39, arch: 0xC000003E, + instruction_pointer: 0, args: [0; 6] }, + }, + notif_fd: -1, + } +} + +extern "C" fn return_value_42( + _ud: *mut std::ffi::c_void, + _notif: *const sandlock_ffi::notif_repr::sandlock_notif_data_t, + _mem: *mut sandlock_ffi::handler::sandlock_mem_handle_t, + out: *mut sandlock_ffi::handler::sandlock_action_out_t, +) -> i32 { + unsafe { sandlock_ffi::handler::sandlock_action_set_return_value(out, 42) }; + 0 +} + +extern "C" fn returns_error_with_unset_action( + _ud: *mut std::ffi::c_void, + _notif: *const sandlock_ffi::notif_repr::sandlock_notif_data_t, + _mem: *mut sandlock_ffi::handler::sandlock_mem_handle_t, + _out: *mut sandlock_ffi::handler::sandlock_action_out_t, +) -> i32 { + -1 +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn ffi_handler_translates_return_value() { + let raw = unsafe { + sandlock_ffi::handler::sandlock_handler_new( + Some(return_value_42), + std::ptr::null_mut(), + None, + sandlock_exception_policy_t::Kill as u32, + ) + }; + let h = unsafe { FfiHandler::from_raw(raw) }; + let cx = fake_ctx(); + let action = h.handle(&cx).await; + assert!(matches!(action, NotifAction::ReturnValue(42))); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn ffi_handler_applies_exception_policy_on_failure() { + let raw = unsafe { + sandlock_ffi::handler::sandlock_handler_new( + Some(returns_error_with_unset_action), + std::ptr::null_mut(), + None, + sandlock_exception_policy_t::DenyEperm as u32, + ) + }; + let h = unsafe { FfiHandler::from_raw(raw) }; + let cx = fake_ctx(); + let action = h.handle(&cx).await; + assert!(matches!(action, NotifAction::Errno(e) if e == libc::EPERM)); +} From 54ebac02b66d74aea26b9028351f1a8f29f7db88 Mon Sep 17 00:00:00 2001 From: dzerik Date: Tue, 12 May 2026 21:42:08 +0300 Subject: [PATCH 07/24] ffi: expose sandlock_run_with_handlers entry points New C entry points that pair the existing policy/argv surface with the handler ABI. Each handler registration's ownership transfers into the run; the supervisor frees them when the run completes. --- crates/sandlock-ffi/include/sandlock.h | 21 +++ crates/sandlock-ffi/src/handler.rs | 183 ++++++++++++++++++++- crates/sandlock-ffi/src/lib.rs | 18 ++ crates/sandlock-ffi/tests/handler_smoke.rs | 114 +++++++++++++ 4 files changed, 329 insertions(+), 7 deletions(-) diff --git a/crates/sandlock-ffi/include/sandlock.h b/crates/sandlock-ffi/include/sandlock.h index df2799f..232d153 100644 --- a/crates/sandlock-ffi/include/sandlock.h +++ b/crates/sandlock-ffi/include/sandlock.h @@ -247,6 +247,27 @@ sandlock_handler_t *sandlock_handler_new(sandlock_handler_fn_t handler_fn, /** Free a handler container that has not been handed to the supervisor. */ void sandlock_handler_free(sandlock_handler_t *h); +typedef struct sandlock_handler_registration_t { + int64_t syscall_nr; + sandlock_handler_t *handler; /* ownership transferred on a successful run */ +} sandlock_handler_registration_t; + +/** Run the policy with extra C handlers. Ownership of each + * `registrations[i].handler` transfers into the call: do not free + * those pointers afterwards. Returns NULL on failure. */ +sandlock_result_t *sandlock_run_with_handlers( + const sandlock_sandbox_t *policy, + const char *const *argv, unsigned int argc, + const sandlock_handler_registration_t *registrations, + size_t nregistrations); + +/** Interactive-stdio variant of `sandlock_run_with_handlers`. */ +sandlock_result_t *sandlock_run_interactive_with_handlers( + const sandlock_sandbox_t *policy, + const char *const *argv, unsigned int argc, + const sandlock_handler_registration_t *registrations, + size_t nregistrations); + #ifdef __cplusplus } #endif diff --git a/crates/sandlock-ffi/src/handler.rs b/crates/sandlock-ffi/src/handler.rs index 09009ab..90828ca 100644 --- a/crates/sandlock-ffi/src/handler.rs +++ b/crates/sandlock-ffi/src/handler.rs @@ -466,15 +466,15 @@ impl FfiHandler { } } -/// `Send`/`Sync` wrapper around the C user-data pointer so it can travel -/// into `spawn_blocking`. The same `Send`/`Sync` reasoning that -/// justifies `unsafe impl Send/Sync for sandlock_handler_t` applies here -/// — the C caller is responsible for the pointer's thread-safety. +/// `Send`-only wrapper around the C user-data pointer so it can travel +/// into `spawn_blocking`. Only the move (not sharing across threads) is +/// required; the deeper Send/Sync rationale for the underlying handler +/// container lives on `sandlock_handler_t`. struct UdPtr(*mut std::ffi::c_void); -// Safety: see `sandlock_handler_t` Send/Sync rationale above. +// Safety: ud is opaque to Rust; the spawn_blocking pipeline only moves +// (not shares) the wrapper. See `sandlock_handler_t` for the deeper +// Send/Sync rationale that justifies the underlying handler container. unsafe impl Send for UdPtr {} -// Safety: see `sandlock_handler_t` Send/Sync rationale above. -unsafe impl Sync for UdPtr {} impl Handler for FfiHandler { fn handle<'a>( @@ -585,3 +585,172 @@ fn translate_action(out: sandlock_action_out_t, child_pgid: i32) -> Option Option> { + if argv.is_null() { + return None; + } + let mut out = Vec::with_capacity(argc as usize); + for i in 0..(argc as isize) { + let p = unsafe { *argv.offset(i) }; + if p.is_null() { + return None; + } + let s = unsafe { CStr::from_ptr(p) }.to_str().ok()?.to_owned(); + out.push(s); + } + Some(out) +} + +fn collect_registrations( + regs: *const sandlock_handler_registration_t, + nregs: usize, +) -> Option> { + if regs.is_null() && nregs > 0 { + return None; + } + if nregs == 0 { + return Some(Vec::new()); + } + let slice = unsafe { slice::from_raw_parts(regs, nregs) }; + // First pass: validate all entries before taking ownership of any. + // Without this, a null pointer at index k+1 would leave us having + // already consumed handlers [0..k] via `Box::from_raw`; dropping the + // partial `out` would free them while the C caller still believes it + // owns the originals — a latent double-free via + // `sandlock_handler_free`. + for r in slice { + if r.handler.is_null() { + return None; + } + } + // Second pass: ownership transfer. Every pointer is non-null per the + // pass above. + let mut out = Vec::with_capacity(nregs); + for r in slice { + // SAFETY: validated non-null above; caller provided pointer from + // `sandlock_handler_new` and must not reuse after this call (the + // public C ABI doc states ownership transfers in). + let h = unsafe { FfiHandler::from_raw(r.handler) }; + out.push((r.syscall_nr, h)); + } + Some(out) +} + +fn block_on_run( + sandbox: &Sandbox, + cmd: Vec, + handlers: Vec<(i64, FfiHandler)>, + interactive: bool, +) -> Option> { + // Use a fresh runtime — sandlock-core already pulls in tokio with + // rt-multi-thread; this matches the pattern used by the existing + // `sandlock_run` path. A panic in an `extern "C"`-reachable path is + // UB, so we report runtime-build failure to the caller via `None` + // instead of unwrapping. + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .ok()?; + let cmd_refs: Vec<&str> = cmd.iter().map(String::as_str).collect(); + let mut sb = sandbox.clone(); + Some(rt.block_on(async move { + if interactive { + sb.run_interactive_with_extra_handlers(&cmd_refs, handlers).await + } else { + sb.run_with_extra_handlers(&cmd_refs, handlers).await + } + })) +} + +/// Run the policy with extra C handlers. Returns NULL on failure. +/// +/// # Safety +/// All pointer arguments must be valid for their documented lifetimes: +/// `policy` must come from `sandlock_sandbox_build`, `argv` must be a +/// readable array of `argc` NUL-terminated strings, and each handler +/// pointer must come from `sandlock_handler_new` and must not be reused +/// after this call (ownership transfers in). +#[no_mangle] +pub unsafe extern "C" fn sandlock_run_with_handlers( + policy: *const crate::sandlock_sandbox_t, + argv: *const *const std::os::raw::c_char, + argc: u32, + registrations: *const sandlock_handler_registration_t, + nregistrations: usize, +) -> *mut crate::sandlock_result_t { + run_with_handlers_inner(policy, argv, argc, registrations, nregistrations, false) +} + +/// Interactive-stdio variant of `sandlock_run_with_handlers`. +/// +/// # Safety +/// Same constraints as `sandlock_run_with_handlers`. +#[no_mangle] +pub unsafe extern "C" fn sandlock_run_interactive_with_handlers( + policy: *const crate::sandlock_sandbox_t, + argv: *const *const std::os::raw::c_char, + argc: u32, + registrations: *const sandlock_handler_registration_t, + nregistrations: usize, +) -> *mut crate::sandlock_result_t { + run_with_handlers_inner(policy, argv, argc, registrations, nregistrations, true) +} + +unsafe fn run_with_handlers_inner( + policy: *const crate::sandlock_sandbox_t, + argv: *const *const std::os::raw::c_char, + argc: u32, + registrations: *const sandlock_handler_registration_t, + nregistrations: usize, + interactive: bool, +) -> *mut crate::sandlock_result_t { + if policy.is_null() { + return std::ptr::null_mut(); + } + let cmd = match argv_from_c(argv, argc) { + Some(v) => v, + None => return std::ptr::null_mut(), + }; + let handlers = match collect_registrations(registrations, nregistrations) { + Some(v) => v, + None => return std::ptr::null_mut(), + }; + let sandbox_ref: &Sandbox = (*policy).inner(); + match block_on_run(sandbox_ref, cmd, handlers, interactive) { + Some(Ok(rr)) => { + let boxed = Box::new(crate::sandlock_result_t::from_run_result(rr)); + Box::into_raw(boxed) + } + _ => std::ptr::null_mut(), + } +} diff --git a/crates/sandlock-ffi/src/lib.rs b/crates/sandlock-ffi/src/lib.rs index fcf8594..d6b165a 100644 --- a/crates/sandlock-ffi/src/lib.rs +++ b/crates/sandlock-ffi/src/lib.rs @@ -25,6 +25,24 @@ pub struct sandlock_sandbox_t { _private: Sandbox, } +impl sandlock_sandbox_t { + /// Crate-private accessor used by `handler.rs` to reach the inner + /// `Sandbox` when wiring `sandlock_run_with_handlers`. Public-API + /// callers still go through the opaque-pointer functions in this + /// module. + pub(crate) fn inner(&self) -> &Sandbox { + &self._private + } +} + +impl sandlock_result_t { + /// Crate-private constructor used by `handler.rs` to wrap a + /// freshly-produced [`RunResult`] in the opaque public type. + pub(crate) fn from_run_result(rr: RunResult) -> Self { + Self { _private: rr } + } +} + /// Opaque handle wrapping a [`RunResult`]. #[repr(C)] pub struct sandlock_result_t { diff --git a/crates/sandlock-ffi/tests/handler_smoke.rs b/crates/sandlock-ffi/tests/handler_smoke.rs index fddab24..7c31e37 100644 --- a/crates/sandlock-ffi/tests/handler_smoke.rs +++ b/crates/sandlock-ffi/tests/handler_smoke.rs @@ -228,3 +228,117 @@ async fn ffi_handler_applies_exception_policy_on_failure() { let action = h.handle(&cx).await; assert!(matches!(action, NotifAction::Errno(e) if e == libc::EPERM)); } + +use std::ffi::CString; +use sandlock_ffi::handler::{ + sandlock_handler_registration_t, sandlock_run_with_handlers, +}; + +extern "C" fn force_getpid_to_777( + _ud: *mut std::ffi::c_void, + _notif: *const sandlock_ffi::notif_repr::sandlock_notif_data_t, + _mem: *mut sandlock_ffi::handler::sandlock_mem_handle_t, + out: *mut sandlock_ffi::handler::sandlock_action_out_t, +) -> i32 { + unsafe { sandlock_ffi::handler::sandlock_action_set_return_value(out, 777) }; + 0 +} + +#[test] +fn run_with_handlers_intercepts_getpid() { + use sandlock_ffi::*; // bring in builder + result symbols + + let builder = sandlock_sandbox_builder_new(); + // Allow the runtime bits the child needs. The exact mounts mirror + // sandlock's own integration tests — read-only access to the system + // libraries and the python interpreter, plus a writable /tmp. + let builder = unsafe { + let p = CString::new("/usr").unwrap(); + sandlock_sandbox_builder_fs_read(builder, p.as_ptr()) + }; + let builder = unsafe { + let p = CString::new("/bin").unwrap(); + sandlock_sandbox_builder_fs_read(builder, p.as_ptr()) + }; + let builder = unsafe { + let p = CString::new("/lib").unwrap(); + sandlock_sandbox_builder_fs_read(builder, p.as_ptr()) + }; + let builder = unsafe { + let p = CString::new("/lib64").unwrap(); + sandlock_sandbox_builder_fs_read(builder, p.as_ptr()) + }; + let builder = unsafe { + let p = CString::new("/etc").unwrap(); + sandlock_sandbox_builder_fs_read(builder, p.as_ptr()) + }; + let builder = unsafe { + let p = CString::new("/tmp").unwrap(); + sandlock_sandbox_builder_fs_write(builder, p.as_ptr()) + }; + + let policy = { + let mut err: i32 = 0; + unsafe { sandlock_sandbox_build(builder, &mut err, std::ptr::null_mut()) } + }; + assert!(!policy.is_null(), "policy build failed"); + + let handler = unsafe { + handler::sandlock_handler_new( + Some(force_getpid_to_777), + std::ptr::null_mut(), + None, + handler::sandlock_exception_policy_t::Kill as u32, + ) + }; + assert!(!handler.is_null(), "handler_new returned null"); + let registrations = [sandlock_handler_registration_t { + syscall_nr: libc::SYS_getpid, + handler, + }]; + + let script = CString::new( + "import os, sys; sys.stdout.write(str(os.getpid()))", + ).unwrap(); + // Use the system python3 directly. Running through `/usr/bin/env + // python3` would pick up any venv shim in $PATH whose pyvenv.cfg + // sits outside the sandbox's read allowlist and fail before our + // handler ever gets a chance to fire. + let arg0 = CString::new("/usr/bin/python3").unwrap(); + let arg1 = CString::new("-c").unwrap(); + let argv = [ + arg0.as_ptr(), + arg1.as_ptr(), + script.as_ptr(), + ]; + + let rr = unsafe { + sandlock_run_with_handlers( + policy, + argv.as_ptr(), + argv.len() as u32, + registrations.as_ptr(), + registrations.len(), + ) + }; + assert!(!rr.is_null(), "sandlock_run_with_handlers returned null"); + let stdout = unsafe { + let mut len: usize = 0; + let p = sandlock_result_stdout_bytes(rr, &mut len); + if p.is_null() { Vec::new() } else { std::slice::from_raw_parts(p, len).to_vec() } + }; + let stderr = unsafe { + let mut len: usize = 0; + let p = sandlock_result_stderr_bytes(rr, &mut len); + if p.is_null() { Vec::new() } else { std::slice::from_raw_parts(p, len).to_vec() } + }; + let stdout_str = String::from_utf8_lossy(&stdout); + let stderr_str = String::from_utf8_lossy(&stderr); + let exit_code = unsafe { sandlock_result_exit_code(rr) }; + assert!(stdout_str.contains("777"), + "expected getpid to be intercepted; exit={} stdout={:?} stderr={:?}", + exit_code, stdout_str, stderr_str); + + unsafe { sandlock_result_free(rr); } + unsafe { sandlock_sandbox_free(policy); } +} From 2373c41f673597a4d4b4bf231de05446163d6fc6 Mon Sep 17 00:00:00 2001 From: dzerik Date: Tue, 12 May 2026 21:50:43 +0300 Subject: [PATCH 08/24] ffi: add pure-C smoke test for handler ABI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Canonical example for downstream bindings — compiles handler_smoke.c against sandlock.h, links the cdylib, runs it from a cargo test. --- crates/sandlock-ffi/include/sandlock.h | 33 +++++- crates/sandlock-ffi/tests/c/handler_smoke.c | 108 ++++++++++++++++++++ crates/sandlock-ffi/tests/c_smoke.rs | 52 ++++++++++ 3 files changed, 189 insertions(+), 4 deletions(-) create mode 100644 crates/sandlock-ffi/tests/c/handler_smoke.c create mode 100644 crates/sandlock-ffi/tests/c_smoke.rs diff --git a/crates/sandlock-ffi/include/sandlock.h b/crates/sandlock-ffi/include/sandlock.h index 232d153..0e7204b 100644 --- a/crates/sandlock-ffi/include/sandlock.h +++ b/crates/sandlock-ffi/include/sandlock.h @@ -252,16 +252,41 @@ typedef struct sandlock_handler_registration_t { sandlock_handler_t *handler; /* ownership transferred on a successful run */ } sandlock_handler_registration_t; -/** Run the policy with extra C handlers. Ownership of each - * `registrations[i].handler` transfers into the call: do not free - * those pointers afterwards. Returns NULL on failure. */ +/** Run the policy with extra C handlers. Returns NULL on failure. + * + * Ownership of `registrations[i].handler` is transferred into the call + * after the function has validated and accepted the registration array. + * On success (non-NULL return) all handler pointers are owned by the + * supervisor and must not be freed by the caller. + * + * On NULL return the transfer status of handler pointers is not + * defined: depending on which internal validation step failed, the + * supervisor may or may not have taken ownership of some handlers. + * The conservative and safe approach is to ABANDON all handler + * pointers after a NULL return — do not call `sandlock_handler_free` + * on them. The cost of the resulting leak is bounded (one allocation + * per handler) and the alternative risks double-free. */ sandlock_result_t *sandlock_run_with_handlers( const sandlock_sandbox_t *policy, const char *const *argv, unsigned int argc, const sandlock_handler_registration_t *registrations, size_t nregistrations); -/** Interactive-stdio variant of `sandlock_run_with_handlers`. */ +/** Interactive-stdio variant of `sandlock_run_with_handlers`. Returns + * NULL on failure. + * + * Ownership of `registrations[i].handler` is transferred into the call + * after the function has validated and accepted the registration array. + * On success (non-NULL return) all handler pointers are owned by the + * supervisor and must not be freed by the caller. + * + * On NULL return the transfer status of handler pointers is not + * defined: depending on which internal validation step failed, the + * supervisor may or may not have taken ownership of some handlers. + * The conservative and safe approach is to ABANDON all handler + * pointers after a NULL return — do not call `sandlock_handler_free` + * on them. The cost of the resulting leak is bounded (one allocation + * per handler) and the alternative risks double-free. */ sandlock_result_t *sandlock_run_interactive_with_handlers( const sandlock_sandbox_t *policy, const char *const *argv, unsigned int argc, diff --git a/crates/sandlock-ffi/tests/c/handler_smoke.c b/crates/sandlock-ffi/tests/c/handler_smoke.c new file mode 100644 index 0000000..03479d0 --- /dev/null +++ b/crates/sandlock-ffi/tests/c/handler_smoke.c @@ -0,0 +1,108 @@ +/* Canonical C example for sandlock's Handler ABI. + * + * Builds a sandbox, registers a single handler on SYS_getpid that + * forces a synthetic return value of 777, runs the system python3 + * interpreter with an inline script that prints os.getpid(), and + * asserts that the captured stdout contains "777". + * + * Downstream consumers writing C/Python/etc. bindings can copy this + * file as a starting point. + */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include + +#include "sandlock.h" + +static int force_getpid_to_777( + void *ud, + const sandlock_notif_data_t *notif, + sandlock_mem_handle_t *mem, + sandlock_action_out_t *out +) { + (void)ud; + (void)notif; + (void)mem; + sandlock_action_set_return_value(out, 777); + return 0; +} + +int main(void) { + /* Build a sandbox that exposes just enough of the host for the + * system python3 interpreter to start. Mirrors the read mounts + * from the Rust integration test in tests/handler_smoke.rs. */ + sandlock_builder_t *b = sandlock_sandbox_builder_new(); + b = sandlock_sandbox_builder_fs_read(b, "/usr"); + b = sandlock_sandbox_builder_fs_read(b, "/bin"); + b = sandlock_sandbox_builder_fs_read(b, "/lib"); + b = sandlock_sandbox_builder_fs_read(b, "/lib64"); + b = sandlock_sandbox_builder_fs_read(b, "/etc"); + b = sandlock_sandbox_builder_fs_write(b, "/tmp"); + + int err = 0; + sandlock_sandbox_t *p = sandlock_sandbox_build(b, &err, NULL); + if (p == NULL) { + fprintf(stderr, "sandlock: sandbox build failed: err=%d\n", err); + return 1; + } + + sandlock_handler_t *h = sandlock_handler_new( + force_getpid_to_777, NULL, NULL, SANDLOCK_EXCEPTION_KILL); + if (h == NULL) { + fprintf(stderr, "sandlock: handler_new returned NULL\n"); + sandlock_sandbox_free(p); + return 1; + } + + sandlock_handler_registration_t regs[1] = { + { .syscall_nr = SYS_getpid, .handler = h }, + }; + + /* Invoke python3 directly (no `/usr/bin/env` shim) so the + * interpreter does not chase venv pyvenv.cfg files outside the + * sandbox's read allowlist. */ + const char *argv[] = { + "/usr/bin/python3", + "-c", + "import os, sys; sys.stdout.write('GOT:' + str(os.getpid()))", + }; + + sandlock_result_t *rr = sandlock_run_with_handlers( + p, argv, 3, regs, 1); + if (rr == NULL) { + fprintf(stderr, "sandlock: run_with_handlers returned NULL\n"); + /* Per sandlock.h: on NULL return, do NOT free handler `h` — + * ownership transfer state is undefined and freeing risks + * double-free. The leak is bounded (one handler box). */ + sandlock_sandbox_free(p); + return 1; + } + + size_t len = 0; + const uint8_t *stdout_bytes = sandlock_result_stdout_bytes(rr, &len); + if (stdout_bytes == NULL) { + fprintf(stderr, "sandlock: no stdout captured\n"); + sandlock_result_free(rr); + sandlock_sandbox_free(p); + return 1; + } + fwrite(stdout_bytes, 1, len, stdout); + fputc('\n', stdout); + + int contains_777 = + (memmem(stdout_bytes, len, "GOT:777", 7) != NULL); + + sandlock_result_free(rr); + sandlock_sandbox_free(p); + + if (!contains_777) { + fprintf(stderr, "expected 'GOT:777' in child stdout\n"); + return 1; + } + return 0; +} diff --git a/crates/sandlock-ffi/tests/c_smoke.rs b/crates/sandlock-ffi/tests/c_smoke.rs new file mode 100644 index 0000000..a1c8ad2 --- /dev/null +++ b/crates/sandlock-ffi/tests/c_smoke.rs @@ -0,0 +1,52 @@ +//! Compile and run the pure-C smoke test against the cdylib. + +#[test] +fn c_smoke_compiles_and_runs() { + use std::path::PathBuf; + use std::process::Command; + + let out_dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")); + let bin = out_dir.join("handler_smoke"); + let profile = if cfg!(debug_assertions) { "debug" } else { "release" }; + let cdylib_dir = std::env::var_os("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .join("target") + }) + .join(profile); + + let rpath_arg = format!("-Wl,-rpath,{}", cdylib_dir.to_str().unwrap()); + + let status = Command::new("cc") + .args([ + "-std=c11", + "-Wall", + "-Wextra", + "-Werror", + "-I", + concat!(env!("CARGO_MANIFEST_DIR"), "/include"), + concat!(env!("CARGO_MANIFEST_DIR"), "/tests/c/handler_smoke.c"), + "-L", + cdylib_dir.to_str().unwrap(), + &rpath_arg, + "-lsandlock_ffi", + "-o", + bin.to_str().unwrap(), + ]) + .status() + .expect("cc invocation"); + assert!(status.success(), "C compile failed"); + + let out = Command::new(&bin).output().expect("run handler_smoke"); + assert!( + out.status.success(), + "handler_smoke exited non-zero: stdout={:?} stderr={:?}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); +} From 4547343ae071d5841bbdc603a287cc11b2a63ccb Mon Sep 17 00:00:00 2001 From: dzerik Date: Tue, 12 May 2026 22:00:21 +0300 Subject: [PATCH 09/24] docs: document the C ABI handler surface Adds a section to extension-handlers.md describing the new C ABI, ownership rules, callback contract, and a pointer to the canonical smoke test. --- docs/extension-handlers.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/extension-handlers.md b/docs/extension-handlers.md index cd24e2e..cca3ca2 100644 --- a/docs/extension-handlers.md +++ b/docs/extension-handlers.md @@ -604,3 +604,32 @@ For a single concrete handler type the bare struct works without the `Box::new` The crate links against `sandlock-core` as an ordinary dependency — no fork, no `[patch.crates-io]`, no duplication of `notif::supervisor`. + +## C ABI + +The same handler model is available to non-Rust callers via the +`sandlock-ffi` cdylib (header: `crates/sandlock-ffi/include/sandlock.h`). + +### Lifetimes + +| Object | Allocated by | Freed by | +|--------------------------------|----------------------------------------|---------------------------------------------| +| `sandlock_handler_t*` | `sandlock_handler_new` | `sandlock_handler_free` (if never registered)
or the supervisor (after a successful `sandlock_run_with_handlers`) | +| `sandlock_action_out_t` | Rust adapter (stack), pointer to C | Adapter (stack-scoped to one callback) | +| `sandlock_mem_handle_t*` | Rust adapter (stack) | Adapter (do not retain past callback return) | +| `sandlock_notif_data_t` | Rust adapter (stack), pointer to C | Adapter (do not retain past callback return) | + +### Callback contract + +A C handler must: + +1. Return `0` exactly when it has called one — and only one — of the + `sandlock_action_set_*` setters on `out`. +2. Return non-zero on any internal error. The supervisor then applies + the handler's `on_exception` policy (default: `SANDLOCK_EXCEPTION_KILL`). +3. Not retain `notif`, `mem`, or `out` past the return statement. + +### Minimal example + +See `crates/sandlock-ffi/tests/c/handler_smoke.c` for the canonical +end-to-end example. From 4fdfecb05106601434682ae4023a2fd0e29b1c8e Mon Sep 17 00:00:00 2001 From: dzerik Date: Wed, 13 May 2026 10:32:52 +0300 Subject: [PATCH 10/24] test: expand handler ABI coverage Adds tests for previously uncovered paths in the handler ABI: all NotifAction translation variants, exception policy fallbacks, panic recovery via catch_unwind, Unset action handling, handler construction edge cases, run-path failure modes, and a live-fd mem_read_cstr smoke against an intercepted openat. --- crates/sandlock-ffi/tests/handler_smoke.rs | 827 +++++++++++++++++++++ 1 file changed, 827 insertions(+) diff --git a/crates/sandlock-ffi/tests/handler_smoke.rs b/crates/sandlock-ffi/tests/handler_smoke.rs index 7c31e37..c498941 100644 --- a/crates/sandlock-ffi/tests/handler_smoke.rs +++ b/crates/sandlock-ffi/tests/handler_smoke.rs @@ -342,3 +342,830 @@ fn run_with_handlers_intercepts_getpid() { unsafe { sandlock_result_free(rr); } unsafe { sandlock_sandbox_free(policy); } } + +// --------------------------------------------------------------------------- +// Expanded coverage (Task 10) +// --------------------------------------------------------------------------- +// +// The tests below probe each remaining branch of the handler ABI surface: +// +// * Group A: setters for the inject-fd variants and null-pointer safety. +// * Group B: every `NotifAction` translation the dispatcher must produce. +// * Group C: exception-policy fallbacks beyond the default `DenyEperm`. +// * Group D: panic recovery across the FFI boundary. +// * Group E: `Unset` action when the callback returns 0 but never sets one. +// * Group F: `sandlock_handler_new` edge cases (null fn / null ud + dropper). +// * Group G: `sandlock_run_with_handlers` failure paths and ownership. +// * Group H: multiple handlers each firing for their own syscall. +// * Group I: live-fd `sandlock_mem_read_cstr` via an intercepted `openat`. +// +// Style mirrors the existing end-to-end test: explicit `extern "C"` handler +// fns, no helper macros, `assert!(matches!(...))` for action variants. + +use sandlock_ffi::handler::{ + sandlock_action_set_inject_fd_send, sandlock_action_set_inject_fd_send_tracked, +}; + +// ---- Group A: action setters -------------------------------------------- + +#[test] +fn action_inject_fd_send_setter_records_payload() { + let mut a = sandlock_action_out_t::zeroed(); + // O_CLOEXEC is the canonical flag a handler would pass through. + unsafe { sandlock_action_set_inject_fd_send(&mut a, 42, 0o2000000) }; + assert_eq!(a.kind, sandlock_action_kind_t::InjectFdSend as u32); + // Safety: kind == InjectFdSend selects the `inject_send` union arm + // (matches the ABI contract documented on the setter). + assert_eq!(unsafe { a.payload.inject_send.srcfd }, 42); + assert_eq!(unsafe { a.payload.inject_send.newfd_flags }, 0o2000000); +} + +#[test] +fn action_inject_fd_send_tracked_setter_records_payload() { + let mut a = sandlock_action_out_t::zeroed(); + unsafe { sandlock_action_set_inject_fd_send_tracked(&mut a, 17, 0, 0xDEAD_BEEF) }; + assert_eq!(a.kind, sandlock_action_kind_t::InjectFdSendTracked as u32); + // Safety: kind == InjectFdSendTracked selects the `inject_send_tracked` + // union arm. + assert_eq!(unsafe { a.payload.inject_send_tracked.srcfd }, 17); + assert_eq!(unsafe { a.payload.inject_send_tracked.newfd_flags }, 0); + assert_eq!(unsafe { a.payload.inject_send_tracked.tracker }, 0xDEAD_BEEF); +} + +#[test] +fn action_setters_are_null_safe() { + // Safety: each setter documents null as a no-op; this test is the + // executable form of that contract. If any setter dereferences null + // the process aborts and the test reports failure. + unsafe { + sandlock_action_set_continue(std::ptr::null_mut()); + sandlock_action_set_errno(std::ptr::null_mut(), 13); + sandlock_action_set_return_value(std::ptr::null_mut(), -1); + sandlock_action_set_hold(std::ptr::null_mut()); + sandlock_action_set_kill(std::ptr::null_mut(), libc::SIGKILL, 0); + sandlock_action_set_inject_fd_send(std::ptr::null_mut(), 0, 0); + sandlock_action_set_inject_fd_send_tracked(std::ptr::null_mut(), 0, 0, 0); + } +} + +// ---- Group B: FfiHandler translation ------------------------------------ +// +// Each variant gets its own explicit `extern "C"` handler so the test +// retains the line-by-line transparency of the existing tests rather than +// hiding setup behind a macro. + +extern "C" fn handler_set_continue( + _ud: *mut std::ffi::c_void, + _notif: *const sandlock_ffi::notif_repr::sandlock_notif_data_t, + _mem: *mut sandlock_ffi::handler::sandlock_mem_handle_t, + out: *mut sandlock_ffi::handler::sandlock_action_out_t, +) -> i32 { + unsafe { sandlock_ffi::handler::sandlock_action_set_continue(out) }; + 0 +} + +extern "C" fn handler_set_errno_eacces( + _ud: *mut std::ffi::c_void, + _notif: *const sandlock_ffi::notif_repr::sandlock_notif_data_t, + _mem: *mut sandlock_ffi::handler::sandlock_mem_handle_t, + out: *mut sandlock_ffi::handler::sandlock_action_out_t, +) -> i32 { + unsafe { sandlock_ffi::handler::sandlock_action_set_errno(out, 13) }; + 0 +} + +extern "C" fn handler_set_hold( + _ud: *mut std::ffi::c_void, + _notif: *const sandlock_ffi::notif_repr::sandlock_notif_data_t, + _mem: *mut sandlock_ffi::handler::sandlock_mem_handle_t, + out: *mut sandlock_ffi::handler::sandlock_action_out_t, +) -> i32 { + unsafe { sandlock_ffi::handler::sandlock_action_set_hold(out) }; + 0 +} + +extern "C" fn handler_set_kill_sigterm_1234( + _ud: *mut std::ffi::c_void, + _notif: *const sandlock_ffi::notif_repr::sandlock_notif_data_t, + _mem: *mut sandlock_ffi::handler::sandlock_mem_handle_t, + out: *mut sandlock_ffi::handler::sandlock_action_out_t, +) -> i32 { + unsafe { sandlock_ffi::handler::sandlock_action_set_kill(out, libc::SIGTERM, 1234) }; + 0 +} + +extern "C" fn handler_set_kill_sigkill_zero_pgid( + _ud: *mut std::ffi::c_void, + _notif: *const sandlock_ffi::notif_repr::sandlock_notif_data_t, + _mem: *mut sandlock_ffi::handler::sandlock_mem_handle_t, + out: *mut sandlock_ffi::handler::sandlock_action_out_t, +) -> i32 { + unsafe { sandlock_ffi::handler::sandlock_action_set_kill(out, libc::SIGKILL, 0) }; + 0 +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn ffi_handler_translates_continue() { + let raw = unsafe { + sandlock_ffi::handler::sandlock_handler_new( + Some(handler_set_continue), + std::ptr::null_mut(), + None, + sandlock_exception_policy_t::Kill as u32, + ) + }; + // Safety: `raw` was just produced by `sandlock_handler_new` and is + // non-null; ownership transfers into the adapter. + let h = unsafe { FfiHandler::from_raw(raw) }; + let action = h.handle(&fake_ctx()).await; + assert!(matches!(action, NotifAction::Continue)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn ffi_handler_translates_errno() { + let raw = unsafe { + sandlock_ffi::handler::sandlock_handler_new( + Some(handler_set_errno_eacces), + std::ptr::null_mut(), + None, + sandlock_exception_policy_t::Kill as u32, + ) + }; + // Safety: see `ffi_handler_translates_continue`. + let h = unsafe { FfiHandler::from_raw(raw) }; + let action = h.handle(&fake_ctx()).await; + assert!(matches!(action, NotifAction::Errno(e) if e == 13)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn ffi_handler_translates_hold() { + let raw = unsafe { + sandlock_ffi::handler::sandlock_handler_new( + Some(handler_set_hold), + std::ptr::null_mut(), + None, + sandlock_exception_policy_t::Kill as u32, + ) + }; + // Safety: see `ffi_handler_translates_continue`. + let h = unsafe { FfiHandler::from_raw(raw) }; + let action = h.handle(&fake_ctx()).await; + assert!(matches!(action, NotifAction::Hold)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn ffi_handler_translates_kill_with_explicit_pgid() { + let raw = unsafe { + sandlock_ffi::handler::sandlock_handler_new( + Some(handler_set_kill_sigterm_1234), + std::ptr::null_mut(), + None, + sandlock_exception_policy_t::Kill as u32, + ) + }; + // Safety: see `ffi_handler_translates_continue`. + let h = unsafe { FfiHandler::from_raw(raw) }; + let action = h.handle(&fake_ctx()).await; + assert!(matches!( + action, + NotifAction::Kill { sig, pgid } if sig == libc::SIGTERM && pgid == 1234 + )); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn ffi_handler_translates_kill_zero_pgid_substitutes_child_pgid() { + let raw = unsafe { + sandlock_ffi::handler::sandlock_handler_new( + Some(handler_set_kill_sigkill_zero_pgid), + std::ptr::null_mut(), + None, + sandlock_exception_policy_t::Kill as u32, + ) + }; + // Safety: see `ffi_handler_translates_continue`. + let h = unsafe { FfiHandler::from_raw(raw) }; + let cx = fake_ctx(); + let expected_pgid = cx.notif.pid as i32; + let action = h.handle(&cx).await; + assert!(matches!( + action, + NotifAction::Kill { sig, pgid } + if sig == libc::SIGKILL && pgid == expected_pgid + )); +} + +// ---- Group C: exception policy fallbacks -------------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn ffi_handler_kill_policy_on_callback_rc_nonzero() { + let raw = unsafe { + sandlock_ffi::handler::sandlock_handler_new( + Some(returns_error_with_unset_action), + std::ptr::null_mut(), + None, + sandlock_exception_policy_t::Kill as u32, + ) + }; + // Safety: see `ffi_handler_translates_continue`. + let h = unsafe { FfiHandler::from_raw(raw) }; + let action = h.handle(&fake_ctx()).await; + assert!(matches!(action, NotifAction::Kill { sig, .. } if sig == libc::SIGKILL)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn ffi_handler_continue_policy_on_callback_rc_nonzero() { + let raw = unsafe { + sandlock_ffi::handler::sandlock_handler_new( + Some(returns_error_with_unset_action), + std::ptr::null_mut(), + None, + sandlock_exception_policy_t::Continue as u32, + ) + }; + // Safety: see `ffi_handler_translates_continue`. + let h = unsafe { FfiHandler::from_raw(raw) }; + let action = h.handle(&fake_ctx()).await; + assert!(matches!(action, NotifAction::Continue)); +} + +// ---- Group D: panic recovery -------------------------------------------- + +extern "C" fn panicking_handler( + _ud: *mut std::ffi::c_void, + _notif: *const sandlock_ffi::notif_repr::sandlock_notif_data_t, + _mem: *mut sandlock_ffi::handler::sandlock_mem_handle_t, + _out: *mut sandlock_ffi::handler::sandlock_action_out_t, +) -> i32 { + panic!("test panic from extern C handler"); +} + +// Ignored: the `extern "C"` ABI on current Rust (since 1.71 stabilising +// `C-unwind`) aborts the process when an unwinding panic crosses the +// boundary — `std::panic::catch_unwind` cannot intercept that. The +// `catch_unwind` in `FfiHandler::handle` is therefore only useful for +// panics that happen *outside* the C callback (e.g. in adapter glue or +// in safe-Rust handlers that the user wrote and called as `extern "C"` +// via a wrapper). Promoting `sandlock_handler_fn_t` to `extern +// "C-unwind"` would make this test pass and the documented panic +// recovery actually deliver — but that is a production ABI change and +// belongs in a separate commit. Until then this test stays here as the +// regression hook. +#[ignore = "extern \"C\" panic aborts (Rust ABI); needs C-unwind ABI change in handler type"] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn ffi_handler_recovers_from_callback_panic() { + let raw = unsafe { + sandlock_ffi::handler::sandlock_handler_new( + Some(panicking_handler), + std::ptr::null_mut(), + None, + sandlock_exception_policy_t::Kill as u32, + ) + }; + // Safety: see `ffi_handler_translates_continue`. + let h = unsafe { FfiHandler::from_raw(raw) }; + let action = h.handle(&fake_ctx()).await; + // The `catch_unwind` inside `spawn_blocking` swallows the panic and + // the dispatcher falls back to the configured exception policy. + assert!(matches!(action, NotifAction::Kill { sig, .. } if sig == libc::SIGKILL)); +} + +// ---- Group E: Unset action with zero rc --------------------------------- + +extern "C" fn never_sets_action( + _ud: *mut std::ffi::c_void, + _notif: *const sandlock_ffi::notif_repr::sandlock_notif_data_t, + _mem: *mut sandlock_ffi::handler::sandlock_mem_handle_t, + _out: *mut sandlock_ffi::handler::sandlock_action_out_t, +) -> i32 { + 0 +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn ffi_handler_callback_returns_zero_but_never_sets_action_triggers_fallback() { + let raw = unsafe { + sandlock_ffi::handler::sandlock_handler_new( + Some(never_sets_action), + std::ptr::null_mut(), + None, + sandlock_exception_policy_t::DenyEperm as u32, + ) + }; + // Safety: see `ffi_handler_translates_continue`. + let h = unsafe { FfiHandler::from_raw(raw) }; + let action = h.handle(&fake_ctx()).await; + // `translate_action` returns `None` for `Unset`, which routes the + // dispatcher onto the exception policy fallback. + assert!(matches!(action, NotifAction::Errno(e) if e == libc::EPERM)); +} + +// ---- Group F: handler_new edge cases ------------------------------------ + +extern "C" fn panicking_dropper(_ud: *mut std::ffi::c_void) { + panic!("dropper invoked when it should not have been"); +} + +#[test] +fn handler_new_with_null_handler_fn_returns_null() { + let h = unsafe { + sandlock_handler_new( + None, + std::ptr::null_mut(), + None, + sandlock_exception_policy_t::Kill as u32, + ) + }; + assert!(h.is_null(), "expected null handle when handler_fn is None"); +} + +#[test] +fn handler_new_with_null_ud_and_dropper_does_not_invoke_dropper() { + // Allocates a container with a destructor but null ud; the `Drop` + // impl on `sandlock_handler_t` must skip the destructor in that case + // because there is nothing to free. + let h = unsafe { + sandlock_handler_new( + Some(test_handler as sandlock_handler_fn_t), + std::ptr::null_mut(), + Some(panicking_dropper), + sandlock_exception_policy_t::Kill as u32, + ) + }; + assert!(!h.is_null(), "expected a valid handler container"); + // Safety: `h` was just produced and not yet freed. If the guard in + // `Drop` were missing the dropper would panic and abort the test. + unsafe { sandlock_handler_free(h) }; +} + +// ---- Group G: run_with_handlers failure paths --------------------------- + +#[test] +fn run_with_handlers_null_policy_returns_null() { + let arg0 = CString::new("/bin/true").unwrap(); + let argv = [arg0.as_ptr()]; + let rr = unsafe { + sandlock_run_with_handlers( + std::ptr::null(), + argv.as_ptr(), + argv.len() as u32, + std::ptr::null(), + 0, + ) + }; + assert!(rr.is_null(), "expected null result for null policy"); +} + +#[test] +fn run_with_handlers_null_argv_returns_null() { + use sandlock_ffi::*; + let builder = sandlock_sandbox_builder_new(); + let policy = { + let mut err: i32 = 0; + unsafe { sandlock_sandbox_build(builder, &mut err, std::ptr::null_mut()) } + }; + assert!(!policy.is_null(), "policy build failed"); + + let rr = unsafe { + sandlock_run_with_handlers( + policy, + std::ptr::null(), + 3, // argc > 0 with null argv must fail validation + std::ptr::null(), + 0, + ) + }; + assert!(rr.is_null(), "expected null result for null argv with argc > 0"); + + unsafe { sandlock_sandbox_free(policy); } +} + +#[test] +fn run_with_handlers_null_registrations_with_nonzero_count_returns_null() { + use sandlock_ffi::*; + let builder = sandlock_sandbox_builder_new(); + let policy = { + let mut err: i32 = 0; + unsafe { sandlock_sandbox_build(builder, &mut err, std::ptr::null_mut()) } + }; + assert!(!policy.is_null(), "policy build failed"); + + let arg0 = CString::new("/bin/true").unwrap(); + let argv = [arg0.as_ptr()]; + let rr = unsafe { + sandlock_run_with_handlers( + policy, + argv.as_ptr(), + argv.len() as u32, + std::ptr::null(), // null registrations with nregistrations > 0 + 1, + ) + }; + assert!(rr.is_null(), "expected null result for null registrations + count > 0"); + + unsafe { sandlock_sandbox_free(policy); } +} + +#[test] +fn run_with_handlers_empty_registrations_runs_normally() { + use sandlock_ffi::*; + + let builder = sandlock_sandbox_builder_new(); + // Same allowlist as the existing end-to-end test — /bin/true links + // against libc and ld.so so it still needs /lib + /lib64 + /usr. + let builder = unsafe { + let p = CString::new("/usr").unwrap(); + sandlock_sandbox_builder_fs_read(builder, p.as_ptr()) + }; + let builder = unsafe { + let p = CString::new("/bin").unwrap(); + sandlock_sandbox_builder_fs_read(builder, p.as_ptr()) + }; + let builder = unsafe { + let p = CString::new("/lib").unwrap(); + sandlock_sandbox_builder_fs_read(builder, p.as_ptr()) + }; + let builder = unsafe { + let p = CString::new("/lib64").unwrap(); + sandlock_sandbox_builder_fs_read(builder, p.as_ptr()) + }; + let builder = unsafe { + let p = CString::new("/etc").unwrap(); + sandlock_sandbox_builder_fs_read(builder, p.as_ptr()) + }; + let builder = unsafe { + let p = CString::new("/tmp").unwrap(); + sandlock_sandbox_builder_fs_write(builder, p.as_ptr()) + }; + + let policy = { + let mut err: i32 = 0; + unsafe { sandlock_sandbox_build(builder, &mut err, std::ptr::null_mut()) } + }; + assert!(!policy.is_null(), "policy build failed"); + + let arg0 = CString::new("/bin/true").unwrap(); + let argv = [arg0.as_ptr()]; + + let rr = unsafe { + sandlock_run_with_handlers( + policy, + argv.as_ptr(), + argv.len() as u32, + std::ptr::null(), + 0, + ) + }; + assert!(!rr.is_null(), "empty registrations should still run /bin/true"); + let success = unsafe { sandlock_result_success(rr) }; + let exit_code = unsafe { sandlock_result_exit_code(rr) }; + assert!(success, "/bin/true should exit successfully; exit={}", exit_code); + + unsafe { sandlock_result_free(rr); } + unsafe { sandlock_sandbox_free(policy); } +} + +static ONE_SHOT_DROPPER_CALLS: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(0); + +extern "C" fn one_shot_dropper(ud: *mut std::ffi::c_void) { + ONE_SHOT_DROPPER_CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if !ud.is_null() { + // Reclaim the leaked Box so leak-sanitizer builds stay clean. + unsafe { drop(Box::from_raw(ud as *mut u32)); } + } +} + +#[test] +fn run_with_handlers_null_handler_in_array_returns_null() { + use sandlock_ffi::*; + + let builder = sandlock_sandbox_builder_new(); + let policy = { + let mut err: i32 = 0; + unsafe { sandlock_sandbox_build(builder, &mut err, std::ptr::null_mut()) } + }; + assert!(!policy.is_null(), "policy build failed"); + + // The validating handler must NOT be consumed by `sandlock_run_with_handlers` + // when validation fails — the call should be transactional. We assert this + // by registering `one_shot_dropper` and verifying it fires exactly once + // (from our explicit `sandlock_handler_free` call below, not from + // `sandlock_run_with_handlers`). + ONE_SHOT_DROPPER_CALLS.store(0, std::sync::atomic::Ordering::SeqCst); + let ud = Box::into_raw(Box::new(0xAAu32)) as *mut std::ffi::c_void; + let valid = unsafe { + sandlock_handler_new( + Some(test_handler as sandlock_handler_fn_t), + ud, + Some(one_shot_dropper), + sandlock_exception_policy_t::Kill as u32, + ) + }; + assert!(!valid.is_null()); + + let regs = [ + sandlock_handler_registration_t { + syscall_nr: libc::SYS_getpid, + handler: valid, + }, + sandlock_handler_registration_t { + syscall_nr: libc::SYS_getppid, + handler: std::ptr::null_mut(), // forces validation failure + }, + ]; + + let arg0 = CString::new("/bin/true").unwrap(); + let argv = [arg0.as_ptr()]; + + let rr = unsafe { + sandlock_run_with_handlers( + policy, + argv.as_ptr(), + argv.len() as u32, + regs.as_ptr(), + regs.len(), + ) + }; + assert!(rr.is_null(), "expected null result when an array entry is null"); + // The valid handler must still be ours to free — proving it was not + // consumed by the failed call. + unsafe { sandlock_handler_free(valid) }; + assert_eq!( + ONE_SHOT_DROPPER_CALLS.load(std::sync::atomic::Ordering::SeqCst), + 1, + "dropper must fire exactly once (from our explicit free)", + ); + + unsafe { sandlock_sandbox_free(policy); } +} + +// ---- Group H: multiple handlers ----------------------------------------- + +extern "C" fn force_getpid_to_111( + _ud: *mut std::ffi::c_void, + _notif: *const sandlock_ffi::notif_repr::sandlock_notif_data_t, + _mem: *mut sandlock_ffi::handler::sandlock_mem_handle_t, + out: *mut sandlock_ffi::handler::sandlock_action_out_t, +) -> i32 { + unsafe { sandlock_ffi::handler::sandlock_action_set_return_value(out, 111) }; + 0 +} + +extern "C" fn force_getppid_to_222( + _ud: *mut std::ffi::c_void, + _notif: *const sandlock_ffi::notif_repr::sandlock_notif_data_t, + _mem: *mut sandlock_ffi::handler::sandlock_mem_handle_t, + out: *mut sandlock_ffi::handler::sandlock_action_out_t, +) -> i32 { + unsafe { sandlock_ffi::handler::sandlock_action_set_return_value(out, 222) }; + 0 +} + +#[test] +fn run_with_handlers_two_handlers_each_fires_for_own_syscall() { + use sandlock_ffi::*; + + let builder = sandlock_sandbox_builder_new(); + let builder = unsafe { + let p = CString::new("/usr").unwrap(); + sandlock_sandbox_builder_fs_read(builder, p.as_ptr()) + }; + let builder = unsafe { + let p = CString::new("/bin").unwrap(); + sandlock_sandbox_builder_fs_read(builder, p.as_ptr()) + }; + let builder = unsafe { + let p = CString::new("/lib").unwrap(); + sandlock_sandbox_builder_fs_read(builder, p.as_ptr()) + }; + let builder = unsafe { + let p = CString::new("/lib64").unwrap(); + sandlock_sandbox_builder_fs_read(builder, p.as_ptr()) + }; + let builder = unsafe { + let p = CString::new("/etc").unwrap(); + sandlock_sandbox_builder_fs_read(builder, p.as_ptr()) + }; + let builder = unsafe { + let p = CString::new("/tmp").unwrap(); + sandlock_sandbox_builder_fs_write(builder, p.as_ptr()) + }; + + let policy = { + let mut err: i32 = 0; + unsafe { sandlock_sandbox_build(builder, &mut err, std::ptr::null_mut()) } + }; + assert!(!policy.is_null(), "policy build failed"); + + let h_pid = unsafe { + handler::sandlock_handler_new( + Some(force_getpid_to_111), + std::ptr::null_mut(), + None, + handler::sandlock_exception_policy_t::Kill as u32, + ) + }; + let h_ppid = unsafe { + handler::sandlock_handler_new( + Some(force_getppid_to_222), + std::ptr::null_mut(), + None, + handler::sandlock_exception_policy_t::Kill as u32, + ) + }; + assert!(!h_pid.is_null() && !h_ppid.is_null()); + + let registrations = [ + sandlock_handler_registration_t { + syscall_nr: libc::SYS_getpid, + handler: h_pid, + }, + sandlock_handler_registration_t { + syscall_nr: libc::SYS_getppid, + handler: h_ppid, + }, + ]; + + let script = CString::new( + "import os, sys; sys.stdout.write(str(os.getpid())+'|'+str(os.getppid()))", + ).unwrap(); + let arg0 = CString::new("/usr/bin/python3").unwrap(); + let arg1 = CString::new("-c").unwrap(); + let argv = [ + arg0.as_ptr(), + arg1.as_ptr(), + script.as_ptr(), + ]; + + let rr = unsafe { + sandlock_run_with_handlers( + policy, + argv.as_ptr(), + argv.len() as u32, + registrations.as_ptr(), + registrations.len(), + ) + }; + assert!(!rr.is_null(), "sandlock_run_with_handlers returned null"); + let stdout = unsafe { + let mut len: usize = 0; + let p = sandlock_result_stdout_bytes(rr, &mut len); + if p.is_null() { Vec::new() } else { std::slice::from_raw_parts(p, len).to_vec() } + }; + let stderr = unsafe { + let mut len: usize = 0; + let p = sandlock_result_stderr_bytes(rr, &mut len); + if p.is_null() { Vec::new() } else { std::slice::from_raw_parts(p, len).to_vec() } + }; + let stdout_str = String::from_utf8_lossy(&stdout); + let stderr_str = String::from_utf8_lossy(&stderr); + let exit_code = unsafe { sandlock_result_exit_code(rr) }; + assert!( + stdout_str.contains("111") && stdout_str.contains("222"), + "expected both handlers to fire; exit={} stdout={:?} stderr={:?}", + exit_code, stdout_str, stderr_str, + ); + + unsafe { sandlock_result_free(rr); } + unsafe { sandlock_sandbox_free(policy); } +} + +// ---- Group I: live-fd mem_read_cstr ------------------------------------- + +extern "C" fn deny_magic_marker_path( + _ud: *mut std::ffi::c_void, + notif: *const sandlock_ffi::notif_repr::sandlock_notif_data_t, + mem: *mut sandlock_ffi::handler::sandlock_mem_handle_t, + out: *mut sandlock_ffi::handler::sandlock_action_out_t, +) -> i32 { + // openat(dirfd, pathname, flags, ...) — pathname is args[1]. + // Safety: `notif` and `mem` are valid pointers supplied by the + // dispatcher for the duration of this callback; `out` is the + // caller-allocated action-out buffer. + let addr = unsafe { (*notif).args[1] }; + let mut buf = [0u8; 256]; + let mut n: usize = 0; + let rc = unsafe { + sandlock_ffi::handler::sandlock_mem_read_cstr( + mem, addr, buf.as_mut_ptr(), buf.len(), &mut n, + ) + }; + if rc != 0 { + // Read failed — fall back to letting the syscall through so the + // test runner sees a clean ENOENT rather than a fabricated EACCES. + unsafe { sandlock_ffi::handler::sandlock_action_set_continue(out) }; + return 0; + } + let path = std::str::from_utf8(&buf[..n]).unwrap_or(""); + if path == "/sandlock-test-magic-marker" { + unsafe { sandlock_ffi::handler::sandlock_action_set_errno(out, libc::EACCES) }; + } else { + unsafe { sandlock_ffi::handler::sandlock_action_set_continue(out) }; + } + 0 +} + +#[test] +fn mem_read_cstr_reads_path_from_intercepted_openat() { + use sandlock_ffi::*; + + let builder = sandlock_sandbox_builder_new(); + let builder = unsafe { + let p = CString::new("/usr").unwrap(); + sandlock_sandbox_builder_fs_read(builder, p.as_ptr()) + }; + let builder = unsafe { + let p = CString::new("/bin").unwrap(); + sandlock_sandbox_builder_fs_read(builder, p.as_ptr()) + }; + let builder = unsafe { + let p = CString::new("/lib").unwrap(); + sandlock_sandbox_builder_fs_read(builder, p.as_ptr()) + }; + let builder = unsafe { + let p = CString::new("/lib64").unwrap(); + sandlock_sandbox_builder_fs_read(builder, p.as_ptr()) + }; + let builder = unsafe { + let p = CString::new("/etc").unwrap(); + sandlock_sandbox_builder_fs_read(builder, p.as_ptr()) + }; + let builder = unsafe { + let p = CString::new("/tmp").unwrap(); + sandlock_sandbox_builder_fs_write(builder, p.as_ptr()) + }; + + let policy = { + let mut err: i32 = 0; + unsafe { sandlock_sandbox_build(builder, &mut err, std::ptr::null_mut()) } + }; + assert!(!policy.is_null(), "policy build failed"); + + let handler = unsafe { + handler::sandlock_handler_new( + Some(deny_magic_marker_path), + std::ptr::null_mut(), + None, + handler::sandlock_exception_policy_t::Kill as u32, + ) + }; + assert!(!handler.is_null()); + let registrations = [sandlock_handler_registration_t { + syscall_nr: libc::SYS_openat, + handler, + }]; + + // Child opens the magic path and prints the errno on failure. + let script = CString::new( + "import os, sys\n\ + try:\n\ + \x20 os.open('/sandlock-test-magic-marker', os.O_RDONLY)\n\ + \x20 sys.exit(0)\n\ + except OSError as e:\n\ + \x20 sys.stderr.write('errno=' + str(e.errno) + '\\n')\n\ + \x20 sys.exit(1)\n", + ).unwrap(); + let arg0 = CString::new("/usr/bin/python3").unwrap(); + let arg1 = CString::new("-c").unwrap(); + let argv = [ + arg0.as_ptr(), + arg1.as_ptr(), + script.as_ptr(), + ]; + + let rr = unsafe { + sandlock_run_with_handlers( + policy, + argv.as_ptr(), + argv.len() as u32, + registrations.as_ptr(), + registrations.len(), + ) + }; + assert!(!rr.is_null(), "sandlock_run_with_handlers returned null"); + let stderr = unsafe { + let mut len: usize = 0; + let p = sandlock_result_stderr_bytes(rr, &mut len); + if p.is_null() { Vec::new() } else { std::slice::from_raw_parts(p, len).to_vec() } + }; + let stdout = unsafe { + let mut len: usize = 0; + let p = sandlock_result_stdout_bytes(rr, &mut len); + if p.is_null() { Vec::new() } else { std::slice::from_raw_parts(p, len).to_vec() } + }; + let stderr_str = String::from_utf8_lossy(&stderr); + let stdout_str = String::from_utf8_lossy(&stdout); + let exit_code = unsafe { sandlock_result_exit_code(rr) }; + // EACCES is 13; if the path-read worked the child saw errno=13. If a + // different errno appears the handler ran but `mem_read_cstr` failed + // and we fell through — fail with a diagnostic message rather than + // silently masking. + assert!( + stderr_str.contains("errno=13"), + "expected handler to inject EACCES via mem_read_cstr; \ + exit={} stdout={:?} stderr={:?}", + exit_code, stdout_str, stderr_str, + ); + + unsafe { sandlock_result_free(rr); } + unsafe { sandlock_sandbox_free(policy); } +} From 4b29868e622c33a4cad0d33107005ce757f16081 Mon Sep 17 00:00:00 2001 From: dzerik Date: Wed, 13 May 2026 10:38:41 +0300 Subject: [PATCH 11/24] fix: switch handler ABI to extern "C-unwind" for real panic recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exception-policy doc-comment and extension-handlers.md both advertise that a panicking handler triggers the configured exception policy. With `extern "C" fn` that promise was a lie — per Rust ABI rules panics across `extern "C"` abort the process before `catch_unwind` ever fires. Switch the two C-callable function-pointer type aliases to `extern "C-unwind"`. C callers see no change (C cannot panic); Rust handlers plugged into the C ABI now actually exercise the panic-recovery path. Un-ignore the regression test. --- crates/sandlock-ffi/src/handler.rs | 15 ++++- crates/sandlock-ffi/tests/handler_smoke.rs | 64 ++++++++++------------ docs/extension-handlers.md | 5 ++ 3 files changed, 48 insertions(+), 36 deletions(-) diff --git a/crates/sandlock-ffi/src/handler.rs b/crates/sandlock-ffi/src/handler.rs index 90828ca..6ec70a6 100644 --- a/crates/sandlock-ffi/src/handler.rs +++ b/crates/sandlock-ffi/src/handler.rs @@ -324,8 +324,14 @@ pub enum sandlock_exception_policy_t { /// Returns 0 on success (and must have called exactly one setter on /// `out`). Returns non-zero to signal a handler-internal error; the /// supervisor then applies the configured exception policy. +/// +/// The ABI is `extern "C-unwind"` rather than plain `extern "C"`. Pure-C +/// callers see no difference (C has no unwinding); Rust handlers plugged +/// into this C ABI surface may panic and the supervisor's `catch_unwind` +/// in [`FfiHandler::handle`] will route the panic to the configured +/// exception policy instead of aborting the process. #[allow(non_camel_case_types)] -pub type sandlock_handler_fn_t = extern "C" fn( +pub type sandlock_handler_fn_t = extern "C-unwind" fn( ud: *mut std::ffi::c_void, notif: *const crate::notif_repr::sandlock_notif_data_t, mem: *mut sandlock_mem_handle_t, @@ -333,8 +339,13 @@ pub type sandlock_handler_fn_t = extern "C" fn( ) -> i32; /// Optional destructor invoked when the container is freed. +/// +/// Uses `extern "C-unwind"` for consistency with [`sandlock_handler_fn_t`] +/// and so that a Rust-side destructor panicking through this pointer +/// unwinds rather than aborts (panic-safety in destructors is good +/// practice even though no in-tree caller currently relies on it). #[allow(non_camel_case_types)] -pub type sandlock_handler_ud_drop_t = extern "C" fn(ud: *mut std::ffi::c_void); +pub type sandlock_handler_ud_drop_t = extern "C-unwind" fn(ud: *mut std::ffi::c_void); /// Opaque handler container (B4 — opaque box). #[repr(C)] diff --git a/crates/sandlock-ffi/tests/handler_smoke.rs b/crates/sandlock-ffi/tests/handler_smoke.rs index c498941..f09bd7c 100644 --- a/crates/sandlock-ffi/tests/handler_smoke.rs +++ b/crates/sandlock-ffi/tests/handler_smoke.rs @@ -116,7 +116,7 @@ use sandlock_ffi::handler::{ sandlock_handler_new, sandlock_handler_t, }; -extern "C" fn test_handler( +extern "C-unwind" fn test_handler( _ud: *mut std::ffi::c_void, _notif: *const sandlock_ffi::notif_repr::sandlock_notif_data_t, _mem: *mut sandlock_ffi::handler::sandlock_mem_handle_t, @@ -126,7 +126,7 @@ extern "C" fn test_handler( 0 } -extern "C" fn dropper(ud: *mut std::ffi::c_void) { +extern "C-unwind" fn dropper(ud: *mut std::ffi::c_void) { // Reconstitute the Box we leaked in the test below. unsafe { drop(Box::from_raw(ud as *mut u32)); } } @@ -178,7 +178,7 @@ fn fake_ctx() -> HandlerCtx { } } -extern "C" fn return_value_42( +extern "C-unwind" fn return_value_42( _ud: *mut std::ffi::c_void, _notif: *const sandlock_ffi::notif_repr::sandlock_notif_data_t, _mem: *mut sandlock_ffi::handler::sandlock_mem_handle_t, @@ -188,7 +188,7 @@ extern "C" fn return_value_42( 0 } -extern "C" fn returns_error_with_unset_action( +extern "C-unwind" fn returns_error_with_unset_action( _ud: *mut std::ffi::c_void, _notif: *const sandlock_ffi::notif_repr::sandlock_notif_data_t, _mem: *mut sandlock_ffi::handler::sandlock_mem_handle_t, @@ -234,7 +234,7 @@ use sandlock_ffi::handler::{ sandlock_handler_registration_t, sandlock_run_with_handlers, }; -extern "C" fn force_getpid_to_777( +extern "C-unwind" fn force_getpid_to_777( _ud: *mut std::ffi::c_void, _notif: *const sandlock_ffi::notif_repr::sandlock_notif_data_t, _mem: *mut sandlock_ffi::handler::sandlock_mem_handle_t, @@ -359,8 +359,9 @@ fn run_with_handlers_intercepts_getpid() { // * Group H: multiple handlers each firing for their own syscall. // * Group I: live-fd `sandlock_mem_read_cstr` via an intercepted `openat`. // -// Style mirrors the existing end-to-end test: explicit `extern "C"` handler -// fns, no helper macros, `assert!(matches!(...))` for action variants. +// Style mirrors the existing end-to-end test: explicit `extern "C-unwind"` +// handler fns, no helper macros, `assert!(matches!(...))` for action +// variants. use sandlock_ffi::handler::{ sandlock_action_set_inject_fd_send, sandlock_action_set_inject_fd_send_tracked, @@ -410,11 +411,11 @@ fn action_setters_are_null_safe() { // ---- Group B: FfiHandler translation ------------------------------------ // -// Each variant gets its own explicit `extern "C"` handler so the test -// retains the line-by-line transparency of the existing tests rather than -// hiding setup behind a macro. +// Each variant gets its own explicit `extern "C-unwind"` handler so the +// test retains the line-by-line transparency of the existing tests rather +// than hiding setup behind a macro. -extern "C" fn handler_set_continue( +extern "C-unwind" fn handler_set_continue( _ud: *mut std::ffi::c_void, _notif: *const sandlock_ffi::notif_repr::sandlock_notif_data_t, _mem: *mut sandlock_ffi::handler::sandlock_mem_handle_t, @@ -424,7 +425,7 @@ extern "C" fn handler_set_continue( 0 } -extern "C" fn handler_set_errno_eacces( +extern "C-unwind" fn handler_set_errno_eacces( _ud: *mut std::ffi::c_void, _notif: *const sandlock_ffi::notif_repr::sandlock_notif_data_t, _mem: *mut sandlock_ffi::handler::sandlock_mem_handle_t, @@ -434,7 +435,7 @@ extern "C" fn handler_set_errno_eacces( 0 } -extern "C" fn handler_set_hold( +extern "C-unwind" fn handler_set_hold( _ud: *mut std::ffi::c_void, _notif: *const sandlock_ffi::notif_repr::sandlock_notif_data_t, _mem: *mut sandlock_ffi::handler::sandlock_mem_handle_t, @@ -444,7 +445,7 @@ extern "C" fn handler_set_hold( 0 } -extern "C" fn handler_set_kill_sigterm_1234( +extern "C-unwind" fn handler_set_kill_sigterm_1234( _ud: *mut std::ffi::c_void, _notif: *const sandlock_ffi::notif_repr::sandlock_notif_data_t, _mem: *mut sandlock_ffi::handler::sandlock_mem_handle_t, @@ -454,7 +455,7 @@ extern "C" fn handler_set_kill_sigterm_1234( 0 } -extern "C" fn handler_set_kill_sigkill_zero_pgid( +extern "C-unwind" fn handler_set_kill_sigkill_zero_pgid( _ud: *mut std::ffi::c_void, _notif: *const sandlock_ffi::notif_repr::sandlock_notif_data_t, _mem: *mut sandlock_ffi::handler::sandlock_mem_handle_t, @@ -590,7 +591,7 @@ async fn ffi_handler_continue_policy_on_callback_rc_nonzero() { // ---- Group D: panic recovery -------------------------------------------- -extern "C" fn panicking_handler( +extern "C-unwind" fn panicking_handler( _ud: *mut std::ffi::c_void, _notif: *const sandlock_ffi::notif_repr::sandlock_notif_data_t, _mem: *mut sandlock_ffi::handler::sandlock_mem_handle_t, @@ -599,18 +600,13 @@ extern "C" fn panicking_handler( panic!("test panic from extern C handler"); } -// Ignored: the `extern "C"` ABI on current Rust (since 1.71 stabilising -// `C-unwind`) aborts the process when an unwinding panic crosses the -// boundary — `std::panic::catch_unwind` cannot intercept that. The -// `catch_unwind` in `FfiHandler::handle` is therefore only useful for -// panics that happen *outside* the C callback (e.g. in adapter glue or -// in safe-Rust handlers that the user wrote and called as `extern "C"` -// via a wrapper). Promoting `sandlock_handler_fn_t` to `extern -// "C-unwind"` would make this test pass and the documented panic -// recovery actually deliver — but that is a production ABI change and -// belongs in a separate commit. Until then this test stays here as the -// regression hook. -#[ignore = "extern \"C\" panic aborts (Rust ABI); needs C-unwind ABI change in handler type"] +// `sandlock_handler_fn_t` is `extern "C-unwind" fn`, so a panic raised +// inside the Rust handler unwinds across the C ABI boundary and is +// caught by the `std::panic::catch_unwind` in `FfiHandler::handle`. The +// dispatcher then falls back to the configured exception policy — here +// `Kill` — which the assertion below verifies. Pure-C callers cannot +// panic, so this stability claim is exclusively for Rust handlers +// exposed through the C ABI (the integration-test pattern here). #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn ffi_handler_recovers_from_callback_panic() { let raw = unsafe { @@ -631,7 +627,7 @@ async fn ffi_handler_recovers_from_callback_panic() { // ---- Group E: Unset action with zero rc --------------------------------- -extern "C" fn never_sets_action( +extern "C-unwind" fn never_sets_action( _ud: *mut std::ffi::c_void, _notif: *const sandlock_ffi::notif_repr::sandlock_notif_data_t, _mem: *mut sandlock_ffi::handler::sandlock_mem_handle_t, @@ -660,7 +656,7 @@ async fn ffi_handler_callback_returns_zero_but_never_sets_action_triggers_fallba // ---- Group F: handler_new edge cases ------------------------------------ -extern "C" fn panicking_dropper(_ud: *mut std::ffi::c_void) { +extern "C-unwind" fn panicking_dropper(_ud: *mut std::ffi::c_void) { panic!("dropper invoked when it should not have been"); } @@ -826,7 +822,7 @@ fn run_with_handlers_empty_registrations_runs_normally() { static ONE_SHOT_DROPPER_CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); -extern "C" fn one_shot_dropper(ud: *mut std::ffi::c_void) { +extern "C-unwind" fn one_shot_dropper(ud: *mut std::ffi::c_void) { ONE_SHOT_DROPPER_CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); if !ud.is_null() { // Reclaim the leaked Box so leak-sanitizer builds stay clean. @@ -900,7 +896,7 @@ fn run_with_handlers_null_handler_in_array_returns_null() { // ---- Group H: multiple handlers ----------------------------------------- -extern "C" fn force_getpid_to_111( +extern "C-unwind" fn force_getpid_to_111( _ud: *mut std::ffi::c_void, _notif: *const sandlock_ffi::notif_repr::sandlock_notif_data_t, _mem: *mut sandlock_ffi::handler::sandlock_mem_handle_t, @@ -910,7 +906,7 @@ extern "C" fn force_getpid_to_111( 0 } -extern "C" fn force_getppid_to_222( +extern "C-unwind" fn force_getppid_to_222( _ud: *mut std::ffi::c_void, _notif: *const sandlock_ffi::notif_repr::sandlock_notif_data_t, _mem: *mut sandlock_ffi::handler::sandlock_mem_handle_t, @@ -1031,7 +1027,7 @@ fn run_with_handlers_two_handlers_each_fires_for_own_syscall() { // ---- Group I: live-fd mem_read_cstr ------------------------------------- -extern "C" fn deny_magic_marker_path( +extern "C-unwind" fn deny_magic_marker_path( _ud: *mut std::ffi::c_void, notif: *const sandlock_ffi::notif_repr::sandlock_notif_data_t, mem: *mut sandlock_ffi::handler::sandlock_mem_handle_t, diff --git a/docs/extension-handlers.md b/docs/extension-handlers.md index cca3ca2..a49fa0d 100644 --- a/docs/extension-handlers.md +++ b/docs/extension-handlers.md @@ -628,6 +628,11 @@ A C handler must: 2. Return non-zero on any internal error. The supervisor then applies the handler's `on_exception` policy (default: `SANDLOCK_EXCEPTION_KILL`). 3. Not retain `notif`, `mem`, or `out` past the return statement. +4. May panic from inside a Rust-side handler exposed through the + C ABI — the supervisor catches the unwind via `catch_unwind` and + applies the configured exception policy. Pure-C callers cannot + panic (C has no unwinding); this clause is for Rust handlers + plugged into the C ABI surface. ### Minimal example From 65848d5eecbf2fb0a62781c52e38c6b075292112 Mon Sep 17 00:00:00 2001 From: dzerik Date: Wed, 13 May 2026 10:42:16 +0300 Subject: [PATCH 12/24] test: verify handler_t Drop invokes ud_drop via observable counter The previous round-trip test relied on leak-sanitizer to flag a missed ud_drop call; default cargo test does not enable LSan, so the assertion was a no-op. Replace with an AtomicUsize counter incremented inside the dropper and asserted post-free. --- crates/sandlock-ffi/tests/handler_smoke.rs | 27 ++++++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/crates/sandlock-ffi/tests/handler_smoke.rs b/crates/sandlock-ffi/tests/handler_smoke.rs index f09bd7c..d3f08ce 100644 --- a/crates/sandlock-ffi/tests/handler_smoke.rs +++ b/crates/sandlock-ffi/tests/handler_smoke.rs @@ -126,27 +126,44 @@ extern "C-unwind" fn test_handler( 0 } -extern "C-unwind" fn dropper(ud: *mut std::ffi::c_void) { - // Reconstitute the Box we leaked in the test below. +static ROUND_TRIP_DROPPER_CALLS: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(0); + +extern "C-unwind" fn round_trip_dropper(ud: *mut std::ffi::c_void) { + // Reclaim the leaked Box so its destructor runs (real drop path). unsafe { drop(Box::from_raw(ud as *mut u32)); } + ROUND_TRIP_DROPPER_CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); } #[test] fn handler_new_and_free_round_trip() { + // Reset in case another test in the binary touched this counter. + ROUND_TRIP_DROPPER_CALLS.store(0, std::sync::atomic::Ordering::SeqCst); + let ud = Box::into_raw(Box::new(0xABCDu32)) as *mut std::ffi::c_void; let on_ex = sandlock_exception_policy_t::Kill as u32; let h: *mut sandlock_handler_t = unsafe { sandlock_handler_new( Some(test_handler as sandlock_handler_fn_t), ud, - Some(dropper), + Some(round_trip_dropper), on_ex, ) }; assert!(!h.is_null()); + assert_eq!( + ROUND_TRIP_DROPPER_CALLS.load(std::sync::atomic::Ordering::SeqCst), + 0, + "dropper must not fire before sandlock_handler_free", + ); + unsafe { sandlock_handler_free(h) }; - // `dropper` runs and frees the Box; if it does not, leak-sanitizer - // (when enabled) will flag this test. + + assert_eq!( + ROUND_TRIP_DROPPER_CALLS.load(std::sync::atomic::Ordering::SeqCst), + 1, + "dropper must fire exactly once during Drop", + ); } #[test] From fa21f0c94cd88448eade85a402e06af623d1cd9f Mon Sep 17 00:00:00 2001 From: dzerik Date: Wed, 13 May 2026 11:31:41 +0300 Subject: [PATCH 13/24] ffi: address pre-PR review feedback - Strip internal task/PR references from production code comments. - Drop sandlock_action_set_inject_fd_send_tracked setter; reserve enum discriminant 5 for the future tracker-aware variant. - Add `name` parameter to sandlock_run_with_handlers and sandlock_run_interactive_with_handlers for parity with the existing sandlock_run entry point. - Tighten Sync safety comment on sandlock_handler_t to reflect the serial dispatch contract from sandlock-core. - Document that InjectFdSend ownership transfer is conditional on the action surviving until supervisor dispatch. - Reject argc == 0 in sandlock_run_with_handlers* before reaching the sandbox. - Remove SANDLOCK_HANDLER_MODULE_BUILT scaffolding and its placeholder integration test. --- crates/sandlock-ffi/include/sandlock.h | 26 ++-- crates/sandlock-ffi/src/handler.rs | 124 +++++++++++--------- crates/sandlock-ffi/tests/c/handler_smoke.c | 2 +- crates/sandlock-ffi/tests/handler_smoke.rs | 70 ++++++----- 4 files changed, 133 insertions(+), 89 deletions(-) diff --git a/crates/sandlock-ffi/include/sandlock.h b/crates/sandlock-ffi/include/sandlock.h index 0e7204b..f43c9b4 100644 --- a/crates/sandlock-ffi/include/sandlock.h +++ b/crates/sandlock-ffi/include/sandlock.h @@ -202,15 +202,19 @@ typedef struct sandlock_action_out_t { void sandlock_action_set_continue(sandlock_action_out_t *out); void sandlock_action_set_errno(sandlock_action_out_t *out, int32_t errno_value); void sandlock_action_set_return_value(sandlock_action_out_t *out, int64_t value); +/** Ownership of `srcfd` transfers from the caller to the supervisor + * only when the resulting action is actually dispatched. If the + * caller subsequently calls a different setter on the same + * `sandlock_action_out_t` (overwriting the kind tag before the + * supervisor reads it), `srcfd` is NOT closed and leaks. Pick one + * setter per action. */ void sandlock_action_set_inject_fd_send(sandlock_action_out_t *out, int32_t srcfd, uint32_t newfd_flags); -/** NOTE: PR 1 of this feature accepts the tracker token for ABI - * completeness but the tracker callback is not yet wired and will - * not fire — the supervisor degrades this to a plain InjectFdSend. - * Do not synchronously wait on a tracker callback. */ -void sandlock_action_set_inject_fd_send_tracked(sandlock_action_out_t *out, - int32_t srcfd, uint32_t newfd_flags, - sandlock_inject_tracker_t tracker); +/* NOTE: `SANDLOCK_ACTION_INJECT_FD_SEND_TRACKED` (= 5) and + * `sandlock_action_inject_tracked_t` are reserved for a future + * tracker-aware inject variant. No setter is exposed in this release; + * actions left with that kind tag are treated as `UNSET` and routed + * through the handler's exception policy. */ void sandlock_action_set_hold(sandlock_action_out_t *out); /** Kill action setter. `pgid == 0` is a sentinel — the supervisor * substitutes the child process's own pid as a best-effort pgid @@ -253,6 +257,9 @@ typedef struct sandlock_handler_registration_t { } sandlock_handler_registration_t; /** Run the policy with extra C handlers. Returns NULL on failure. + * + * `name` may be NULL to auto-generate as `sandbox-{pid}`, mirroring the + * convention used by `sandlock_run`. * * Ownership of `registrations[i].handler` is transferred into the call * after the function has validated and accepted the registration array. @@ -268,6 +275,7 @@ typedef struct sandlock_handler_registration_t { * per handler) and the alternative risks double-free. */ sandlock_result_t *sandlock_run_with_handlers( const sandlock_sandbox_t *policy, + const char *name, const char *const *argv, unsigned int argc, const sandlock_handler_registration_t *registrations, size_t nregistrations); @@ -275,6 +283,9 @@ sandlock_result_t *sandlock_run_with_handlers( /** Interactive-stdio variant of `sandlock_run_with_handlers`. Returns * NULL on failure. * + * `name` may be NULL to auto-generate as `sandbox-{pid}`, mirroring the + * convention used by `sandlock_run_interactive`. + * * Ownership of `registrations[i].handler` is transferred into the call * after the function has validated and accepted the registration array. * On success (non-NULL return) all handler pointers are owned by the @@ -289,6 +300,7 @@ sandlock_result_t *sandlock_run_with_handlers( * per handler) and the alternative risks double-free. */ sandlock_result_t *sandlock_run_interactive_with_handlers( const sandlock_sandbox_t *policy, + const char *name, const char *const *argv, unsigned int argc, const sandlock_handler_registration_t *registrations, size_t nregistrations); diff --git a/crates/sandlock-ffi/src/handler.rs b/crates/sandlock-ffi/src/handler.rs index 6ec70a6..91e80cb 100644 --- a/crates/sandlock-ffi/src/handler.rs +++ b/crates/sandlock-ffi/src/handler.rs @@ -5,8 +5,6 @@ use std::slice; use sandlock_core::seccomp::notif::{read_child_cstr, read_child_mem, write_child_mem}; -pub const SANDLOCK_HANDLER_MODULE_BUILT: bool = true; - /// Opaque child-memory accessor handed to a C handler callback. /// /// Constructed on the stack inside the Rust adapter just before the @@ -120,7 +118,8 @@ pub unsafe extern "C" fn sandlock_mem_write( #[allow(non_camel_case_types)] pub enum sandlock_action_kind_t { /// No action set yet; the supervisor treats this as "fall through to - /// the handler's on_exception policy" (Task 6 wires this up). + /// the handler's on_exception policy" (see `exception_action` in + /// `FfiHandler`). Unset = 0, Continue = 1, Errno = 2, @@ -149,10 +148,10 @@ pub struct sandlock_action_inject_t { pub newfd_flags: u32, } -/// Token used by `InjectFdSendTracked` so the C side can correlate the -/// callback that fires after `SECCOMP_IOCTL_NOTIF_ADDFD` returns the -/// child-side fd number. Wired through to a Rust `OnInjectSuccess` -/// closure built in Task 6. +/// Token reserved for a future tracker-aware inject variant. Currently +/// unimplemented — kept as a type alias so the ABI of the +/// `sandlock_action_inject_tracked_t` payload stays stable across the +/// future release that wires the tracker callback. #[allow(non_camel_case_types)] pub type sandlock_inject_tracker_t = u64; @@ -238,6 +237,13 @@ pub unsafe extern "C" fn sandlock_action_set_return_value( /// Inject the supervisor-side fd `srcfd` into the traced child as a new /// fd (number chosen by the kernel via `SECCOMP_IOCTL_NOTIF_ADDFD`). /// +/// Note: ownership of `srcfd` transfers from the C caller to the +/// supervisor only when the resulting action is actually dispatched. +/// If the C caller subsequently calls a different setter on the same +/// `sandlock_action_out_t` (overwriting the kind tag before the +/// supervisor reads it), `srcfd` is NOT closed and leaks. Pick one +/// setter per action. +/// /// # Safety /// Same constraints as `sandlock_action_set_continue`; `srcfd` must be /// a valid open fd in the supervisor process at the moment of the @@ -253,26 +259,6 @@ pub unsafe extern "C" fn sandlock_action_set_inject_fd_send( (*out).payload.inject_send = sandlock_action_inject_t { srcfd, newfd_flags }; } -/// Tracked variant of `sandlock_action_set_inject_fd_send` — the -/// supervisor will fire a Rust-side callback identified by `tracker` -/// once the kernel reports the child-side fd number. -/// -/// # Safety -/// Same constraints as `sandlock_action_set_inject_fd_send`. -#[no_mangle] -pub unsafe extern "C" fn sandlock_action_set_inject_fd_send_tracked( - out: *mut sandlock_action_out_t, - srcfd: RawFd, - newfd_flags: u32, - tracker: sandlock_inject_tracker_t, -) { - if out.is_null() { return; } - (*out).kind = sandlock_action_kind_t::InjectFdSendTracked as u32; - (*out).payload.inject_send_tracked = sandlock_action_inject_tracked_t { - srcfd, newfd_flags, tracker, - }; -} - /// Hold the syscall pending until the supervisor explicitly releases it. /// /// # Safety @@ -364,15 +350,14 @@ pub struct sandlock_handler_t { // (function pointer + `void*` user-data) and a `#[repr(u32)]` enum, all of // which are `Send`-safe to move across threads. // -// `Sync`: required so `sandlock_handler_t` can live inside the -// `Arc` storage that the dispatch table uses. This is a -// stronger claim than `Send` — the supervisor may dispatch concurrent -// notifications for the same syscall on different worker threads, meaning -// the same `&sandlock_handler_t` (and the same `ud` pointer) may be read -// from multiple threads at the same time. The C caller is responsible for -// ensuring `ud` is either immutable or guarded by thread-safe state of its -// own (atomics, mutex, etc.) — Rust offers no synchronization for opaque -// `void*` user-data. +// `Sync`: required because the supervisor's dispatch table stores +// handlers as `Arc`, and `Arc` requires `T: Send + +// Sync` even when actual dispatch is serial. In practice the +// supervisor's seccomp loop drives one notification at a time per +// handler instance, so the C caller's `ud` is touched from at most +// one worker thread at any given moment. The C caller is still +// responsible for ensuring `ud` is at least minimally thread-safe +// (no aliasing with other threads outside the supervisor loop). unsafe impl Send for sandlock_handler_t {} unsafe impl Sync for sandlock_handler_t {} @@ -448,7 +433,7 @@ use std::pin::Pin; /// Rust adapter wrapping an owned `sandlock_handler_t` and implementing /// `Handler`. Constructed when the supervisor accepts handlers passed -/// through `sandlock_run_with_handlers` (Task 7). +/// through `sandlock_run_with_handlers`. pub struct FfiHandler { inner: Box, } @@ -498,8 +483,9 @@ impl Handler for FfiHandler { let notif_fd = cx.notif_fd; let notif_id = cx.notif.id; let pid = cx.notif.pid; - let child_pgid = cx.notif.pid as i32; // best-effort; supervisor - // wires real pgid in Task 7. + let child_pgid = cx.notif.pid as i32; // best-effort placeholder; + // a future release plumbs + // the real child pgid in. let handler_fn = self.inner.handler_fn; let ud = UdPtr(self.inner.ud); let on_exception_fallback = self.exception_action(child_pgid); @@ -581,16 +567,11 @@ fn translate_action(out: sandlock_action_out_t, child_pgid: i32) -> Option { - // Tracker callbacks are not yet wired — PR 1 accepts the - // variant for ABI completeness but degrades to plain - // InjectFdSend until Task 7 adds the tracker registry. - NotifAction::InjectFdSend { - srcfd: std::os::unix::io::OwnedFd::from_raw_fd( - out.payload.inject_send_tracked.srcfd), - newfd_flags: out.payload.inject_send_tracked.newfd_flags, - } - } + // Discriminant reserved for future tracker-injection ABI; no + // setter is exposed in this release, so this kind should + // never be set legitimately. Treat it as Unset → exception + // policy fallback. + K::InjectFdSendTracked => return None, K::Unset => unreachable!(), } }; @@ -598,7 +579,7 @@ fn translate_action(out: sandlock_action_out_t, child_pgid: i32) -> Option, cmd: Vec, handlers: Vec<(i64, FfiHandler)>, interactive: bool, @@ -693,7 +682,13 @@ fn block_on_run( .build() .ok()?; let cmd_refs: Vec<&str> = cmd.iter().map(String::as_str).collect(); - let mut sb = sandbox.clone(); + // Apply `name` via the builder method on a clone — mirrors the + // pattern used by `sandlock_run` in lib.rs. A `None` here means + // "auto-generate `sandbox-{pid}`", matching the C ABI contract. + let mut sb = match name { + Some(n) => sandbox.clone().with_name(n), + None => sandbox.clone(), + }; Some(rt.block_on(async move { if interactive { sb.run_interactive_with_extra_handlers(&cmd_refs, handlers).await @@ -705,6 +700,10 @@ fn block_on_run( /// Run the policy with extra C handlers. Returns NULL on failure. /// +/// `name` may be NULL to auto-generate `sandbox-{pid}`, or a valid +/// NUL-terminated UTF-8 C string; the placement mirrors the existing +/// `sandlock_run` entry point in `lib.rs`. +/// /// # Safety /// All pointer arguments must be valid for their documented lifetimes: /// `policy` must come from `sandlock_sandbox_build`, `argv` must be a @@ -714,31 +713,36 @@ fn block_on_run( #[no_mangle] pub unsafe extern "C" fn sandlock_run_with_handlers( policy: *const crate::sandlock_sandbox_t, + name: *const std::os::raw::c_char, argv: *const *const std::os::raw::c_char, argc: u32, registrations: *const sandlock_handler_registration_t, nregistrations: usize, ) -> *mut crate::sandlock_result_t { - run_with_handlers_inner(policy, argv, argc, registrations, nregistrations, false) + run_with_handlers_inner(policy, name, argv, argc, registrations, nregistrations, false) } /// Interactive-stdio variant of `sandlock_run_with_handlers`. /// +/// `name` follows the same convention as `sandlock_run_with_handlers`. +/// /// # Safety /// Same constraints as `sandlock_run_with_handlers`. #[no_mangle] pub unsafe extern "C" fn sandlock_run_interactive_with_handlers( policy: *const crate::sandlock_sandbox_t, + name: *const std::os::raw::c_char, argv: *const *const std::os::raw::c_char, argc: u32, registrations: *const sandlock_handler_registration_t, nregistrations: usize, ) -> *mut crate::sandlock_result_t { - run_with_handlers_inner(policy, argv, argc, registrations, nregistrations, true) + run_with_handlers_inner(policy, name, argv, argc, registrations, nregistrations, true) } unsafe fn run_with_handlers_inner( policy: *const crate::sandlock_sandbox_t, + name: *const std::os::raw::c_char, argv: *const *const std::os::raw::c_char, argc: u32, registrations: *const sandlock_handler_registration_t, @@ -748,6 +752,18 @@ unsafe fn run_with_handlers_inner( if policy.is_null() { return std::ptr::null_mut(); } + // Decode the optional name eagerly so a malformed (non-UTF-8) C + // string fails the call fast, before we take ownership of any + // handler containers via `collect_registrations`. Matches the + // contract used by `sandlock_run`. + let name_opt: Option = if name.is_null() { + None + } else { + match CStr::from_ptr(name).to_str() { + Ok(s) => Some(s.to_owned()), + Err(_) => return std::ptr::null_mut(), + } + }; let cmd = match argv_from_c(argv, argc) { Some(v) => v, None => return std::ptr::null_mut(), @@ -757,7 +773,7 @@ unsafe fn run_with_handlers_inner( None => return std::ptr::null_mut(), }; let sandbox_ref: &Sandbox = (*policy).inner(); - match block_on_run(sandbox_ref, cmd, handlers, interactive) { + match block_on_run(sandbox_ref, name_opt, cmd, handlers, interactive) { Some(Ok(rr)) => { let boxed = Box::new(crate::sandlock_result_t::from_run_result(rr)); Box::into_raw(boxed) diff --git a/crates/sandlock-ffi/tests/c/handler_smoke.c b/crates/sandlock-ffi/tests/c/handler_smoke.c index 03479d0..e061a59 100644 --- a/crates/sandlock-ffi/tests/c/handler_smoke.c +++ b/crates/sandlock-ffi/tests/c/handler_smoke.c @@ -73,7 +73,7 @@ int main(void) { }; sandlock_result_t *rr = sandlock_run_with_handlers( - p, argv, 3, regs, 1); + p, NULL /* name: auto-generate sandbox-{pid} */, argv, 3, regs, 1); if (rr == NULL) { fprintf(stderr, "sandlock: run_with_handlers returned NULL\n"); /* Per sandlock.h: on NULL return, do NOT free handler `h` — diff --git a/crates/sandlock-ffi/tests/handler_smoke.rs b/crates/sandlock-ffi/tests/handler_smoke.rs index d3f08ce..ea7ef68 100644 --- a/crates/sandlock-ffi/tests/handler_smoke.rs +++ b/crates/sandlock-ffi/tests/handler_smoke.rs @@ -1,12 +1,4 @@ -//! Integration smoke test for the FFI handler ABI introduced in PR 1. -//! Subsequent tasks expand this file as the surface is built up. - -#[test] -fn handler_module_is_exposed() { - // This forces the `handler` module to be referenced from the cdylib - // public surface. Replaced by real tests in later tasks. - let _ = sandlock_ffi::handler::SANDLOCK_HANDLER_MODULE_BUILT; -} +//! Integration smoke tests for the FFI handler ABI. use sandlock_ffi::notif_repr::sandlock_notif_data_t; @@ -50,7 +42,8 @@ use sandlock_ffi::handler::{ #[test] fn mem_accessors_reject_null_arguments() { // Verifies the null-pointer guards in each accessor. Happy-path - // coverage comes in Task 7 with a live notif_fd. + // coverage with a live notif_fd is exercised by the end-to-end + // tests further down this file. let mut buf = [0u8; 4]; let mut out_len: usize = 0; let p = std::ptr::null(); @@ -332,6 +325,7 @@ fn run_with_handlers_intercepts_getpid() { let rr = unsafe { sandlock_run_with_handlers( policy, + std::ptr::null(), // name: auto-generate `sandbox-{pid}` argv.as_ptr(), argv.len() as u32, registrations.as_ptr(), @@ -361,7 +355,7 @@ fn run_with_handlers_intercepts_getpid() { } // --------------------------------------------------------------------------- -// Expanded coverage (Task 10) +// Expanded coverage // --------------------------------------------------------------------------- // // The tests below probe each remaining branch of the handler ABI surface: @@ -380,9 +374,7 @@ fn run_with_handlers_intercepts_getpid() { // handler fns, no helper macros, `assert!(matches!(...))` for action // variants. -use sandlock_ffi::handler::{ - sandlock_action_set_inject_fd_send, sandlock_action_set_inject_fd_send_tracked, -}; +use sandlock_ffi::handler::sandlock_action_set_inject_fd_send; // ---- Group A: action setters -------------------------------------------- @@ -398,18 +390,6 @@ fn action_inject_fd_send_setter_records_payload() { assert_eq!(unsafe { a.payload.inject_send.newfd_flags }, 0o2000000); } -#[test] -fn action_inject_fd_send_tracked_setter_records_payload() { - let mut a = sandlock_action_out_t::zeroed(); - unsafe { sandlock_action_set_inject_fd_send_tracked(&mut a, 17, 0, 0xDEAD_BEEF) }; - assert_eq!(a.kind, sandlock_action_kind_t::InjectFdSendTracked as u32); - // Safety: kind == InjectFdSendTracked selects the `inject_send_tracked` - // union arm. - assert_eq!(unsafe { a.payload.inject_send_tracked.srcfd }, 17); - assert_eq!(unsafe { a.payload.inject_send_tracked.newfd_flags }, 0); - assert_eq!(unsafe { a.payload.inject_send_tracked.tracker }, 0xDEAD_BEEF); -} - #[test] fn action_setters_are_null_safe() { // Safety: each setter documents null as a no-op; this test is the @@ -422,7 +402,6 @@ fn action_setters_are_null_safe() { sandlock_action_set_hold(std::ptr::null_mut()); sandlock_action_set_kill(std::ptr::null_mut(), libc::SIGKILL, 0); sandlock_action_set_inject_fd_send(std::ptr::null_mut(), 0, 0); - sandlock_action_set_inject_fd_send_tracked(std::ptr::null_mut(), 0, 0, 0); } } @@ -718,6 +697,7 @@ fn run_with_handlers_null_policy_returns_null() { let rr = unsafe { sandlock_run_with_handlers( std::ptr::null(), + std::ptr::null(), // name: auto-generate `sandbox-{pid}` argv.as_ptr(), argv.len() as u32, std::ptr::null(), @@ -740,6 +720,7 @@ fn run_with_handlers_null_argv_returns_null() { let rr = unsafe { sandlock_run_with_handlers( policy, + std::ptr::null(), // name: auto-generate `sandbox-{pid}` std::ptr::null(), 3, // argc > 0 with null argv must fail validation std::ptr::null(), @@ -751,6 +732,36 @@ fn run_with_handlers_null_argv_returns_null() { unsafe { sandlock_sandbox_free(policy); } } +#[test] +fn run_with_handlers_zero_argc_returns_null() { + // argc == 0 means "no command to execute" — the sandbox cannot + // exec an empty argv, so the FFI must reject it at the boundary + // before consuming handler containers. + use sandlock_ffi::*; + let builder = sandlock_sandbox_builder_new(); + let policy = { + let mut err: i32 = 0; + unsafe { sandlock_sandbox_build(builder, &mut err, std::ptr::null_mut()) } + }; + assert!(!policy.is_null(), "policy build failed"); + + let arg0 = CString::new("/bin/true").unwrap(); + let argv = [arg0.as_ptr()]; + let rr = unsafe { + sandlock_run_with_handlers( + policy, + std::ptr::null(), // name: auto-generate `sandbox-{pid}` + argv.as_ptr(), + 0, // zero argc must reject + std::ptr::null(), + 0, + ) + }; + assert!(rr.is_null(), "expected null result for argc == 0"); + + unsafe { sandlock_sandbox_free(policy); } +} + #[test] fn run_with_handlers_null_registrations_with_nonzero_count_returns_null() { use sandlock_ffi::*; @@ -766,6 +777,7 @@ fn run_with_handlers_null_registrations_with_nonzero_count_returns_null() { let rr = unsafe { sandlock_run_with_handlers( policy, + std::ptr::null(), // name: auto-generate `sandbox-{pid}` argv.as_ptr(), argv.len() as u32, std::ptr::null(), // null registrations with nregistrations > 0 @@ -821,6 +833,7 @@ fn run_with_handlers_empty_registrations_runs_normally() { let rr = unsafe { sandlock_run_with_handlers( policy, + std::ptr::null(), // name: auto-generate `sandbox-{pid}` argv.as_ptr(), argv.len() as u32, std::ptr::null(), @@ -892,6 +905,7 @@ fn run_with_handlers_null_handler_in_array_returns_null() { let rr = unsafe { sandlock_run_with_handlers( policy, + std::ptr::null(), // name: auto-generate `sandbox-{pid}` argv.as_ptr(), argv.len() as u32, regs.as_ptr(), @@ -1012,6 +1026,7 @@ fn run_with_handlers_two_handlers_each_fires_for_own_syscall() { let rr = unsafe { sandlock_run_with_handlers( policy, + std::ptr::null(), // name: auto-generate `sandbox-{pid}` argv.as_ptr(), argv.len() as u32, registrations.as_ptr(), @@ -1148,6 +1163,7 @@ fn mem_read_cstr_reads_path_from_intercepted_openat() { let rr = unsafe { sandlock_run_with_handlers( policy, + std::ptr::null(), // name: auto-generate `sandbox-{pid}` argv.as_ptr(), argv.len() as u32, registrations.as_ptr(), From a471701318ce6d75cf9415e5136b234c4b2c70a4 Mon Sep 17 00:00:00 2001 From: dzerik Date: Wed, 13 May 2026 11:45:42 +0300 Subject: [PATCH 14/24] fix: resolve child pgid via getpgid() for Kill actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous implementation substituted the child's pid for pgid==0 in sandlock_action_set_kill — that targets the single process, not its process group, contradicting the documented "Kill the child process group" semantics on NotifAction::Kill. Query getpgid(notif.pid) inside FfiHandler::handle to recover the real pgid. Fall back to the pid only on ESRCH (child already exited). Update the C-header doc to reflect the actual resolution mechanism. --- crates/sandlock-ffi/include/sandlock.h | 6 +-- crates/sandlock-ffi/src/handler.rs | 22 ++++++-- crates/sandlock-ffi/tests/handler_smoke.rs | 63 +++++++++++++++++++--- 3 files changed, 76 insertions(+), 15 deletions(-) diff --git a/crates/sandlock-ffi/include/sandlock.h b/crates/sandlock-ffi/include/sandlock.h index f43c9b4..3216c2c 100644 --- a/crates/sandlock-ffi/include/sandlock.h +++ b/crates/sandlock-ffi/include/sandlock.h @@ -217,9 +217,9 @@ void sandlock_action_set_inject_fd_send(sandlock_action_out_t *out, * through the handler's exception policy. */ void sandlock_action_set_hold(sandlock_action_out_t *out); /** Kill action setter. `pgid == 0` is a sentinel — the supervisor - * substitutes the child process's own pid as a best-effort pgid - * while the real-pgid wiring is being completed. To target a - * specific group, pass an explicit non-zero pgid. */ + * substitutes the child process group id (resolved via getpgid(pid) + * on the notification's pid). To target a specific group, pass an + * explicit non-zero pgid. */ void sandlock_action_set_kill(sandlock_action_out_t *out, int32_t sig, int32_t pgid); typedef enum sandlock_exception_policy { diff --git a/crates/sandlock-ffi/src/handler.rs b/crates/sandlock-ffi/src/handler.rs index 91e80cb..20f6091 100644 --- a/crates/sandlock-ffi/src/handler.rs +++ b/crates/sandlock-ffi/src/handler.rs @@ -269,8 +269,10 @@ pub unsafe extern "C" fn sandlock_action_set_hold(out: *mut sandlock_action_out_ (*out).kind = sandlock_action_kind_t::Hold as u32; } -/// Kill the target (`pgid > 0` for the whole process group, or the pid -/// the supervisor records for the notification) with signal `sig`. +/// Kill the target with signal `sig`. Pass `pgid > 0` to target an +/// explicit process group; `pgid == 0` is a sentinel — the supervisor +/// substitutes the child process group id resolved via `getpgid(pid)` +/// on the notification's pid. /// /// # Safety /// Same constraints as `sandlock_action_set_continue`. @@ -483,9 +485,19 @@ impl Handler for FfiHandler { let notif_fd = cx.notif_fd; let notif_id = cx.notif.id; let pid = cx.notif.pid; - let child_pgid = cx.notif.pid as i32; // best-effort placeholder; - // a future release plumbs - // the real child pgid in. + // Query the kernel for the child's real process-group id. The supervisor + // catches the notification synchronously, so the child is still alive at + // this point. `getpgid` returns -1 on ESRCH (child exited between notif + // and our query) — fall back to the child's pid in that case so a + // downstream Kill action has at least one valid target even if the + // process is already a zombie. + let child_pgid = { + let pid = cx.notif.pid as i32; + // SAFETY: `getpgid` is signal-safe and async-safe; no preconditions + // beyond passing a valid pid. + let pgid = unsafe { libc::getpgid(pid) }; + if pgid < 0 { pid } else { pgid } + }; let handler_fn = self.inner.handler_fn; let ud = UdPtr(self.inner.ud); let on_exception_fallback = self.exception_action(child_pgid); diff --git a/crates/sandlock-ffi/tests/handler_smoke.rs b/crates/sandlock-ffi/tests/handler_smoke.rs index ea7ef68..0767da1 100644 --- a/crates/sandlock-ffi/tests/handler_smoke.rs +++ b/crates/sandlock-ffi/tests/handler_smoke.rs @@ -531,6 +531,35 @@ async fn ffi_handler_translates_kill_with_explicit_pgid() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn ffi_handler_translates_kill_zero_pgid_substitutes_child_pgid() { + // Spawn a real child so that getpgid(child.pid) is the test runner's + // pgid — distinct from child.pid (a fresh pid). This makes the test a + // genuine regression hook for the substitution: buggy production code + // that returns notif.pid would yield child.pid, but the production + // formula (getpgid(notif.pid)) yields the test runner's pgid. The + // mismatch causes the assertion to fail under the bug. + let mut child = std::process::Command::new("sleep") + .arg("30") + .spawn() + .expect("spawn sleep child"); + let child_pid = child.id() as i32; + // Compute the expected pgid the same way production does. If `sleep` + // exited or was reaped between spawn and this call, fall back to the + // pid to mirror production's ESRCH branch. + let expected_pgid = { + // SAFETY: same as the production call — no preconditions. + let q = unsafe { libc::getpgid(child_pid) }; + if q < 0 { child_pid } else { q } + }; + // Sanity-check that this host actually exposes a meaningful pgid != + // pid for the spawned child. Otherwise the assertion below is + // satisfied by both the buggy and fixed implementations, making the + // test useless on this host. + assert_ne!( + expected_pgid, child_pid, + "test precondition: getpgid(child_pid) must differ from child_pid for this test to catch regressions; \ + got pgid={expected_pgid}, pid={child_pid}", + ); + let raw = unsafe { sandlock_ffi::handler::sandlock_handler_new( Some(handler_set_kill_sigkill_zero_pgid), @@ -541,14 +570,34 @@ async fn ffi_handler_translates_kill_zero_pgid_substitutes_child_pgid() { }; // Safety: see `ffi_handler_translates_continue`. let h = unsafe { FfiHandler::from_raw(raw) }; - let cx = fake_ctx(); - let expected_pgid = cx.notif.pid as i32; + let cx = HandlerCtx { + notif: SeccompNotif { + id: 1, + pid: child_pid as u32, + flags: 0, + data: SeccompData { + nr: 39, + arch: 0xC000_003E, + instruction_pointer: 0, + args: [0; 6], + }, + }, + notif_fd: -1, + }; let action = h.handle(&cx).await; - assert!(matches!( - action, - NotifAction::Kill { sig, pgid } - if sig == libc::SIGKILL && pgid == expected_pgid - )); + + // Reap the child regardless of assertion outcome. + let _ = child.kill(); + let _ = child.wait(); + + assert!( + matches!( + action, + NotifAction::Kill { sig, pgid } + if sig == libc::SIGKILL && pgid == expected_pgid + ), + "expected Kill {{ sig: SIGKILL, pgid: {expected_pgid} }}, got {action:?}", + ); } // ---- Group C: exception policy fallbacks -------------------------------- From d1807c337f1d82700afbd060c03425c63beb5618 Mon Sep 17 00:00:00 2001 From: dzerik Date: Wed, 13 May 2026 12:20:49 +0300 Subject: [PATCH 15/24] fix: close ownership gaps in handler ABI Four findings from adversarial review: A1: A C handler that calls sandlock_action_set_inject_fd_send and then panics/returns rc != 0 left srcfd open in the supervisor forever. Drain pending inject-fd payloads on every fallback path. A2: A C handler can write the InjectFdSendTracked discriminant directly without a setter (the value is public in the C header). Drain srcfd in that translate_action branch. A3: run_with_handlers_inner's early-return paths (null policy, invalid argv, invalid name) abandoned registered handler pointers. Restructure to always consume the registration array regardless of return value; update the C header contract. A5: sandlock_handler_free was extern "C" but its ud_drop docstring claimed unwinding panics. Switch to extern "C-unwind" to match the documented behavior. --- crates/sandlock-ffi/include/sandlock.h | 38 ++- crates/sandlock-ffi/src/handler.rs | 154 ++++++++++-- crates/sandlock-ffi/tests/handler_smoke.rs | 259 +++++++++++++++++++++ 3 files changed, 409 insertions(+), 42 deletions(-) diff --git a/crates/sandlock-ffi/include/sandlock.h b/crates/sandlock-ffi/include/sandlock.h index 3216c2c..1248518 100644 --- a/crates/sandlock-ffi/include/sandlock.h +++ b/crates/sandlock-ffi/include/sandlock.h @@ -261,18 +261,15 @@ typedef struct sandlock_handler_registration_t { * `name` may be NULL to auto-generate as `sandbox-{pid}`, mirroring the * convention used by `sandlock_run`. * - * Ownership of `registrations[i].handler` is transferred into the call - * after the function has validated and accepted the registration array. - * On success (non-NULL return) all handler pointers are owned by the - * supervisor and must not be freed by the caller. + * Ownership of every `registrations[i].handler` pointer transfers into + * the call on entry. After this function returns, the caller MUST NOT + * call `sandlock_handler_free` on any handler pointer that was passed + * in — successful or not, the supervisor is responsible for freeing + * the containers (which also invokes the registered `ud_drop`). * - * On NULL return the transfer status of handler pointers is not - * defined: depending on which internal validation step failed, the - * supervisor may or may not have taken ownership of some handlers. - * The conservative and safe approach is to ABANDON all handler - * pointers after a NULL return — do not call `sandlock_handler_free` - * on them. The cost of the resulting leak is bounded (one allocation - * per handler) and the alternative risks double-free. */ + * Null handler pointers in the array are treated as a validation error + * and the call returns NULL; non-null entries in the same array are + * still freed by the supervisor (the array is consumed as a whole). */ sandlock_result_t *sandlock_run_with_handlers( const sandlock_sandbox_t *policy, const char *name, @@ -286,18 +283,15 @@ sandlock_result_t *sandlock_run_with_handlers( * `name` may be NULL to auto-generate as `sandbox-{pid}`, mirroring the * convention used by `sandlock_run_interactive`. * - * Ownership of `registrations[i].handler` is transferred into the call - * after the function has validated and accepted the registration array. - * On success (non-NULL return) all handler pointers are owned by the - * supervisor and must not be freed by the caller. + * Ownership of every `registrations[i].handler` pointer transfers into + * the call on entry. After this function returns, the caller MUST NOT + * call `sandlock_handler_free` on any handler pointer that was passed + * in — successful or not, the supervisor is responsible for freeing + * the containers (which also invokes the registered `ud_drop`). * - * On NULL return the transfer status of handler pointers is not - * defined: depending on which internal validation step failed, the - * supervisor may or may not have taken ownership of some handlers. - * The conservative and safe approach is to ABANDON all handler - * pointers after a NULL return — do not call `sandlock_handler_free` - * on them. The cost of the resulting leak is bounded (one allocation - * per handler) and the alternative risks double-free. */ + * Null handler pointers in the array are treated as a validation error + * and the call returns NULL; non-null entries in the same array are + * still freed by the supervisor (the array is consumed as a whole). */ sandlock_result_t *sandlock_run_interactive_with_handlers( const sandlock_sandbox_t *policy, const char *name, diff --git a/crates/sandlock-ffi/src/handler.rs b/crates/sandlock-ffi/src/handler.rs index 20f6091..599d90f 100644 --- a/crates/sandlock-ffi/src/handler.rs +++ b/crates/sandlock-ffi/src/handler.rs @@ -417,12 +417,18 @@ pub unsafe extern "C" fn sandlock_handler_new( /// handler; calling this on a registered handler is undefined behaviour /// (the supervisor's later free would double-free). /// +/// The ABI is `extern "C-unwind"` rather than plain `extern "C"` so a +/// panic propagated from a Rust-side `ud_drop` (declared as +/// [`sandlock_handler_ud_drop_t`], itself `extern "C-unwind"`) unwinds +/// the caller rather than aborting the process. Pure-C callers see no +/// difference (C has no unwinding). +/// /// # Safety /// `h` must be either null or a pointer previously returned by /// `sandlock_handler_new` that has not yet been registered with the /// supervisor and has not already been freed. #[no_mangle] -pub unsafe extern "C" fn sandlock_handler_free(h: *mut sandlock_handler_t) { +pub unsafe extern "C-unwind" fn sandlock_handler_free(h: *mut sandlock_handler_t) { if h.is_null() { return; } drop(Box::from_raw(h)); } @@ -529,25 +535,100 @@ impl Handler for FfiHandler { }; match rc_or_panic { - Ok(0) => translate_action(out, child_pgid) - .unwrap_or(on_exception_fallback), - _ => on_exception_fallback, + Ok(0) => match translate_action(&out, child_pgid) { + Some(action) => action, + None => { + // Action kind ended up Unset, unknown, or the + // reserved InjectFdSendTracked discriminant. + // Drain any inject-fd payload before falling + // back to the exception policy — otherwise the + // supervisor leaks the srcfd that was armed by + // the (failed) callback. + // SAFETY: `drain_pending_inject_fd` inspects + // `out.kind` itself before touching the union, + // and `out.kind` matches the union variant per + // the action setters' contract. + unsafe { drain_pending_inject_fd(&out) }; + on_exception_fallback + } + }, + _ => { + // Either the callback returned a non-zero rc OR + // `catch_unwind` caught a panic. The callback may + // have armed an InjectFdSend{,Tracked} payload + // before failing; drain it so its srcfd doesn't + // leak in the supervisor. + // SAFETY: see the `Ok(0) -> None` branch above. + unsafe { drain_pending_inject_fd(&out) }; + on_exception_fallback + } } }) } } +/// Drains a still-pending `InjectFdSend` or `InjectFdSendTracked` +/// payload by consuming the contained `srcfd` into an `OwnedFd` and +/// dropping it (which closes the fd). Called from error paths in +/// [`FfiHandler::handle`] that fall back to the exception policy +/// without dispatching the action — without this, the supervisor +/// silently leaks fds armed by a C handler that subsequently panicked +/// or returned a non-zero rc. +/// +/// No-op for any other action kind (including `Unset`). +/// +/// # Safety +/// `out` must point at a fully-initialised `sandlock_action_out_t`. +/// The function inspects only `out.kind` and the union arm matching +/// that kind, which is sound because the action setters establish the +/// invariant "the `kind` tag selects the union arm". +unsafe fn drain_pending_inject_fd(out: &sandlock_action_out_t) { + use sandlock_action_kind_t as K; + if out.kind == K::InjectFdSend as u32 { + // SAFETY: `kind == InjectFdSend` selects the `inject_send` + // arm per the setter contract. Wrapping the raw fd in an + // `OwnedFd` and dropping it closes the fd. + drop(std::os::unix::io::OwnedFd::from_raw_fd( + out.payload.inject_send.srcfd, + )); + } else if out.kind == K::InjectFdSendTracked as u32 { + // The C header exposes the discriminant value publicly even + // though we don't ship a setter for it. A C caller can still + // assign `out->kind = 5; out->payload.inject_send_tracked.srcfd = X;` + // by hand. Treat it like `InjectFdSend` for cleanup purposes: + // the srcfd was armed and must be released. + // SAFETY: see `InjectFdSend` arm above. + drop(std::os::unix::io::OwnedFd::from_raw_fd( + out.payload.inject_send_tracked.srcfd, + )); + } +} + /// Convert the C-side decision into a `NotifAction`. Returns `None` if -/// the kind is `Unset` or unknown — the caller then falls back to the -/// exception policy. -fn translate_action(out: sandlock_action_out_t, child_pgid: i32) -> Option { +/// the kind is `Unset`, unknown, or `InjectFdSendTracked` (no setter +/// exposed; treated as fallback). The caller then falls back to the +/// exception policy, and is responsible for invoking +/// [`drain_pending_inject_fd`] to release any armed inject-fd payload. +/// +/// Note: this function takes `&sandlock_action_out_t` rather than +/// consuming the struct so that the caller can still inspect `out.kind` +/// on the `None` branch and drain any pending fd payload. The +/// `InjectFdSend` arm uses `OwnedFd::from_raw_fd` on the union field, +/// which is what materialises the ownership transfer from the C caller +/// to the supervisor when this branch is taken. +fn translate_action(out: &sandlock_action_out_t, child_pgid: i32) -> Option { use sandlock_action_kind_t as K; let kind = match out.kind { x if x == K::Continue as u32 => K::Continue, x if x == K::Errno as u32 => K::Errno, x if x == K::ReturnValue as u32 => K::ReturnValue, x if x == K::InjectFdSend as u32 => K::InjectFdSend, - x if x == K::InjectFdSendTracked as u32 => K::InjectFdSendTracked, + // Discriminant reserved for a future tracker-injection ABI; no + // setter is exposed in this release. A C caller can still set + // it by hand (the value is public in the C header). Return + // `None` so the caller drains the srcfd and falls back to the + // exception policy. + x if x == K::InjectFdSendTracked as u32 => return None, x if x == K::Hold as u32 => K::Hold, x if x == K::Kill as u32 => K::Kill, _ => return None, // Unset or unknown @@ -557,10 +638,10 @@ fn translate_action(out: sandlock_action_out_t, child_pgid: i32) -> Option NotifAction::Continue, @@ -579,12 +660,7 @@ fn translate_action(out: sandlock_action_out_t, child_pgid: i32) -> Option return None, - K::Unset => unreachable!(), + K::InjectFdSendTracked | K::Unset => unreachable!(), } }; Some(action) @@ -752,6 +828,32 @@ pub unsafe extern "C" fn sandlock_run_interactive_with_handlers( run_with_handlers_inner(policy, name, argv, argc, registrations, nregistrations, true) } +/// Drops every non-null handler pointer in the registration array. +/// Used by [`run_with_handlers_inner`] on early-return paths where +/// `collect_registrations` was not reached — guarantees the C ABI +/// contract "all handler pointers are consumed by this call". +/// +/// # Safety +/// `regs` is either null (no-op) or points to `nregs` valid +/// `sandlock_handler_registration_t` slots whose `handler` pointer is +/// either null or comes from `sandlock_handler_new` and has not been +/// freed by anyone else. +unsafe fn release_registrations( + regs: *const sandlock_handler_registration_t, + nregs: usize, +) { + if regs.is_null() || nregs == 0 { + return; + } + let slice = slice::from_raw_parts(regs, nregs); + for r in slice { + if !r.handler.is_null() { + // Reclaim and drop the container so its `ud_drop` runs. + drop(Box::from_raw(r.handler)); + } + } +} + unsafe fn run_with_handlers_inner( policy: *const crate::sandlock_sandbox_t, name: *const std::os::raw::c_char, @@ -762,6 +864,9 @@ unsafe fn run_with_handlers_inner( interactive: bool, ) -> *mut crate::sandlock_result_t { if policy.is_null() { + // Honour the documented contract: ownership of every handler + // pointer transfers in on entry, regardless of return value. + release_registrations(registrations, nregistrations); return std::ptr::null_mut(); } // Decode the optional name eagerly so a malformed (non-UTF-8) C @@ -773,13 +878,22 @@ unsafe fn run_with_handlers_inner( } else { match CStr::from_ptr(name).to_str() { Ok(s) => Some(s.to_owned()), - Err(_) => return std::ptr::null_mut(), + Err(_) => { + release_registrations(registrations, nregistrations); + return std::ptr::null_mut(); + } } }; let cmd = match argv_from_c(argv, argc) { Some(v) => v, - None => return std::ptr::null_mut(), + None => { + release_registrations(registrations, nregistrations); + return std::ptr::null_mut(); + } }; + // `collect_registrations` is itself transactional: on failure it + // doesn't consume any handlers. Preserve that contract — the + // caller still owns them on a `None` return from this point. let handlers = match collect_registrations(registrations, nregistrations) { Some(v) => v, None => return std::ptr::null_mut(), diff --git a/crates/sandlock-ffi/tests/handler_smoke.rs b/crates/sandlock-ffi/tests/handler_smoke.rs index 0767da1..955149b 100644 --- a/crates/sandlock-ffi/tests/handler_smoke.rs +++ b/crates/sandlock-ffi/tests/handler_smoke.rs @@ -1247,3 +1247,262 @@ fn mem_read_cstr_reads_path_from_intercepted_openat() { unsafe { sandlock_result_free(rr); } unsafe { sandlock_sandbox_free(policy); } } + +// --------------------------------------------------------------------------- +// Ownership regression tests (A1, A2, A3, A5) +// --------------------------------------------------------------------------- +// +// These exercise the four ownership/leak gaps that adversarial review +// surfaced after the initial handler ABI landed: +// +// * A1: a callback that arms `InjectFdSend` then panics or returns +// non-zero must NOT leak the supervisor-side srcfd. +// * A2: a callback that writes the `InjectFdSendTracked` discriminant +// by hand (no setter is exposed but the value is public in the C +// header) must NOT leak the supervisor-side srcfd. +// * A3: `sandlock_run_with_handlers` early-return paths (null policy, +// invalid argv, invalid name) must still consume the registered +// handler containers — the documented contract is "ownership +// transfers on entry, regardless of return value". +// * A5: `sandlock_handler_free` was `extern "C"`, so a panicking +// `ud_drop` would abort. Switched to `extern "C-unwind"`; verify a +// panic propagates back instead of aborting the process. + +// A small pipe helper used by the inject-fd drain tests below. Returns +// `(read_end, write_end)`. The write end is what the handler hands to +// the supervisor as the "inject" srcfd; the read end stays in this +// test and observes EOF once the drain path closes the write end. +fn make_pipe() -> (i32, i32) { + // SAFETY: `libc::pipe` writes exactly two fds into the array on + // success and returns 0; we assert success below. + let mut fds = [0i32; 2]; + let rc = unsafe { libc::pipe(fds.as_mut_ptr()) }; + assert_eq!(rc, 0, "pipe() failed: errno={}", std::io::Error::last_os_error()); + (fds[0], fds[1]) +} + +// Reads up to one byte from `fd` with `O_NONBLOCK` set first. Returns +// the value `libc::read` returned (>=0 byte count, or -1 on error; +// `errno` is preserved in that case so the caller can distinguish EOF +// from EAGAIN). +fn read_eof_or_eagain(fd: i32) -> isize { + // SAFETY: `F_SETFL` with `O_NONBLOCK` is a simple flag set; `read` + // reads at most one byte into the on-stack buffer. + unsafe { + libc::fcntl(fd, libc::F_SETFL, libc::O_NONBLOCK); + let mut buf = [0u8; 1]; + libc::read(fd, buf.as_mut_ptr() as *mut std::ffi::c_void, 1) + } +} + +extern "C-unwind" fn arm_inject_fd_then_panic( + ud: *mut std::ffi::c_void, + _notif: *const sandlock_ffi::notif_repr::sandlock_notif_data_t, + _mem: *mut sandlock_ffi::handler::sandlock_mem_handle_t, + out: *mut sandlock_ffi::handler::sandlock_action_out_t, +) -> i32 { + // The test stashes the write-end fd in a heap-allocated i32 and + // passes its pointer as `ud`. Read the fd, arm the inject action, + // then panic — the dispatcher must still drain the fd. + // SAFETY: `ud` points to a live `i32` for the duration of this call + // (owned by the test). + let fd = unsafe { *(ud as *const i32) }; + unsafe { sandlock_ffi::handler::sandlock_action_set_inject_fd_send(out, fd, 0) }; + panic!("test panic after arming InjectFdSend"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a1_ffi_handler_drains_inject_fd_on_panic() { + // Bug A1 regression hook: a C handler that calls + // `sandlock_action_set_inject_fd_send` and then panics used to leak + // the supervisor-side srcfd. After the fix, the dispatcher's + // catch-unwind path drains the pending payload, closing the fd. + let (read_fd, write_fd) = make_pipe(); + // Heap-allocated so the pointer stays valid across spawn_blocking. + let fd_holder: Box = Box::new(write_fd); + let fd_ptr = Box::into_raw(fd_holder) as *mut std::ffi::c_void; + + let raw = unsafe { + sandlock_ffi::handler::sandlock_handler_new( + Some(arm_inject_fd_then_panic), + fd_ptr, + None, + sandlock_exception_policy_t::Kill as u32, + ) + }; + // SAFETY: `raw` was just produced and is non-null. + let h = unsafe { FfiHandler::from_raw(raw) }; + let action = h.handle(&fake_ctx()).await; + assert!( + matches!(action, NotifAction::Kill { sig, .. } if sig == libc::SIGKILL), + "panic must route to the exception policy fallback", + ); + + // After `handle` returns, the drain path should have closed + // `write_fd`. Reading from `read_fd` (with O_NONBLOCK) returns 0 + // (EOF). If the leak were still present, the write end would + // remain open and `read` would return -1/EAGAIN. + let n = read_eof_or_eagain(read_fd); + let errno = std::io::Error::last_os_error(); + assert_eq!( + n, 0, + "expected EOF on read end (write end closed by drain); got n={n} errno={errno}", + ); + + // Reclaim the heap allocation for the fd holder so the test is + // leak-clean. `write_fd` itself is owned by the drain path; do NOT + // close it here. + // SAFETY: `fd_ptr` came from `Box::into_raw` on a `Box`. + unsafe { drop(Box::from_raw(fd_ptr as *mut i32)); } + // SAFETY: `read_fd` is still open; close it. + unsafe { libc::close(read_fd); } +} + +extern "C-unwind" fn arm_inject_fd_send_tracked_discriminant( + ud: *mut std::ffi::c_void, + _notif: *const sandlock_ffi::notif_repr::sandlock_notif_data_t, + _mem: *mut sandlock_ffi::handler::sandlock_mem_handle_t, + out: *mut sandlock_ffi::handler::sandlock_action_out_t, +) -> i32 { + // Write the InjectFdSendTracked discriminant by hand. The setter + // is not exposed in this release, but the discriminant value is + // public in the C header, so a C caller could do exactly this. + // SAFETY: `ud` and `out` are valid for the duration of this call. + let fd = unsafe { *(ud as *const i32) }; + unsafe { + (*out).kind = sandlock_ffi::handler::sandlock_action_kind_t::InjectFdSendTracked as u32; + (*out).payload.inject_send_tracked = + sandlock_ffi::handler::sandlock_action_inject_tracked_t { + srcfd: fd, + newfd_flags: 0, + tracker: 0, + }; + } + 0 +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a2_ffi_handler_drains_inject_fd_tracked_discriminant() { + // Bug A2 regression hook: a C handler that writes the + // `InjectFdSendTracked` discriminant directly used to leak the + // srcfd because `translate_action`'s `K::InjectFdSendTracked` arm + // returned None and dropped the value without reclaiming the fd. + let (read_fd, write_fd) = make_pipe(); + let fd_holder: Box = Box::new(write_fd); + let fd_ptr = Box::into_raw(fd_holder) as *mut std::ffi::c_void; + + let raw = unsafe { + sandlock_ffi::handler::sandlock_handler_new( + Some(arm_inject_fd_send_tracked_discriminant), + fd_ptr, + None, + sandlock_exception_policy_t::Kill as u32, + ) + }; + // SAFETY: `raw` was just produced and is non-null. + let h = unsafe { FfiHandler::from_raw(raw) }; + let action = h.handle(&fake_ctx()).await; + assert!( + matches!(action, NotifAction::Kill { sig, .. } if sig == libc::SIGKILL), + "unsupported tracked discriminant must route to the exception policy fallback", + ); + + let n = read_eof_or_eagain(read_fd); + let errno = std::io::Error::last_os_error(); + assert_eq!( + n, 0, + "expected EOF on read end (write end closed by drain); got n={n} errno={errno}", + ); + + // SAFETY: `fd_ptr` came from `Box::into_raw` on a `Box`. + unsafe { drop(Box::from_raw(fd_ptr as *mut i32)); } + // SAFETY: `read_fd` is still open; close it. + unsafe { libc::close(read_fd); } +} + +static A3_UD_DROPPER_CALLS: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(0); + +extern "C-unwind" fn a3_counter_dropper(_ud: *mut std::ffi::c_void) { + A3_UD_DROPPER_CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); +} + +#[test] +fn a3_run_with_handlers_releases_registrations_on_null_policy() { + // Bug A3 regression hook: the null-policy early-return path used to + // abandon the registration array. After the fix, the supervisor + // consumes every non-null handler pointer on entry, regardless of + // return value. + A3_UD_DROPPER_CALLS.store(0, std::sync::atomic::Ordering::SeqCst); + // Non-null ud — the `Drop` impl on `sandlock_handler_t` only fires + // the dropper when `ud` is non-null. The dropper itself ignores + // the value, so any non-null bit pattern works. + let h = unsafe { + sandlock_handler_new( + Some(test_handler as sandlock_handler_fn_t), + 0xFEED_FACEusize as *mut std::ffi::c_void, + Some(a3_counter_dropper), + sandlock_exception_policy_t::Kill as u32, + ) + }; + assert!(!h.is_null(), "handler_new must produce a valid container"); + let regs = [sandlock_handler_registration_t { + syscall_nr: libc::SYS_getpid, + handler: h, + }]; + let rr = unsafe { + sandlock_run_with_handlers( + std::ptr::null(), // null policy triggers the early-return path + std::ptr::null(), // name + std::ptr::null(), // argv + 0, // argc + regs.as_ptr(), + regs.len(), + ) + }; + assert!(rr.is_null(), "expected null result for null policy"); + assert_eq!( + A3_UD_DROPPER_CALLS.load(std::sync::atomic::Ordering::SeqCst), + 1, + "ud_drop must fire on the early-return path (handler consumed by supervisor)", + ); +} + +extern "C-unwind" fn a5_panicking_dropper(_ud: *mut std::ffi::c_void) { + panic!("test panic from dropper"); +} + +#[test] +fn a5_handler_free_unwinds_on_panicking_dropper() { + // Bug A5 regression hook: `sandlock_handler_free` used to be + // `extern "C"`, which aborts on unwind. After the fix it is + // `extern "C-unwind"` and a panicking `ud_drop` propagates back to + // the caller's `catch_unwind`. + // + // Note: with the bug still present, the process aborts here and + // the test binary dies — `catch_unwind` cannot recover from an + // abort. So we write the test against the FIXED code; the + // destructive sanity check (manually flipping the ABI back to + // `extern "C"`) is a one-shot manual confirmation. + let h = unsafe { + sandlock_handler_new( + Some(test_handler as sandlock_handler_fn_t), + // Non-null ud so the `Drop` impl invokes the dropper. Any + // non-null bit pattern works because the dropper itself + // never reads through the pointer — it just panics. + 0xDEAD_BEEFusize as *mut std::ffi::c_void, + Some(a5_panicking_dropper), + sandlock_exception_policy_t::Kill as u32, + ) + }; + assert!(!h.is_null(), "handler_new must produce a valid container"); + let result = std::panic::catch_unwind(|| { + // SAFETY: `h` is a valid, unregistered container; we + // intentionally trigger the panicking dropper by freeing it. + unsafe { sandlock_handler_free(h) }; + }); + assert!( + result.is_err(), + "expected sandlock_handler_free to unwind a panicking dropper instead of aborting", + ); +} From 8bcdb457e16da77a8dea58cc6040c7240d4c4e79 Mon Sep 17 00:00:00 2001 From: dzerik Date: Wed, 13 May 2026 12:25:11 +0300 Subject: [PATCH 16/24] test: strengthen layout and rejection coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B1: handler_new_rejects_invalid_exception_policy probed only one out-of-range value (99u32). A mutation that rejects only that single value would have slipped through. Iterate over the boundary (3u32, one past Continue=2), a mid-range value, and u32::MAX. B2: action_out_layout_is_stable verified only size and alignment. A field reorder that preserves the total size — e.g. swapping kind with the implicit padding — would have passed. Pin down each field's byte offset via addr_of! through MaybeUninit. Add the same offset-based test for sandlock_notif_data_t alongside the existing size assertion. --- crates/sandlock-ffi/tests/handler_smoke.rs | 96 ++++++++++++++++++---- 1 file changed, 82 insertions(+), 14 deletions(-) diff --git a/crates/sandlock-ffi/tests/handler_smoke.rs b/crates/sandlock-ffi/tests/handler_smoke.rs index 955149b..a02c350 100644 --- a/crates/sandlock-ffi/tests/handler_smoke.rs +++ b/crates/sandlock-ffi/tests/handler_smoke.rs @@ -97,11 +97,74 @@ fn action_setters_record_kind_and_payload() { #[test] fn action_out_layout_is_stable() { - // kind(4) + pad(4) + payload(16) = 24 bytes; alignment driven by the - // u64 inside the union. Layout drift between Rust and the C header - // would corrupt caller-allocated buffers. - assert_eq!(std::mem::size_of::(), 24); - assert_eq!(std::mem::align_of::(), 8); + // Size + align are gross guards; pin down field offsets so a + // field reorder that preserves size still gets caught. + use std::mem::{align_of, size_of, MaybeUninit}; + use sandlock_ffi::handler::sandlock_action_out_t; + + assert_eq!(size_of::(), 24, + "size drift breaks the C ABI layout"); + assert_eq!(align_of::(), 8, + "align drift breaks the C ABI layout"); + + // Hand-roll offset_of through MaybeUninit — works on stable Rust + // without an extra crate. The C header has kind at offset 0 and + // payload at offset 8 (4 bytes implicit padding after kind). + let mut probe = MaybeUninit::::uninit(); + let base = probe.as_mut_ptr() as usize; + let kind_offset = unsafe { std::ptr::addr_of_mut!((*probe.as_mut_ptr()).kind) as usize - base }; + let payload_offset = unsafe { std::ptr::addr_of_mut!((*probe.as_mut_ptr()).payload) as usize - base }; + assert_eq!(kind_offset, 0, "kind must be at offset 0"); + assert_eq!(payload_offset, 8, "payload must be at offset 8 (kind+4 bytes padding)"); +} + +#[test] +fn notif_data_field_offsets_are_stable() { + use std::mem::MaybeUninit; + use sandlock_ffi::notif_repr::sandlock_notif_data_t; + + let probe = MaybeUninit::::uninit(); + let base = probe.as_ptr() as usize; + + // C header order: id(u64), pid(u32), flags(u32), syscall_nr(i32), + // arch(u32), instruction_pointer(u64), args([u64;6]). Each + // `addr_of!` is cast to `*const u8` so the closure-free subtraction + // works uniformly across the heterogeneous field types. + assert_eq!( + unsafe { std::ptr::addr_of!((*probe.as_ptr()).id) as *const u8 as usize - base }, + 0, + "id must be at offset 0", + ); + assert_eq!( + unsafe { std::ptr::addr_of!((*probe.as_ptr()).pid) as *const u8 as usize - base }, + 8, + "pid must be at offset 8", + ); + assert_eq!( + unsafe { std::ptr::addr_of!((*probe.as_ptr()).flags) as *const u8 as usize - base }, + 12, + "flags must be at offset 12", + ); + assert_eq!( + unsafe { std::ptr::addr_of!((*probe.as_ptr()).syscall_nr) as *const u8 as usize - base }, + 16, + "syscall_nr must be at offset 16", + ); + assert_eq!( + unsafe { std::ptr::addr_of!((*probe.as_ptr()).arch) as *const u8 as usize - base }, + 20, + "arch must be at offset 20", + ); + assert_eq!( + unsafe { std::ptr::addr_of!((*probe.as_ptr()).instruction_pointer) as *const u8 as usize - base }, + 24, + "instruction_pointer must be at offset 24", + ); + assert_eq!( + unsafe { std::ptr::addr_of!((*probe.as_ptr()).args) as *const u8 as usize - base }, + 32, + "args must be at offset 32", + ); } use sandlock_ffi::handler::{ @@ -161,15 +224,20 @@ fn handler_new_and_free_round_trip() { #[test] fn handler_new_rejects_invalid_exception_policy() { - let h = unsafe { - sandlock_handler_new( - Some(test_handler as sandlock_handler_fn_t), - std::ptr::null_mut(), - None, - 99u32, // out of range - ) - }; - assert!(h.is_null(), "expected null handle on invalid on_exception"); + // Cover the boundary (one past the highest valid Continue=2), + // a mid-range value, and the extreme u32::MAX. A mutation that + // rejects only specific values would fail at least one of these. + for bad in [3u32, 4u32, 99u32, u32::MAX] { + let h = unsafe { + sandlock_handler_new( + Some(test_handler as sandlock_handler_fn_t), + std::ptr::null_mut(), + None, + bad, + ) + }; + assert!(h.is_null(), "expected null for on_exception={bad}"); + } } use sandlock_core::seccomp::dispatch::{Handler, HandlerCtx}; From 5deb8de55820f229a281a19fc57d3ca54b62c700 Mon Sep 17 00:00:00 2001 From: dzerik Date: Wed, 13 May 2026 12:36:01 +0300 Subject: [PATCH 17/24] fix: harden child pgid resolution against suicide vectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In nested PID namespaces (Kubernetes pod-in-pod, KubeVirt, DinD) the kernel can deliver a seccomp notification with notif.pid == 0 when the trapped task is invisible from the supervisor's PID namespace. `getpgid(0)` then returns the supervisor's own pgid; substituting that into a Kill { pgid } action sent via killpg kills the supervisor — a sandbox-escape vector reachable by any malicious child. Add three defensive guards before substitution: - reject notif.pid <= 0, - reject getpgid() failure (ESRCH and friends), - reject resolved pgid that matches the supervisor's own. In all three cases, fall back to the bare pid; the kernel will reject the malformed killpg with ESRCH, but the supervisor lives. Regression tests cover each guard with destructive verification: removing the corresponding arm produces test failures asserting the unfixed action would target the supervisor. --- crates/sandlock-ffi/src/handler.rs | 46 ++++- crates/sandlock-ffi/tests/handler_smoke.rs | 205 ++++++++++++++++++--- 2 files changed, 216 insertions(+), 35 deletions(-) diff --git a/crates/sandlock-ffi/src/handler.rs b/crates/sandlock-ffi/src/handler.rs index 599d90f..e4ac1d0 100644 --- a/crates/sandlock-ffi/src/handler.rs +++ b/crates/sandlock-ffi/src/handler.rs @@ -491,18 +491,44 @@ impl Handler for FfiHandler { let notif_fd = cx.notif_fd; let notif_id = cx.notif.id; let pid = cx.notif.pid; - // Query the kernel for the child's real process-group id. The supervisor - // catches the notification synchronously, so the child is still alive at - // this point. `getpgid` returns -1 on ESRCH (child exited between notif - // and our query) — fall back to the child's pid in that case so a - // downstream Kill action has at least one valid target even if the - // process is already a zombie. + // Resolve the trapped child's process group id for use as a fallback + // pgid in Kill actions where the caller passed pgid == 0. Three guard + // rails: + // + // 1. `notif.pid == 0` can occur in nested PID namespaces (e.g., + // Kubernetes pod-in-pod, KubeVirt, DinD). `getpgid(0)` returns + // the supervisor's own pgid — substituting that into a Kill + // action would be a supervisor-suicide vector. + // + // 2. `getpgid(pid) <= 0` indicates ESRCH (child exited between + // notif and our query) or another kernel-side failure. + // + // 3. Even on success, the resolved pgid must differ from the + // supervisor's own pgid. If sandlock-core does not call + // `setpgid(0, 0)` after fork, the child inherits the parent's + // pgid — sending `killpg(supervisor_pgid)` would kill the + // supervisor along with the child. + // + // In all three failure cases, fall back to the bare pid. A `killpg(pid)` + // when `pid` does not name a valid process group will fail with ESRCH + // inside the supervisor's response path — safer than killing the + // supervisor. let child_pgid = { let pid = cx.notif.pid as i32; - // SAFETY: `getpgid` is signal-safe and async-safe; no preconditions - // beyond passing a valid pid. - let pgid = unsafe { libc::getpgid(pid) }; - if pgid < 0 { pid } else { pgid } + // SAFETY: `getpgid(0)` is signal-safe and has no preconditions. + let supervisor_pgid = unsafe { libc::getpgid(0) }; + if pid <= 0 { + pid + } else { + // SAFETY: `getpgid` is signal-safe; positive pid is the only + // documented precondition. + let pgid = unsafe { libc::getpgid(pid) }; + if pgid <= 0 || pgid == supervisor_pgid { + pid + } else { + pgid + } + } }; let handler_fn = self.inner.handler_fn; let ud = UdPtr(self.inner.ud); diff --git a/crates/sandlock-ffi/tests/handler_smoke.rs b/crates/sandlock-ffi/tests/handler_smoke.rs index a02c350..b56928a 100644 --- a/crates/sandlock-ffi/tests/handler_smoke.rs +++ b/crates/sandlock-ffi/tests/handler_smoke.rs @@ -599,34 +599,54 @@ async fn ffi_handler_translates_kill_with_explicit_pgid() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn ffi_handler_translates_kill_zero_pgid_substitutes_child_pgid() { - // Spawn a real child so that getpgid(child.pid) is the test runner's - // pgid — distinct from child.pid (a fresh pid). This makes the test a - // genuine regression hook for the substitution: buggy production code - // that returns notif.pid would yield child.pid, but the production - // formula (getpgid(notif.pid)) yields the test runner's pgid. The - // mismatch causes the assertion to fail under the bug. - let mut child = std::process::Command::new("sleep") - .arg("30") - .spawn() - .expect("spawn sleep child"); + // Spawn a child that places itself into a fresh process group via + // `setpgid(0, 0)` in pre_exec. The child therefore becomes its own + // pgid leader: `getpgid(child_pid) == child_pid`, and crucially + // `getpgid(child_pid) != getpgid(0)` (the supervisor's pgid). + // + // The supervisor_pgid guard added in the defense-in-depth pass would + // otherwise refuse the substitution and fall back to the bare pid. + // By breaking the child away into its own group we keep this test + // exercising the happy path: zero pgid in a Kill action is replaced + // with the resolved pgid (here `== child_pid`, but reached through + // the substitution branch — not the supervisor-guard fallback). + use std::os::unix::process::CommandExt; + let mut cmd = std::process::Command::new("sleep"); + cmd.arg("30"); + unsafe { + cmd.pre_exec(|| { + // SAFETY: `setpgid` is async-signal-safe; pid=0 acts on the + // calling process; pgid=0 creates a new group whose leader + // is the calling process. + if libc::setpgid(0, 0) != 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + let mut child = cmd.spawn().expect("spawn sleep child"); let child_pid = child.id() as i32; - // Compute the expected pgid the same way production does. If `sleep` - // exited or was reaped between spawn and this call, fall back to the - // pid to mirror production's ESRCH branch. - let expected_pgid = { - // SAFETY: same as the production call — no preconditions. - let q = unsafe { libc::getpgid(child_pid) }; - if q < 0 { child_pid } else { q } - }; - // Sanity-check that this host actually exposes a meaningful pgid != - // pid for the spawned child. Otherwise the assertion below is - // satisfied by both the buggy and fixed implementations, making the - // test useless on this host. + let supervisor_pgid = unsafe { libc::getpgid(0) }; + // Poll briefly because pre_exec runs after fork but the parent may + // observe the pgid change asynchronously. + let mut resolved_pgid = unsafe { libc::getpgid(child_pid) }; + for _ in 0..50 { + if resolved_pgid == child_pid { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + resolved_pgid = unsafe { libc::getpgid(child_pid) }; + } + // The child is its own pgid leader; supervisor's pgid is distinct. + assert_eq!( + resolved_pgid, child_pid, + "precondition: pre_exec setpgid(0,0) should leave child as its own pgid leader", + ); assert_ne!( - expected_pgid, child_pid, - "test precondition: getpgid(child_pid) must differ from child_pid for this test to catch regressions; \ - got pgid={expected_pgid}, pid={child_pid}", + resolved_pgid, supervisor_pgid, + "precondition: child's pgid must differ from supervisor's pgid for the substitution branch to fire", ); + let expected_pgid = child_pid; let raw = unsafe { sandlock_ffi::handler::sandlock_handler_new( @@ -668,6 +688,141 @@ async fn ffi_handler_translates_kill_zero_pgid_substitutes_child_pgid() { ); } +// ---- Group K: defense-in-depth guards for child pgid resolution ---------- +// +// These tests verify the three guard rails in `FfiHandler::handle` that +// protect against the supervisor-suicide vector when resolving the +// fallback pgid for `Kill { pgid: 0 }` actions. + +fn fake_ctx_with_pid(pid: u32) -> HandlerCtx { + HandlerCtx { + notif: SeccompNotif { + id: 1, + pid, + flags: 0, + data: SeccompData { + nr: 39, + arch: 0xC000_003E, + instruction_pointer: 0, + args: [0; 6], + }, + }, + notif_fd: -1, + } +} + +extern "C-unwind" fn k_handler_set_kill_sigkill_zero_pgid( + _ud: *mut std::ffi::c_void, + _notif: *const sandlock_ffi::notif_repr::sandlock_notif_data_t, + _mem: *mut sandlock_ffi::handler::sandlock_mem_handle_t, + out: *mut sandlock_ffi::handler::sandlock_action_out_t, +) -> i32 { + unsafe { sandlock_ffi::handler::sandlock_action_set_kill(out, libc::SIGKILL, 0) }; + 0 +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn k1_pgid_resolution_rejects_pid_zero() { + // notif.pid == 0 can occur in nested PID namespaces (Kubernetes + // pod-in-pod). Without the `pid <= 0` guard, `getpgid(0)` would + // return the supervisor's own pgid, and a Kill { pgid: 0 } action + // would be substituted with `pgid == supervisor_pgid`, turning a + // killpg into supervisor suicide. + // + // With the guard: we fall back to the bare pid (here `0`). The + // kernel will reject `killpg(0)` inside the response path, but the + // supervisor survives. + let raw = unsafe { + sandlock_ffi::handler::sandlock_handler_new( + Some(k_handler_set_kill_sigkill_zero_pgid), + std::ptr::null_mut(), + None, + sandlock_exception_policy_t::Continue as u32, + ) + }; + let handler = unsafe { FfiHandler::from_raw(raw) }; + let cx = fake_ctx_with_pid(0); + let action = handler.handle(&cx).await; + assert!( + matches!(action, NotifAction::Kill { pgid: 0, .. }), + "expected Kill {{ pgid: 0 }} (guard refused getpgid(0) substitution), got {action:?}", + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn k2_pgid_resolution_rejects_supervisor_pgid_match() { + // Spawn a child WITHOUT pre_exec setpgid, so it inherits the + // supervisor's process group. `getpgid(child_pid) == getpgid(0) == + // supervisor_pgid`. Without the `pgid == supervisor_pgid` guard, + // the substitution would yield Kill { pgid: supervisor_pgid }, the + // supervisor-suicide vector. With the guard, we fall back to the + // bare child pid. + let supervisor_pgid = unsafe { libc::getpgid(0) }; + let mut child = std::process::Command::new("sleep") + .arg("30") + .spawn() + .expect("spawn sleep child"); + let child_pid = child.id() as i32; + + // The child inherits the supervisor's pgid by default. Confirm the + // precondition holds; otherwise this test cannot discriminate. + let resolved_pgid = unsafe { libc::getpgid(child_pid) }; + assert_eq!( + resolved_pgid, supervisor_pgid, + "precondition: child should inherit supervisor's pgid; got {resolved_pgid}, supervisor={supervisor_pgid}", + ); + assert_ne!( + child_pid, supervisor_pgid, + "precondition: child_pid must differ from supervisor_pgid; otherwise the assertion cannot discriminate substitution vs fallback", + ); + + let raw = unsafe { + sandlock_ffi::handler::sandlock_handler_new( + Some(k_handler_set_kill_sigkill_zero_pgid), + std::ptr::null_mut(), + None, + sandlock_exception_policy_t::Continue as u32, + ) + }; + let handler = unsafe { FfiHandler::from_raw(raw) }; + let cx = fake_ctx_with_pid(child_pid as u32); + let action = handler.handle(&cx).await; + + // Reap the child regardless of assertion outcome. + let _ = child.kill(); + let _ = child.wait(); + + assert!( + matches!(action, NotifAction::Kill { pgid, .. } if pgid == child_pid), + "expected Kill {{ pgid: child_pid={child_pid} }} (guard refused supervisor_pgid={supervisor_pgid} substitution), got {action:?}", + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn k3_pgid_resolution_falls_back_on_esrch() { + // Use a clearly-dead pid that will never exist on this host. + // `getpgid(i32::MAX)` returns -1 with ESRCH on Linux. Without the + // `pgid <= 0` guard, the action would be Kill { pgid: -1 }; with + // the guard, we fall back to the bare pid. + let dead_pid: u32 = i32::MAX as u32; + + let raw = unsafe { + sandlock_ffi::handler::sandlock_handler_new( + Some(k_handler_set_kill_sigkill_zero_pgid), + std::ptr::null_mut(), + None, + sandlock_exception_policy_t::Continue as u32, + ) + }; + let handler = unsafe { FfiHandler::from_raw(raw) }; + let cx = fake_ctx_with_pid(dead_pid); + let action = handler.handle(&cx).await; + assert!( + matches!(action, NotifAction::Kill { pgid, .. } if pgid == dead_pid as i32), + "expected Kill {{ pgid: dead_pid={dead_pid} }} (ESRCH fallback to bare pid), got {action:?}", + ); +} + // ---- Group C: exception policy fallbacks -------------------------------- #[tokio::test(flavor = "multi_thread", worker_threads = 2)] From 872c885bba0597cde4307c758ebc53d8c42af7af Mon Sep 17 00:00:00 2001 From: dzerik Date: Thu, 14 May 2026 10:47:50 +0300 Subject: [PATCH 18/24] fix: release handlers on collect_registrations validation failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainer review caught a gap in the always-consume contract: when the registration array contains a null handler, collect_registrations returns None and run_with_handlers_inner returned null without calling release_registrations. The non-null entries leaked — their ud_drop never fired — violating the public C-ABI contract documented in sandlock.h. Add release_registrations on that branch. The accompanying test was internally consistent with the old contract (it manually called sandlock_handler_free on the registered handler to verify the dropper fires), which masked the bug. Update the test to assert the supervisor performs the release without any manual free — matching the public contract and serving as a regression hook for both the implementation and the test pattern. Resolves comment 1 from PR #44 review. --- crates/sandlock-ffi/src/handler.rs | 15 +++++++++++---- crates/sandlock-ffi/tests/handler_smoke.rs | 17 ++++++++--------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/crates/sandlock-ffi/src/handler.rs b/crates/sandlock-ffi/src/handler.rs index e4ac1d0..8b2936a 100644 --- a/crates/sandlock-ffi/src/handler.rs +++ b/crates/sandlock-ffi/src/handler.rs @@ -917,12 +917,19 @@ unsafe fn run_with_handlers_inner( return std::ptr::null_mut(); } }; - // `collect_registrations` is itself transactional: on failure it - // doesn't consume any handlers. Preserve that contract — the - // caller still owns them on a `None` return from this point. let handlers = match collect_registrations(registrations, nregistrations) { Some(v) => v, - None => return std::ptr::null_mut(), + None => { + // Validation failed (null handler in the array). The + // non-null handlers in the array have not been taken into + // FfiHandler ownership by `collect_registrations` (it is + // validate-first), but the public C-ABI contract guarantees + // "array consumed as a whole" — release them here so the C + // caller is never responsible for any registered pointer + // after this call returns. + release_registrations(registrations, nregistrations); + return std::ptr::null_mut(); + } }; let sandbox_ref: &Sandbox = (*policy).inner(); match block_on_run(sandbox_ref, name_opt, cmd, handlers, interactive) { diff --git a/crates/sandlock-ffi/tests/handler_smoke.rs b/crates/sandlock-ffi/tests/handler_smoke.rs index b56928a..c46d48c 100644 --- a/crates/sandlock-ffi/tests/handler_smoke.rs +++ b/crates/sandlock-ffi/tests/handler_smoke.rs @@ -1143,11 +1143,13 @@ fn run_with_handlers_null_handler_in_array_returns_null() { }; assert!(!policy.is_null(), "policy build failed"); - // The validating handler must NOT be consumed by `sandlock_run_with_handlers` - // when validation fails — the call should be transactional. We assert this - // by registering `one_shot_dropper` and verifying it fires exactly once - // (from our explicit `sandlock_handler_free` call below, not from - // `sandlock_run_with_handlers`). + // The supervisor owns and frees the valid handler even when the + // call rejects the array because of a null entry. We assert this + // by registering `one_shot_dropper` and verifying it fires + // exactly once — from the supervisor's `release_registrations`, + // not from a manual `sandlock_handler_free` (which would now be + // a double-free per the always-consume contract documented in + // sandlock.h). ONE_SHOT_DROPPER_CALLS.store(0, std::sync::atomic::Ordering::SeqCst); let ud = Box::into_raw(Box::new(0xAAu32)) as *mut std::ffi::c_void; let valid = unsafe { @@ -1185,13 +1187,10 @@ fn run_with_handlers_null_handler_in_array_returns_null() { ) }; assert!(rr.is_null(), "expected null result when an array entry is null"); - // The valid handler must still be ours to free — proving it was not - // consumed by the failed call. - unsafe { sandlock_handler_free(valid) }; assert_eq!( ONE_SHOT_DROPPER_CALLS.load(std::sync::atomic::Ordering::SeqCst), 1, - "dropper must fire exactly once (from our explicit free)", + "dropper must fire exactly once (from the supervisor's release_registrations)", ); unsafe { sandlock_sandbox_free(policy); } From c2323a045ae5d2d091645885c9783d2e2d8c8491 Mon Sep 17 00:00:00 2001 From: dzerik Date: Thu, 14 May 2026 10:52:33 +0300 Subject: [PATCH 19/24] =?UTF-8?q?docs:=20align=20Sync=20contract=20?= =?UTF-8?q?=E2=80=94=20require=20thread-safe=20ud?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainer review flagged that the Sync safety comment claimed "supervisor serializes per handler" while the public C-ABI docs separately required `ud` to be thread-safe — a self-contradiction. Adopt the conservative position: the supervisor MAY dispatch handler callbacks concurrently across different notifications (the current loop is largely serial but the public ABI makes no concurrency guarantee). The C caller is responsible for ensuring their opaque `ud` is thread-safe. This survives a future dispatcher that parallelises without an ABI break. Resolves comment 2 from PR #44 review. --- crates/sandlock-ffi/include/sandlock.h | 26 +++++++++++++++++++-- crates/sandlock-ffi/src/handler.rs | 26 +++++++++++---------- docs/extension-handlers.md | 32 ++++++++++++++++++-------- 3 files changed, 61 insertions(+), 23 deletions(-) diff --git a/crates/sandlock-ffi/include/sandlock.h b/crates/sandlock-ffi/include/sandlock.h index 1248518..9c52aeb 100644 --- a/crates/sandlock-ffi/include/sandlock.h +++ b/crates/sandlock-ffi/include/sandlock.h @@ -228,11 +228,29 @@ typedef enum sandlock_exception_policy { SANDLOCK_EXCEPTION_CONTINUE = 2, } sandlock_exception_policy_t; +/** Opaque handler container. + * + * Ownership: allocated by `sandlock_handler_new` and freed by either + * `sandlock_handler_free` (if never registered) or by the supervisor + * after a successful or failed `sandlock_run_with_handlers` call. + * + * Thread safety: the supervisor MAY invoke the handler callback from + * multiple worker threads concurrently across different notifications + * (today's dispatch loop is largely serial; the public ABI makes no + * concurrency guarantee, so a future dispatcher could parallelise + * without breaking compatibility). The caller MUST ensure their `ud` + * pointer is thread-safe — either immutable, or guarded by their own + * synchronization primitives (atomics, mutex, etc.). Rust provides no + * synchronization for an opaque `void*`. */ typedef struct sandlock_handler_t sandlock_handler_t; /** C handler signature. Return 0 on success; a non-zero return triggers * the handler's exception policy. The callee MUST call exactly one - * sandlock_action_set_*() on `out` before returning 0. */ + * sandlock_action_set_*() on `out` before returning 0. + * + * Thread safety: see `sandlock_handler_t` — this function may be + * invoked concurrently from multiple worker threads. Any state + * reachable through `ud` must be thread-safe. */ typedef int (*sandlock_handler_fn_t)(void *ud, const sandlock_notif_data_t *notif, sandlock_mem_handle_t *mem, @@ -242,7 +260,11 @@ typedef void (*sandlock_handler_ud_drop_t)(void *ud); /** Allocate a handler container. Returns NULL when `handler_fn` is NULL * or when `on_exception` is not one of the documented `SANDLOCK_EXCEPTION_*` - * values. */ + * values. + * + * `ud` must be thread-safe to access — see `sandlock_handler_t` for + * the concurrency contract. `ud_drop`, if non-NULL, is invoked exactly + * once when the container is freed. */ sandlock_handler_t *sandlock_handler_new(sandlock_handler_fn_t handler_fn, void *ud, sandlock_handler_ud_drop_t ud_drop, diff --git a/crates/sandlock-ffi/src/handler.rs b/crates/sandlock-ffi/src/handler.rs index 8b2936a..51d2f43 100644 --- a/crates/sandlock-ffi/src/handler.rs +++ b/crates/sandlock-ffi/src/handler.rs @@ -347,19 +347,21 @@ pub struct sandlock_handler_t { // Safety: // -// `Send`: required so the supervisor can move the handler container into a -// `tokio::task::spawn_blocking` closure. The struct contains only pointers -// (function pointer + `void*` user-data) and a `#[repr(u32)]` enum, all of -// which are `Send`-safe to move across threads. +// `Send`: required so the supervisor can move the handler container into +// a `tokio::task::spawn_blocking` closure. The struct contains only +// pointers (function pointer + `void*` user-data) and a `#[repr(u32)]` +// enum, all of which are `Send`-safe to move across threads. // -// `Sync`: required because the supervisor's dispatch table stores -// handlers as `Arc`, and `Arc` requires `T: Send + -// Sync` even when actual dispatch is serial. In practice the -// supervisor's seccomp loop drives one notification at a time per -// handler instance, so the C caller's `ud` is touched from at most -// one worker thread at any given moment. The C caller is still -// responsible for ensuring `ud` is at least minimally thread-safe -// (no aliasing with other threads outside the supervisor loop). +// `Sync`: required because the dispatch table stores handlers as +// `Arc`, and `Arc` requires `T: Send + Sync`. The +// supervisor MAY dispatch handler invocations concurrently across +// different notifications (today's loop is largely serial, but the +// contract makes no guarantee — a future dispatcher could parallelise +// without breaking the public ABI). Consequently the C caller MUST +// ensure their `ud` is either immutable, or guarded by thread-safe +// state of their own (atomics, mutex, etc.). Rust offers no +// synchronization for an opaque `void*` — the responsibility is on +// the C side. unsafe impl Send for sandlock_handler_t {} unsafe impl Sync for sandlock_handler_t {} diff --git a/docs/extension-handlers.md b/docs/extension-handlers.md index a49fa0d..fd966ef 100644 --- a/docs/extension-handlers.md +++ b/docs/extension-handlers.md @@ -380,15 +380,18 @@ The contract is exercised at two layers: ### Continue-site safety -The supervisor processes notifications sequentially in a single tokio task, so the response sent -for one notification gates the kernel resumption of the trapped syscall. Sandlock-internal -locks (`tokio::sync::Mutex`/`RwLock`) live on the supervisor; user handlers do not have access -to them through `HandlerCtx`, so the contract here is local to handler-owned state on `&self`: -a `tokio::sync::Mutex` or `RwLock` field on your handler must not be held across an -`.await` point. If the guard is alive when control returns to the supervisor loop, the next -notification that needs the same lock parks, the response for the current notification is not -sent, and the child stays trapped in the syscall. Acquire, mutate, drop — `await` only after -the guard is out of scope. +Today's supervisor processes notifications sequentially in a single tokio task, so the response +sent for one notification gates the kernel resumption of the trapped syscall. Treat this as an +implementation detail, not a contract — the public API makes no promise that a future +dispatcher will not parallelise. The `Handler` trait already requires `Send + Sync`, and the C +ABI requires `ud` to be thread-safe (see [C ABI → Thread safety](#thread-safety)) for exactly +this reason. Sandlock-internal locks (`tokio::sync::Mutex`/`RwLock`) live on the supervisor; +user handlers do not have access to them through `HandlerCtx`, so the contract here is local to +handler-owned state on `&self`: a `tokio::sync::Mutex` or `RwLock` field on your handler +must not be held across an `.await` point. If the guard is alive when control returns to the +supervisor loop, the next notification that needs the same lock parks, the response for the +current notification is not sent, and the child stays trapped in the syscall. Acquire, mutate, +drop — `await` only after the guard is out of scope. See [issue #27][i27] for the underlying supervisor-loop contract that this convention extends to user handlers. @@ -634,6 +637,17 @@ A C handler must: panic (C has no unwinding); this clause is for Rust handlers plugged into the C ABI surface. +### Thread safety + +The supervisor MAY invoke a C handler callback from multiple worker +threads concurrently across different notifications. Today's dispatch +loop is largely serial, but the public C ABI makes no concurrency +guarantee — a future dispatcher could parallelise without an ABI +break. Consequently the caller MUST ensure their `ud` pointer is +thread-safe: either immutable, or guarded by their own synchronization +primitives (atomics, mutex, etc.). Rust offers no synchronization for +an opaque `void*`; the responsibility is on the C side. + ### Minimal example See `crates/sandlock-ffi/tests/c/handler_smoke.c` for the canonical From fcf133a3958ae9dc9a1a135056afaf055902e77e Mon Sep 17 00:00:00 2001 From: dzerik Date: Thu, 14 May 2026 10:58:04 +0300 Subject: [PATCH 20/24] refactor: split handler module into abi/adapter/run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainer review noted that the monolithic handler.rs (~940 lines) mixed ABI types, the FfiHandler adapter, helpers, and run entry points, blurring trust boundaries. Split into: * handler/abi.rs — public ABI types, setters, accessors * handler/adapter.rs — FfiHandler + translate_action + drain helpers * handler/run.rs — sandlock_run_with_handlers entry points handler/mod.rs re-exports the full public surface so external consumers and integration tests continue to import from `sandlock_ffi::handler::*` unchanged. Resolves comment 3 from PR #44 review. --- crates/sandlock-ffi/src/handler.rs | 944 --------------------- crates/sandlock-ffi/src/handler/abi.rs | 457 ++++++++++ crates/sandlock-ffi/src/handler/adapter.rs | 270 ++++++ crates/sandlock-ffi/src/handler/mod.rs | 16 + crates/sandlock-ffi/src/handler/run.rs | 236 ++++++ 5 files changed, 979 insertions(+), 944 deletions(-) delete mode 100644 crates/sandlock-ffi/src/handler.rs create mode 100644 crates/sandlock-ffi/src/handler/abi.rs create mode 100644 crates/sandlock-ffi/src/handler/adapter.rs create mode 100644 crates/sandlock-ffi/src/handler/mod.rs create mode 100644 crates/sandlock-ffi/src/handler/run.rs diff --git a/crates/sandlock-ffi/src/handler.rs b/crates/sandlock-ffi/src/handler.rs deleted file mode 100644 index 51d2f43..0000000 --- a/crates/sandlock-ffi/src/handler.rs +++ /dev/null @@ -1,944 +0,0 @@ -//! FFI surface for the sandlock `Handler` trait. See `docs/extension-handlers.md`. - -use std::os::unix::io::RawFd; -use std::slice; - -use sandlock_core::seccomp::notif::{read_child_cstr, read_child_mem, write_child_mem}; - -/// Opaque child-memory accessor handed to a C handler callback. -/// -/// Constructed on the stack inside the Rust adapter just before the -/// callback fires, invalidated when the callback returns. C handlers -/// must not store the pointer beyond the callback's return. -#[repr(C)] -#[allow(non_camel_case_types)] -pub struct sandlock_mem_handle_t { - notif_fd: RawFd, - notif_id: u64, - pid: u32, -} - -impl sandlock_mem_handle_t { - pub(crate) fn new(notif_fd: RawFd, notif_id: u64, pid: u32) -> Self { - Self { notif_fd, notif_id, pid } - } -} - -/// Read up to `max_len-1` bytes of a NUL-terminated string at `addr` from the -/// traced child. On success the destination buffer is NUL-terminated and -/// `*out_len` holds the byte count copied (excluding the NUL); returns 0. -/// On failure returns -1 and leaves `*out_len` untouched. `max_len` must be -/// at least 1 to fit the NUL terminator. -/// -/// # Safety -/// `handle` must point to a live `sandlock_mem_handle_t` provided by the -/// supervisor; `buf` must be writable for `max_len` bytes; `out_len` must -/// be a valid `size_t*`. -#[no_mangle] -pub unsafe extern "C" fn sandlock_mem_read_cstr( - handle: *const sandlock_mem_handle_t, - addr: u64, - buf: *mut u8, - max_len: usize, - out_len: *mut usize, -) -> i32 { - if handle.is_null() || buf.is_null() || out_len.is_null() || max_len == 0 { - return -1; - } - let h = &*handle; - // `max_len` is the caller-supplied buffer size including space for the - // trailing NUL; reserve one byte so we can always terminate the string. - let cap = max_len - 1; - let s = match read_child_cstr(h.notif_fd, h.notif_id, h.pid, addr, cap) { - Some(s) => s, - None => return -1, - }; - let bytes = s.as_bytes(); - let n = bytes.len().min(cap); - std::ptr::copy_nonoverlapping(bytes.as_ptr(), buf, n); - *buf.add(n) = 0; - *out_len = n; - 0 -} - -/// Raw byte read at `addr` of exactly `len` bytes. Writes byte count -/// actually read to `*out_len`. Returns 0 on success, -1 on failure. -/// -/// # Safety -/// Same constraints as `sandlock_mem_read_cstr`. -#[no_mangle] -pub unsafe extern "C" fn sandlock_mem_read( - handle: *const sandlock_mem_handle_t, - addr: u64, - buf: *mut u8, - len: usize, - out_len: *mut usize, -) -> i32 { - if handle.is_null() || buf.is_null() || out_len.is_null() { - return -1; - } - let h = &*handle; - let v = match read_child_mem(h.notif_fd, h.notif_id, h.pid, addr, len) { - Ok(v) => v, - Err(_) => return -1, - }; - let n = v.len(); - std::ptr::copy_nonoverlapping(v.as_ptr(), buf, n); - *out_len = n; - 0 -} - -/// Write `len` bytes from `buf` into the child at `addr`. Returns 0 on -/// success, -1 on failure. -/// -/// # Safety -/// Same constraints as `sandlock_mem_read_cstr`; `buf` must be readable -/// for `len` bytes. -#[no_mangle] -pub unsafe extern "C" fn sandlock_mem_write( - handle: *const sandlock_mem_handle_t, - addr: u64, - buf: *const u8, - len: usize, -) -> i32 { - if handle.is_null() || buf.is_null() { - return -1; - } - let h = &*handle; - let data = slice::from_raw_parts(buf, len); - match write_child_mem(h.notif_fd, h.notif_id, h.pid, addr, data) { - Ok(()) => 0, - Err(_) => -1, - } -} - -/// Tag distinguishing payload variants of `sandlock_action_out_t`. -#[repr(u32)] -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -#[allow(non_camel_case_types)] -pub enum sandlock_action_kind_t { - /// No action set yet; the supervisor treats this as "fall through to - /// the handler's on_exception policy" (see `exception_action` in - /// `FfiHandler`). - Unset = 0, - Continue = 1, - Errno = 2, - ReturnValue = 3, - InjectFdSend = 4, - InjectFdSendTracked = 5, - Hold = 6, - Kill = 7, -} - -#[repr(C)] -#[derive(Clone, Copy)] -#[allow(non_camel_case_types)] -pub struct sandlock_action_kill_t { - pub sig: i32, - pub pgid: i32, -} - -#[repr(C)] -#[derive(Clone, Copy)] -#[allow(non_camel_case_types)] -pub struct sandlock_action_inject_t { - /// Owned by the C caller; ownership transfers to the supervisor on - /// successful invocation of the corresponding setter. - pub srcfd: i32, - pub newfd_flags: u32, -} - -/// Token reserved for a future tracker-aware inject variant. Currently -/// unimplemented — kept as a type alias so the ABI of the -/// `sandlock_action_inject_tracked_t` payload stays stable across the -/// future release that wires the tracker callback. -#[allow(non_camel_case_types)] -pub type sandlock_inject_tracker_t = u64; - -#[repr(C)] -#[derive(Clone, Copy)] -#[allow(non_camel_case_types)] -pub struct sandlock_action_inject_tracked_t { - pub srcfd: i32, - pub newfd_flags: u32, - pub tracker: sandlock_inject_tracker_t, -} - -#[repr(C)] -#[allow(non_camel_case_types)] -pub union sandlock_action_payload_t { - pub none: u64, - pub errno: i32, - pub return_value: i64, - pub inject_send: sandlock_action_inject_t, - pub inject_send_tracked: sandlock_action_inject_tracked_t, - pub kill: sandlock_action_kill_t, -} - -#[repr(C)] -#[allow(non_camel_case_types)] -pub struct sandlock_action_out_t { - pub kind: u32, - pub payload: sandlock_action_payload_t, -} - -impl sandlock_action_out_t { - /// Construct an `Unset` action with all payload bytes zero. The payload - /// union has variants up to 16 bytes; this ensures all bytes are - /// initialised before the C handler writes its decision. - pub fn zeroed() -> Self { - // Safety: `sandlock_action_payload_t` is `#[repr(C)]` with only - // integer-and-integer-aggregate variants; the zero bit-pattern is - // valid for all of them. - Self { - kind: sandlock_action_kind_t::Unset as u32, - payload: unsafe { std::mem::MaybeUninit::zeroed().assume_init() }, - } - } -} - -/// Mark the action as `Continue` (let the syscall proceed unchanged). -/// -/// # Safety -/// `out` must be a valid pointer to a `sandlock_action_out_t` writable -/// for the duration of the call, or null (in which case the call is a -/// no-op). -#[no_mangle] -pub unsafe extern "C" fn sandlock_action_set_continue(out: *mut sandlock_action_out_t) { - if out.is_null() { return; } - (*out).kind = sandlock_action_kind_t::Continue as u32; -} - -/// Fail the syscall with `errno`. -/// -/// # Safety -/// Same constraints as `sandlock_action_set_continue`. -#[no_mangle] -pub unsafe extern "C" fn sandlock_action_set_errno(out: *mut sandlock_action_out_t, errno: i32) { - if out.is_null() { return; } - (*out).kind = sandlock_action_kind_t::Errno as u32; - (*out).payload.errno = errno; -} - -/// Return a specific value from the syscall without entering the kernel. -/// -/// # Safety -/// Same constraints as `sandlock_action_set_continue`. -#[no_mangle] -pub unsafe extern "C" fn sandlock_action_set_return_value( - out: *mut sandlock_action_out_t, - value: i64, -) { - if out.is_null() { return; } - (*out).kind = sandlock_action_kind_t::ReturnValue as u32; - (*out).payload.return_value = value; -} - -/// Inject the supervisor-side fd `srcfd` into the traced child as a new -/// fd (number chosen by the kernel via `SECCOMP_IOCTL_NOTIF_ADDFD`). -/// -/// Note: ownership of `srcfd` transfers from the C caller to the -/// supervisor only when the resulting action is actually dispatched. -/// If the C caller subsequently calls a different setter on the same -/// `sandlock_action_out_t` (overwriting the kind tag before the -/// supervisor reads it), `srcfd` is NOT closed and leaks. Pick one -/// setter per action. -/// -/// # Safety -/// Same constraints as `sandlock_action_set_continue`; `srcfd` must be -/// a valid open fd in the supervisor process at the moment of the -/// supervisor's dispatch. -#[no_mangle] -pub unsafe extern "C" fn sandlock_action_set_inject_fd_send( - out: *mut sandlock_action_out_t, - srcfd: RawFd, - newfd_flags: u32, -) { - if out.is_null() { return; } - (*out).kind = sandlock_action_kind_t::InjectFdSend as u32; - (*out).payload.inject_send = sandlock_action_inject_t { srcfd, newfd_flags }; -} - -/// Hold the syscall pending until the supervisor explicitly releases it. -/// -/// # Safety -/// Same constraints as `sandlock_action_set_continue`. -#[no_mangle] -pub unsafe extern "C" fn sandlock_action_set_hold(out: *mut sandlock_action_out_t) { - if out.is_null() { return; } - (*out).kind = sandlock_action_kind_t::Hold as u32; -} - -/// Kill the target with signal `sig`. Pass `pgid > 0` to target an -/// explicit process group; `pgid == 0` is a sentinel — the supervisor -/// substitutes the child process group id resolved via `getpgid(pid)` -/// on the notification's pid. -/// -/// # Safety -/// Same constraints as `sandlock_action_set_continue`. -#[no_mangle] -pub unsafe extern "C" fn sandlock_action_set_kill( - out: *mut sandlock_action_out_t, - sig: i32, - pgid: i32, -) { - if out.is_null() { return; } - (*out).kind = sandlock_action_kind_t::Kill as u32; - (*out).payload.kill = sandlock_action_kill_t { sig, pgid }; -} - -/// Exception policy applied when the handler callback fails to set a -/// valid action (returns non-zero rc, leaves `kind == Unset`, or panics -/// across the FFI boundary). -#[repr(u32)] -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -#[allow(non_camel_case_types)] -pub enum sandlock_exception_policy_t { - /// Treat the failure as `NotifAction::Kill { sig: SIGKILL, pgid: child_pgid }`. - /// Default; "fail-closed" — the safe option. - Kill = 0, - /// Treat the failure as `NotifAction::Errno(EPERM)`. Useful for - /// audit-style handlers where the syscall is what failed rather than - /// the supervisor. - DenyEperm = 1, - /// Treat the failure as `NotifAction::Continue`. Explicit fail-open; - /// only safe when the syscall is *also* allowed by the BPF filter and - /// Landlock layer (e.g. observability handlers). - Continue = 2, -} - -/// C-callable handler entry point. -/// -/// Returns 0 on success (and must have called exactly one setter on -/// `out`). Returns non-zero to signal a handler-internal error; the -/// supervisor then applies the configured exception policy. -/// -/// The ABI is `extern "C-unwind"` rather than plain `extern "C"`. Pure-C -/// callers see no difference (C has no unwinding); Rust handlers plugged -/// into this C ABI surface may panic and the supervisor's `catch_unwind` -/// in [`FfiHandler::handle`] will route the panic to the configured -/// exception policy instead of aborting the process. -#[allow(non_camel_case_types)] -pub type sandlock_handler_fn_t = extern "C-unwind" fn( - ud: *mut std::ffi::c_void, - notif: *const crate::notif_repr::sandlock_notif_data_t, - mem: *mut sandlock_mem_handle_t, - out: *mut sandlock_action_out_t, -) -> i32; - -/// Optional destructor invoked when the container is freed. -/// -/// Uses `extern "C-unwind"` for consistency with [`sandlock_handler_fn_t`] -/// and so that a Rust-side destructor panicking through this pointer -/// unwinds rather than aborts (panic-safety in destructors is good -/// practice even though no in-tree caller currently relies on it). -#[allow(non_camel_case_types)] -pub type sandlock_handler_ud_drop_t = extern "C-unwind" fn(ud: *mut std::ffi::c_void); - -/// Opaque handler container (B4 — opaque box). -#[repr(C)] -#[allow(non_camel_case_types)] -pub struct sandlock_handler_t { - pub(crate) handler_fn: Option, - pub(crate) ud: *mut std::ffi::c_void, - pub(crate) ud_drop: Option, - pub(crate) on_exception: sandlock_exception_policy_t, -} - -// Safety: -// -// `Send`: required so the supervisor can move the handler container into -// a `tokio::task::spawn_blocking` closure. The struct contains only -// pointers (function pointer + `void*` user-data) and a `#[repr(u32)]` -// enum, all of which are `Send`-safe to move across threads. -// -// `Sync`: required because the dispatch table stores handlers as -// `Arc`, and `Arc` requires `T: Send + Sync`. The -// supervisor MAY dispatch handler invocations concurrently across -// different notifications (today's loop is largely serial, but the -// contract makes no guarantee — a future dispatcher could parallelise -// without breaking the public ABI). Consequently the C caller MUST -// ensure their `ud` is either immutable, or guarded by thread-safe -// state of their own (atomics, mutex, etc.). Rust offers no -// synchronization for an opaque `void*` — the responsibility is on -// the C side. -unsafe impl Send for sandlock_handler_t {} -unsafe impl Sync for sandlock_handler_t {} - -impl Drop for sandlock_handler_t { - fn drop(&mut self) { - if let Some(drop_fn) = self.ud_drop.take() { - if !self.ud.is_null() { - (drop_fn)(self.ud); - self.ud = std::ptr::null_mut(); - } - } - } -} - -/// Allocate a handler container. `handler_fn` must be non-null; passing -/// `ud_drop = None` is legal when `ud` does not require cleanup. -/// -/// # Safety -/// `ud` is opaque to Rust — the caller guarantees that the pointer -/// remains valid until either (a) `sandlock_handler_free` is called or -/// (b) the supervisor takes ownership via `sandlock_run_with_handlers` -/// and the run completes. -/// If `on_exception` does not match a defined `sandlock_exception_policy_t` -/// discriminant (0, 1, or 2), the call returns null and no allocation occurs. -#[no_mangle] -pub unsafe extern "C" fn sandlock_handler_new( - handler_fn: Option, - ud: *mut std::ffi::c_void, - ud_drop: Option, - on_exception: u32, -) -> *mut sandlock_handler_t { - if handler_fn.is_none() { - return std::ptr::null_mut(); - } - let on_exception = match on_exception { - 0 => sandlock_exception_policy_t::Kill, - 1 => sandlock_exception_policy_t::DenyEperm, - 2 => sandlock_exception_policy_t::Continue, - // Reject out-of-range discriminants at the FFI boundary so we never - // store an invalid enum value into the struct — reading one later - // via `match` would be undefined behaviour. - _ => return std::ptr::null_mut(), - }; - let h = Box::new(sandlock_handler_t { - handler_fn, - ud, - ud_drop, - on_exception, - }); - Box::into_raw(h) -} - -/// Free a handler container that has *not* been registered with a -/// sandbox. After successful registration the supervisor owns the -/// handler; calling this on a registered handler is undefined behaviour -/// (the supervisor's later free would double-free). -/// -/// The ABI is `extern "C-unwind"` rather than plain `extern "C"` so a -/// panic propagated from a Rust-side `ud_drop` (declared as -/// [`sandlock_handler_ud_drop_t`], itself `extern "C-unwind"`) unwinds -/// the caller rather than aborting the process. Pure-C callers see no -/// difference (C has no unwinding). -/// -/// # Safety -/// `h` must be either null or a pointer previously returned by -/// `sandlock_handler_new` that has not yet been registered with the -/// supervisor and has not already been freed. -#[no_mangle] -pub unsafe extern "C-unwind" fn sandlock_handler_free(h: *mut sandlock_handler_t) { - if h.is_null() { return; } - drop(Box::from_raw(h)); -} - -use sandlock_core::seccomp::dispatch::{Handler, HandlerCtx}; -use sandlock_core::seccomp::notif::NotifAction; -use std::future::Future; -use std::os::unix::io::FromRawFd; -use std::pin::Pin; - -/// Rust adapter wrapping an owned `sandlock_handler_t` and implementing -/// `Handler`. Constructed when the supervisor accepts handlers passed -/// through `sandlock_run_with_handlers`. -pub struct FfiHandler { - inner: Box, -} - -impl FfiHandler { - /// Take ownership of a raw `sandlock_handler_t*` produced by - /// `sandlock_handler_new`. - /// - /// # Safety - /// `raw` must be a non-null pointer returned by `sandlock_handler_new` - /// and never freed via `sandlock_handler_free`. After this call the - /// supervisor owns the container. - pub unsafe fn from_raw(raw: *mut sandlock_handler_t) -> Self { - assert!(!raw.is_null(), "FfiHandler::from_raw on null pointer"); - Self { inner: Box::from_raw(raw) } - } - - fn exception_action(&self, child_pgid: i32) -> NotifAction { - match self.inner.on_exception { - sandlock_exception_policy_t::Kill => { - NotifAction::Kill { sig: libc::SIGKILL, pgid: child_pgid } - } - sandlock_exception_policy_t::DenyEperm => NotifAction::Errno(libc::EPERM), - sandlock_exception_policy_t::Continue => NotifAction::Continue, - } - } -} - -/// `Send`-only wrapper around the C user-data pointer so it can travel -/// into `spawn_blocking`. Only the move (not sharing across threads) is -/// required; the deeper Send/Sync rationale for the underlying handler -/// container lives on `sandlock_handler_t`. -struct UdPtr(*mut std::ffi::c_void); -// Safety: ud is opaque to Rust; the spawn_blocking pipeline only moves -// (not shares) the wrapper. See `sandlock_handler_t` for the deeper -// Send/Sync rationale that justifies the underlying handler container. -unsafe impl Send for UdPtr {} - -impl Handler for FfiHandler { - fn handle<'a>( - &'a self, - cx: &'a HandlerCtx, - ) -> Pin + Send + 'a>> { - // Capture the pieces we need by value so spawn_blocking can run - // the C callback on a worker thread without &self lifetime games. - let notif_snap = crate::notif_repr::sandlock_notif_data_t::from(&cx.notif); - let notif_fd = cx.notif_fd; - let notif_id = cx.notif.id; - let pid = cx.notif.pid; - // Resolve the trapped child's process group id for use as a fallback - // pgid in Kill actions where the caller passed pgid == 0. Three guard - // rails: - // - // 1. `notif.pid == 0` can occur in nested PID namespaces (e.g., - // Kubernetes pod-in-pod, KubeVirt, DinD). `getpgid(0)` returns - // the supervisor's own pgid — substituting that into a Kill - // action would be a supervisor-suicide vector. - // - // 2. `getpgid(pid) <= 0` indicates ESRCH (child exited between - // notif and our query) or another kernel-side failure. - // - // 3. Even on success, the resolved pgid must differ from the - // supervisor's own pgid. If sandlock-core does not call - // `setpgid(0, 0)` after fork, the child inherits the parent's - // pgid — sending `killpg(supervisor_pgid)` would kill the - // supervisor along with the child. - // - // In all three failure cases, fall back to the bare pid. A `killpg(pid)` - // when `pid` does not name a valid process group will fail with ESRCH - // inside the supervisor's response path — safer than killing the - // supervisor. - let child_pgid = { - let pid = cx.notif.pid as i32; - // SAFETY: `getpgid(0)` is signal-safe and has no preconditions. - let supervisor_pgid = unsafe { libc::getpgid(0) }; - if pid <= 0 { - pid - } else { - // SAFETY: `getpgid` is signal-safe; positive pid is the only - // documented precondition. - let pgid = unsafe { libc::getpgid(pid) }; - if pgid <= 0 || pgid == supervisor_pgid { - pid - } else { - pgid - } - } - }; - let handler_fn = self.inner.handler_fn; - let ud = UdPtr(self.inner.ud); - let on_exception_fallback = self.exception_action(child_pgid); - - Box::pin(async move { - let join = tokio::task::spawn_blocking(move || { - // Rust 2021 disjoint closure captures (RFC 2229) would - // otherwise capture `ud.0` (a bare `*mut c_void`, not - // `Send`) rather than the whole `UdPtr`. Binding `ud` to - // a fresh local at the top of the closure forces a - // whole-struct capture so the `Send` impl on `UdPtr` - // applies to the outer closure. - let ud = ud; - let UdPtr(ud_raw) = ud; - let mut mem = sandlock_mem_handle_t::new(notif_fd, notif_id, pid); - let mut out = sandlock_action_out_t::zeroed(); - let rc = match handler_fn { - Some(f) => std::panic::catch_unwind(std::panic::AssertUnwindSafe( - || f(ud_raw, ¬if_snap, &mut mem, &mut out), - )), - None => Ok(-1), - }; - (rc, out) - }).await; - - let (rc_or_panic, out) = match join { - Ok(pair) => pair, - Err(_join_err) => return on_exception_fallback, - }; - - match rc_or_panic { - Ok(0) => match translate_action(&out, child_pgid) { - Some(action) => action, - None => { - // Action kind ended up Unset, unknown, or the - // reserved InjectFdSendTracked discriminant. - // Drain any inject-fd payload before falling - // back to the exception policy — otherwise the - // supervisor leaks the srcfd that was armed by - // the (failed) callback. - // SAFETY: `drain_pending_inject_fd` inspects - // `out.kind` itself before touching the union, - // and `out.kind` matches the union variant per - // the action setters' contract. - unsafe { drain_pending_inject_fd(&out) }; - on_exception_fallback - } - }, - _ => { - // Either the callback returned a non-zero rc OR - // `catch_unwind` caught a panic. The callback may - // have armed an InjectFdSend{,Tracked} payload - // before failing; drain it so its srcfd doesn't - // leak in the supervisor. - // SAFETY: see the `Ok(0) -> None` branch above. - unsafe { drain_pending_inject_fd(&out) }; - on_exception_fallback - } - } - }) - } -} - -/// Drains a still-pending `InjectFdSend` or `InjectFdSendTracked` -/// payload by consuming the contained `srcfd` into an `OwnedFd` and -/// dropping it (which closes the fd). Called from error paths in -/// [`FfiHandler::handle`] that fall back to the exception policy -/// without dispatching the action — without this, the supervisor -/// silently leaks fds armed by a C handler that subsequently panicked -/// or returned a non-zero rc. -/// -/// No-op for any other action kind (including `Unset`). -/// -/// # Safety -/// `out` must point at a fully-initialised `sandlock_action_out_t`. -/// The function inspects only `out.kind` and the union arm matching -/// that kind, which is sound because the action setters establish the -/// invariant "the `kind` tag selects the union arm". -unsafe fn drain_pending_inject_fd(out: &sandlock_action_out_t) { - use sandlock_action_kind_t as K; - if out.kind == K::InjectFdSend as u32 { - // SAFETY: `kind == InjectFdSend` selects the `inject_send` - // arm per the setter contract. Wrapping the raw fd in an - // `OwnedFd` and dropping it closes the fd. - drop(std::os::unix::io::OwnedFd::from_raw_fd( - out.payload.inject_send.srcfd, - )); - } else if out.kind == K::InjectFdSendTracked as u32 { - // The C header exposes the discriminant value publicly even - // though we don't ship a setter for it. A C caller can still - // assign `out->kind = 5; out->payload.inject_send_tracked.srcfd = X;` - // by hand. Treat it like `InjectFdSend` for cleanup purposes: - // the srcfd was armed and must be released. - // SAFETY: see `InjectFdSend` arm above. - drop(std::os::unix::io::OwnedFd::from_raw_fd( - out.payload.inject_send_tracked.srcfd, - )); - } -} - -/// Convert the C-side decision into a `NotifAction`. Returns `None` if -/// the kind is `Unset`, unknown, or `InjectFdSendTracked` (no setter -/// exposed; treated as fallback). The caller then falls back to the -/// exception policy, and is responsible for invoking -/// [`drain_pending_inject_fd`] to release any armed inject-fd payload. -/// -/// Note: this function takes `&sandlock_action_out_t` rather than -/// consuming the struct so that the caller can still inspect `out.kind` -/// on the `None` branch and drain any pending fd payload. The -/// `InjectFdSend` arm uses `OwnedFd::from_raw_fd` on the union field, -/// which is what materialises the ownership transfer from the C caller -/// to the supervisor when this branch is taken. -fn translate_action(out: &sandlock_action_out_t, child_pgid: i32) -> Option { - use sandlock_action_kind_t as K; - let kind = match out.kind { - x if x == K::Continue as u32 => K::Continue, - x if x == K::Errno as u32 => K::Errno, - x if x == K::ReturnValue as u32 => K::ReturnValue, - x if x == K::InjectFdSend as u32 => K::InjectFdSend, - // Discriminant reserved for a future tracker-injection ABI; no - // setter is exposed in this release. A C caller can still set - // it by hand (the value is public in the C header). Return - // `None` so the caller drains the srcfd and falls back to the - // exception policy. - x if x == K::InjectFdSendTracked as u32 => return None, - x if x == K::Hold as u32 => K::Hold, - x if x == K::Kill as u32 => K::Kill, - _ => return None, // Unset or unknown - }; - - // Safety: the `out.payload` union variant matched here was just - // selected by the `kind` discriminant above. The C action setters - // documented in this module pair each `kind` value with exactly one - // payload variant, so reading that variant is the only legal access. - // For `InjectFdSend` the documented contract on - // `sandlock_action_set_inject_fd_send` transfers ownership of - // `srcfd` to the supervisor; wrapping it in an `OwnedFd` here is - // what materialises that transfer. - let action = unsafe { - match kind { - K::Continue => NotifAction::Continue, - K::Errno => NotifAction::Errno(out.payload.errno), - K::ReturnValue => NotifAction::ReturnValue(out.payload.return_value), - K::Hold => NotifAction::Hold, - K::Kill => { - let pgid = if out.payload.kill.pgid == 0 { - child_pgid - } else { - out.payload.kill.pgid - }; - NotifAction::Kill { sig: out.payload.kill.sig, pgid } - } - K::InjectFdSend => NotifAction::InjectFdSend { - srcfd: std::os::unix::io::OwnedFd::from_raw_fd(out.payload.inject_send.srcfd), - newfd_flags: out.payload.inject_send.newfd_flags, - }, - K::InjectFdSendTracked | K::Unset => unreachable!(), - } - }; - Some(action) -} - -// ---------------------------------------------------------------- -// Run entry points -// ---------------------------------------------------------------- - -use sandlock_core::{Sandbox, RunResult, SandlockError}; -use std::ffi::CStr; - -/// C-side pair of `(syscall_nr, handler*)` consumed by -/// `sandlock_run_with_handlers`. Ownership of `handler` transfers into -/// the run on success; the supervisor frees the container. -#[repr(C)] -#[derive(Clone, Copy)] -#[allow(non_camel_case_types)] -pub struct sandlock_handler_registration_t { - pub syscall_nr: i64, - pub handler: *mut sandlock_handler_t, -} - -// Safety: the raw pointer field is opaque to Rust. The supervisor moves -// the registration array into a worker thread once it has been turned -// into `(i64, FfiHandler)` pairs; the registration struct itself never -// crosses thread boundaries while holding the raw pointer. We mark -// `Send` to allow the input array to be borrowed inside `unsafe` -// contexts without per-call wrapper structs. -unsafe impl Send for sandlock_handler_registration_t {} - -fn argv_from_c( - argv: *const *const std::os::raw::c_char, - argc: u32, -) -> Option> { - if argv.is_null() { - return None; - } - // Reject argc == 0 here: an empty argv would have us hand the - // sandbox an empty command vector, which the supervisor cannot - // execute. Failing fast keeps the error surfacing at the FFI - // boundary where the C caller can react. - if argc == 0 { - return None; - } - let mut out = Vec::with_capacity(argc as usize); - for i in 0..(argc as isize) { - let p = unsafe { *argv.offset(i) }; - if p.is_null() { - return None; - } - let s = unsafe { CStr::from_ptr(p) }.to_str().ok()?.to_owned(); - out.push(s); - } - Some(out) -} - -fn collect_registrations( - regs: *const sandlock_handler_registration_t, - nregs: usize, -) -> Option> { - if regs.is_null() && nregs > 0 { - return None; - } - if nregs == 0 { - return Some(Vec::new()); - } - let slice = unsafe { slice::from_raw_parts(regs, nregs) }; - // First pass: validate all entries before taking ownership of any. - // Without this, a null pointer at index k+1 would leave us having - // already consumed handlers [0..k] via `Box::from_raw`; dropping the - // partial `out` would free them while the C caller still believes it - // owns the originals — a latent double-free via - // `sandlock_handler_free`. - for r in slice { - if r.handler.is_null() { - return None; - } - } - // Second pass: ownership transfer. Every pointer is non-null per the - // pass above. - let mut out = Vec::with_capacity(nregs); - for r in slice { - // SAFETY: validated non-null above; caller provided pointer from - // `sandlock_handler_new` and must not reuse after this call (the - // public C ABI doc states ownership transfers in). - let h = unsafe { FfiHandler::from_raw(r.handler) }; - out.push((r.syscall_nr, h)); - } - Some(out) -} - -fn block_on_run( - sandbox: &Sandbox, - name: Option, - cmd: Vec, - handlers: Vec<(i64, FfiHandler)>, - interactive: bool, -) -> Option> { - // Use a fresh runtime — sandlock-core already pulls in tokio with - // rt-multi-thread; this matches the pattern used by the existing - // `sandlock_run` path. A panic in an `extern "C"`-reachable path is - // UB, so we report runtime-build failure to the caller via `None` - // instead of unwrapping. - let rt = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .ok()?; - let cmd_refs: Vec<&str> = cmd.iter().map(String::as_str).collect(); - // Apply `name` via the builder method on a clone — mirrors the - // pattern used by `sandlock_run` in lib.rs. A `None` here means - // "auto-generate `sandbox-{pid}`", matching the C ABI contract. - let mut sb = match name { - Some(n) => sandbox.clone().with_name(n), - None => sandbox.clone(), - }; - Some(rt.block_on(async move { - if interactive { - sb.run_interactive_with_extra_handlers(&cmd_refs, handlers).await - } else { - sb.run_with_extra_handlers(&cmd_refs, handlers).await - } - })) -} - -/// Run the policy with extra C handlers. Returns NULL on failure. -/// -/// `name` may be NULL to auto-generate `sandbox-{pid}`, or a valid -/// NUL-terminated UTF-8 C string; the placement mirrors the existing -/// `sandlock_run` entry point in `lib.rs`. -/// -/// # Safety -/// All pointer arguments must be valid for their documented lifetimes: -/// `policy` must come from `sandlock_sandbox_build`, `argv` must be a -/// readable array of `argc` NUL-terminated strings, and each handler -/// pointer must come from `sandlock_handler_new` and must not be reused -/// after this call (ownership transfers in). -#[no_mangle] -pub unsafe extern "C" fn sandlock_run_with_handlers( - policy: *const crate::sandlock_sandbox_t, - name: *const std::os::raw::c_char, - argv: *const *const std::os::raw::c_char, - argc: u32, - registrations: *const sandlock_handler_registration_t, - nregistrations: usize, -) -> *mut crate::sandlock_result_t { - run_with_handlers_inner(policy, name, argv, argc, registrations, nregistrations, false) -} - -/// Interactive-stdio variant of `sandlock_run_with_handlers`. -/// -/// `name` follows the same convention as `sandlock_run_with_handlers`. -/// -/// # Safety -/// Same constraints as `sandlock_run_with_handlers`. -#[no_mangle] -pub unsafe extern "C" fn sandlock_run_interactive_with_handlers( - policy: *const crate::sandlock_sandbox_t, - name: *const std::os::raw::c_char, - argv: *const *const std::os::raw::c_char, - argc: u32, - registrations: *const sandlock_handler_registration_t, - nregistrations: usize, -) -> *mut crate::sandlock_result_t { - run_with_handlers_inner(policy, name, argv, argc, registrations, nregistrations, true) -} - -/// Drops every non-null handler pointer in the registration array. -/// Used by [`run_with_handlers_inner`] on early-return paths where -/// `collect_registrations` was not reached — guarantees the C ABI -/// contract "all handler pointers are consumed by this call". -/// -/// # Safety -/// `regs` is either null (no-op) or points to `nregs` valid -/// `sandlock_handler_registration_t` slots whose `handler` pointer is -/// either null or comes from `sandlock_handler_new` and has not been -/// freed by anyone else. -unsafe fn release_registrations( - regs: *const sandlock_handler_registration_t, - nregs: usize, -) { - if regs.is_null() || nregs == 0 { - return; - } - let slice = slice::from_raw_parts(regs, nregs); - for r in slice { - if !r.handler.is_null() { - // Reclaim and drop the container so its `ud_drop` runs. - drop(Box::from_raw(r.handler)); - } - } -} - -unsafe fn run_with_handlers_inner( - policy: *const crate::sandlock_sandbox_t, - name: *const std::os::raw::c_char, - argv: *const *const std::os::raw::c_char, - argc: u32, - registrations: *const sandlock_handler_registration_t, - nregistrations: usize, - interactive: bool, -) -> *mut crate::sandlock_result_t { - if policy.is_null() { - // Honour the documented contract: ownership of every handler - // pointer transfers in on entry, regardless of return value. - release_registrations(registrations, nregistrations); - return std::ptr::null_mut(); - } - // Decode the optional name eagerly so a malformed (non-UTF-8) C - // string fails the call fast, before we take ownership of any - // handler containers via `collect_registrations`. Matches the - // contract used by `sandlock_run`. - let name_opt: Option = if name.is_null() { - None - } else { - match CStr::from_ptr(name).to_str() { - Ok(s) => Some(s.to_owned()), - Err(_) => { - release_registrations(registrations, nregistrations); - return std::ptr::null_mut(); - } - } - }; - let cmd = match argv_from_c(argv, argc) { - Some(v) => v, - None => { - release_registrations(registrations, nregistrations); - return std::ptr::null_mut(); - } - }; - let handlers = match collect_registrations(registrations, nregistrations) { - Some(v) => v, - None => { - // Validation failed (null handler in the array). The - // non-null handlers in the array have not been taken into - // FfiHandler ownership by `collect_registrations` (it is - // validate-first), but the public C-ABI contract guarantees - // "array consumed as a whole" — release them here so the C - // caller is never responsible for any registered pointer - // after this call returns. - release_registrations(registrations, nregistrations); - return std::ptr::null_mut(); - } - }; - let sandbox_ref: &Sandbox = (*policy).inner(); - match block_on_run(sandbox_ref, name_opt, cmd, handlers, interactive) { - Some(Ok(rr)) => { - let boxed = Box::new(crate::sandlock_result_t::from_run_result(rr)); - Box::into_raw(boxed) - } - _ => std::ptr::null_mut(), - } -} diff --git a/crates/sandlock-ffi/src/handler/abi.rs b/crates/sandlock-ffi/src/handler/abi.rs new file mode 100644 index 0000000..983ecc1 --- /dev/null +++ b/crates/sandlock-ffi/src/handler/abi.rs @@ -0,0 +1,457 @@ +//! Public ABI types, setters, and accessor entry points exposed by the +//! handler module. No Rust-side dispatch logic lives here — only the +//! data layout and the thin `extern "C-unwind"` wrappers around it. + +use std::os::unix::io::RawFd; +use std::slice; + +use sandlock_core::seccomp::notif::{read_child_cstr, read_child_mem, write_child_mem}; + +/// Opaque child-memory accessor handed to a C handler callback. +/// +/// Constructed on the stack inside the Rust adapter just before the +/// callback fires, invalidated when the callback returns. C handlers +/// must not store the pointer beyond the callback's return. +#[repr(C)] +#[allow(non_camel_case_types)] +pub struct sandlock_mem_handle_t { + notif_fd: RawFd, + notif_id: u64, + pid: u32, +} + +impl sandlock_mem_handle_t { + pub(super) fn new(notif_fd: RawFd, notif_id: u64, pid: u32) -> Self { + Self { notif_fd, notif_id, pid } + } +} + +/// Read up to `max_len-1` bytes of a NUL-terminated string at `addr` from the +/// traced child. On success the destination buffer is NUL-terminated and +/// `*out_len` holds the byte count copied (excluding the NUL); returns 0. +/// On failure returns -1 and leaves `*out_len` untouched. `max_len` must be +/// at least 1 to fit the NUL terminator. +/// +/// # Safety +/// `handle` must point to a live `sandlock_mem_handle_t` provided by the +/// supervisor; `buf` must be writable for `max_len` bytes; `out_len` must +/// be a valid `size_t*`. +#[no_mangle] +pub unsafe extern "C" fn sandlock_mem_read_cstr( + handle: *const sandlock_mem_handle_t, + addr: u64, + buf: *mut u8, + max_len: usize, + out_len: *mut usize, +) -> i32 { + if handle.is_null() || buf.is_null() || out_len.is_null() || max_len == 0 { + return -1; + } + let h = &*handle; + // `max_len` is the caller-supplied buffer size including space for the + // trailing NUL; reserve one byte so we can always terminate the string. + let cap = max_len - 1; + let s = match read_child_cstr(h.notif_fd, h.notif_id, h.pid, addr, cap) { + Some(s) => s, + None => return -1, + }; + let bytes = s.as_bytes(); + let n = bytes.len().min(cap); + std::ptr::copy_nonoverlapping(bytes.as_ptr(), buf, n); + *buf.add(n) = 0; + *out_len = n; + 0 +} + +/// Raw byte read at `addr` of exactly `len` bytes. Writes byte count +/// actually read to `*out_len`. Returns 0 on success, -1 on failure. +/// +/// # Safety +/// Same constraints as `sandlock_mem_read_cstr`. +#[no_mangle] +pub unsafe extern "C" fn sandlock_mem_read( + handle: *const sandlock_mem_handle_t, + addr: u64, + buf: *mut u8, + len: usize, + out_len: *mut usize, +) -> i32 { + if handle.is_null() || buf.is_null() || out_len.is_null() { + return -1; + } + let h = &*handle; + let v = match read_child_mem(h.notif_fd, h.notif_id, h.pid, addr, len) { + Ok(v) => v, + Err(_) => return -1, + }; + let n = v.len(); + std::ptr::copy_nonoverlapping(v.as_ptr(), buf, n); + *out_len = n; + 0 +} + +/// Write `len` bytes from `buf` into the child at `addr`. Returns 0 on +/// success, -1 on failure. +/// +/// # Safety +/// Same constraints as `sandlock_mem_read_cstr`; `buf` must be readable +/// for `len` bytes. +#[no_mangle] +pub unsafe extern "C" fn sandlock_mem_write( + handle: *const sandlock_mem_handle_t, + addr: u64, + buf: *const u8, + len: usize, +) -> i32 { + if handle.is_null() || buf.is_null() { + return -1; + } + let h = &*handle; + let data = slice::from_raw_parts(buf, len); + match write_child_mem(h.notif_fd, h.notif_id, h.pid, addr, data) { + Ok(()) => 0, + Err(_) => -1, + } +} + +/// Tag distinguishing payload variants of `sandlock_action_out_t`. +#[repr(u32)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[allow(non_camel_case_types)] +pub enum sandlock_action_kind_t { + /// No action set yet; the supervisor treats this as "fall through to + /// the handler's on_exception policy" (see `exception_action` in + /// `FfiHandler`). + Unset = 0, + Continue = 1, + Errno = 2, + ReturnValue = 3, + InjectFdSend = 4, + InjectFdSendTracked = 5, + Hold = 6, + Kill = 7, +} + +#[repr(C)] +#[derive(Clone, Copy)] +#[allow(non_camel_case_types)] +pub struct sandlock_action_kill_t { + pub sig: i32, + pub pgid: i32, +} + +#[repr(C)] +#[derive(Clone, Copy)] +#[allow(non_camel_case_types)] +pub struct sandlock_action_inject_t { + /// Owned by the C caller; ownership transfers to the supervisor on + /// successful invocation of the corresponding setter. + pub srcfd: i32, + pub newfd_flags: u32, +} + +/// Token reserved for a future tracker-aware inject variant. Currently +/// unimplemented — kept as a type alias so the ABI of the +/// `sandlock_action_inject_tracked_t` payload stays stable across the +/// future release that wires the tracker callback. +#[allow(non_camel_case_types)] +pub type sandlock_inject_tracker_t = u64; + +#[repr(C)] +#[derive(Clone, Copy)] +#[allow(non_camel_case_types)] +pub struct sandlock_action_inject_tracked_t { + pub srcfd: i32, + pub newfd_flags: u32, + pub tracker: sandlock_inject_tracker_t, +} + +#[repr(C)] +#[allow(non_camel_case_types)] +pub union sandlock_action_payload_t { + pub none: u64, + pub errno: i32, + pub return_value: i64, + pub inject_send: sandlock_action_inject_t, + pub inject_send_tracked: sandlock_action_inject_tracked_t, + pub kill: sandlock_action_kill_t, +} + +#[repr(C)] +#[allow(non_camel_case_types)] +pub struct sandlock_action_out_t { + pub kind: u32, + pub payload: sandlock_action_payload_t, +} + +impl sandlock_action_out_t { + /// Construct an `Unset` action with all payload bytes zero. The payload + /// union has variants up to 16 bytes; this ensures all bytes are + /// initialised before the C handler writes its decision. + pub fn zeroed() -> Self { + // Safety: `sandlock_action_payload_t` is `#[repr(C)]` with only + // integer-and-integer-aggregate variants; the zero bit-pattern is + // valid for all of them. + Self { + kind: sandlock_action_kind_t::Unset as u32, + payload: unsafe { std::mem::MaybeUninit::zeroed().assume_init() }, + } + } +} + +/// Mark the action as `Continue` (let the syscall proceed unchanged). +/// +/// # Safety +/// `out` must be a valid pointer to a `sandlock_action_out_t` writable +/// for the duration of the call, or null (in which case the call is a +/// no-op). +#[no_mangle] +pub unsafe extern "C" fn sandlock_action_set_continue(out: *mut sandlock_action_out_t) { + if out.is_null() { return; } + (*out).kind = sandlock_action_kind_t::Continue as u32; +} + +/// Fail the syscall with `errno`. +/// +/// # Safety +/// Same constraints as `sandlock_action_set_continue`. +#[no_mangle] +pub unsafe extern "C" fn sandlock_action_set_errno(out: *mut sandlock_action_out_t, errno: i32) { + if out.is_null() { return; } + (*out).kind = sandlock_action_kind_t::Errno as u32; + (*out).payload.errno = errno; +} + +/// Return a specific value from the syscall without entering the kernel. +/// +/// # Safety +/// Same constraints as `sandlock_action_set_continue`. +#[no_mangle] +pub unsafe extern "C" fn sandlock_action_set_return_value( + out: *mut sandlock_action_out_t, + value: i64, +) { + if out.is_null() { return; } + (*out).kind = sandlock_action_kind_t::ReturnValue as u32; + (*out).payload.return_value = value; +} + +/// Inject the supervisor-side fd `srcfd` into the traced child as a new +/// fd (number chosen by the kernel via `SECCOMP_IOCTL_NOTIF_ADDFD`). +/// +/// Note: ownership of `srcfd` transfers from the C caller to the +/// supervisor only when the resulting action is actually dispatched. +/// If the C caller subsequently calls a different setter on the same +/// `sandlock_action_out_t` (overwriting the kind tag before the +/// supervisor reads it), `srcfd` is NOT closed and leaks. Pick one +/// setter per action. +/// +/// # Safety +/// Same constraints as `sandlock_action_set_continue`; `srcfd` must be +/// a valid open fd in the supervisor process at the moment of the +/// supervisor's dispatch. +#[no_mangle] +pub unsafe extern "C" fn sandlock_action_set_inject_fd_send( + out: *mut sandlock_action_out_t, + srcfd: RawFd, + newfd_flags: u32, +) { + if out.is_null() { return; } + (*out).kind = sandlock_action_kind_t::InjectFdSend as u32; + (*out).payload.inject_send = sandlock_action_inject_t { srcfd, newfd_flags }; +} + +/// Hold the syscall pending until the supervisor explicitly releases it. +/// +/// # Safety +/// Same constraints as `sandlock_action_set_continue`. +#[no_mangle] +pub unsafe extern "C" fn sandlock_action_set_hold(out: *mut sandlock_action_out_t) { + if out.is_null() { return; } + (*out).kind = sandlock_action_kind_t::Hold as u32; +} + +/// Kill the target with signal `sig`. Pass `pgid > 0` to target an +/// explicit process group; `pgid == 0` is a sentinel — the supervisor +/// substitutes the child process group id resolved via `getpgid(pid)` +/// on the notification's pid. +/// +/// # Safety +/// Same constraints as `sandlock_action_set_continue`. +#[no_mangle] +pub unsafe extern "C" fn sandlock_action_set_kill( + out: *mut sandlock_action_out_t, + sig: i32, + pgid: i32, +) { + if out.is_null() { return; } + (*out).kind = sandlock_action_kind_t::Kill as u32; + (*out).payload.kill = sandlock_action_kill_t { sig, pgid }; +} + +/// Exception policy applied when the handler callback fails to set a +/// valid action (returns non-zero rc, leaves `kind == Unset`, or panics +/// across the FFI boundary). +#[repr(u32)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[allow(non_camel_case_types)] +pub enum sandlock_exception_policy_t { + /// Treat the failure as `NotifAction::Kill { sig: SIGKILL, pgid: child_pgid }`. + /// Default; "fail-closed" — the safe option. + Kill = 0, + /// Treat the failure as `NotifAction::Errno(EPERM)`. Useful for + /// audit-style handlers where the syscall is what failed rather than + /// the supervisor. + DenyEperm = 1, + /// Treat the failure as `NotifAction::Continue`. Explicit fail-open; + /// only safe when the syscall is *also* allowed by the BPF filter and + /// Landlock layer (e.g. observability handlers). + Continue = 2, +} + +/// C-callable handler entry point. +/// +/// Returns 0 on success (and must have called exactly one setter on +/// `out`). Returns non-zero to signal a handler-internal error; the +/// supervisor then applies the configured exception policy. +/// +/// The ABI is `extern "C-unwind"` rather than plain `extern "C"`. Pure-C +/// callers see no difference (C has no unwinding); Rust handlers plugged +/// into this C ABI surface may panic and the supervisor's `catch_unwind` +/// in [`super::adapter::FfiHandler::handle`] will route the panic to the +/// configured exception policy instead of aborting the process. +#[allow(non_camel_case_types)] +pub type sandlock_handler_fn_t = extern "C-unwind" fn( + ud: *mut std::ffi::c_void, + notif: *const crate::notif_repr::sandlock_notif_data_t, + mem: *mut sandlock_mem_handle_t, + out: *mut sandlock_action_out_t, +) -> i32; + +/// Optional destructor invoked when the container is freed. +/// +/// Uses `extern "C-unwind"` for consistency with [`sandlock_handler_fn_t`] +/// and so that a Rust-side destructor panicking through this pointer +/// unwinds rather than aborts (panic-safety in destructors is good +/// practice even though no in-tree caller currently relies on it). +#[allow(non_camel_case_types)] +pub type sandlock_handler_ud_drop_t = extern "C-unwind" fn(ud: *mut std::ffi::c_void); + +/// Opaque handler container (B4 — opaque box). +#[repr(C)] +#[allow(non_camel_case_types)] +pub struct sandlock_handler_t { + pub(super) handler_fn: Option, + pub(super) ud: *mut std::ffi::c_void, + pub(super) ud_drop: Option, + pub(super) on_exception: sandlock_exception_policy_t, +} + +// Safety: +// +// `Send`: required so the supervisor can move the handler container into +// a `tokio::task::spawn_blocking` closure. The struct contains only +// pointers (function pointer + `void*` user-data) and a `#[repr(u32)]` +// enum, all of which are `Send`-safe to move across threads. +// +// `Sync`: required because the dispatch table stores handlers as +// `Arc`, and `Arc` requires `T: Send + Sync`. The +// supervisor MAY dispatch handler invocations concurrently across +// different notifications (today's loop is largely serial, but the +// contract makes no guarantee — a future dispatcher could parallelise +// without breaking the public ABI). Consequently the C caller MUST +// ensure their `ud` is either immutable, or guarded by thread-safe +// state of their own (atomics, mutex, etc.). Rust offers no +// synchronization for an opaque `void*` — the responsibility is on +// the C side. +unsafe impl Send for sandlock_handler_t {} +unsafe impl Sync for sandlock_handler_t {} + +impl Drop for sandlock_handler_t { + fn drop(&mut self) { + if let Some(drop_fn) = self.ud_drop.take() { + if !self.ud.is_null() { + (drop_fn)(self.ud); + self.ud = std::ptr::null_mut(); + } + } + } +} + +/// Allocate a handler container. `handler_fn` must be non-null; passing +/// `ud_drop = None` is legal when `ud` does not require cleanup. +/// +/// # Safety +/// `ud` is opaque to Rust — the caller guarantees that the pointer +/// remains valid until either (a) `sandlock_handler_free` is called or +/// (b) the supervisor takes ownership via `sandlock_run_with_handlers` +/// and the run completes. +/// If `on_exception` does not match a defined `sandlock_exception_policy_t` +/// discriminant (0, 1, or 2), the call returns null and no allocation occurs. +#[no_mangle] +pub unsafe extern "C" fn sandlock_handler_new( + handler_fn: Option, + ud: *mut std::ffi::c_void, + ud_drop: Option, + on_exception: u32, +) -> *mut sandlock_handler_t { + if handler_fn.is_none() { + return std::ptr::null_mut(); + } + let on_exception = match on_exception { + 0 => sandlock_exception_policy_t::Kill, + 1 => sandlock_exception_policy_t::DenyEperm, + 2 => sandlock_exception_policy_t::Continue, + // Reject out-of-range discriminants at the FFI boundary so we never + // store an invalid enum value into the struct — reading one later + // via `match` would be undefined behaviour. + _ => return std::ptr::null_mut(), + }; + let h = Box::new(sandlock_handler_t { + handler_fn, + ud, + ud_drop, + on_exception, + }); + Box::into_raw(h) +} + +/// Free a handler container that has *not* been registered with a +/// sandbox. After successful registration the supervisor owns the +/// handler; calling this on a registered handler is undefined behaviour +/// (the supervisor's later free would double-free). +/// +/// The ABI is `extern "C-unwind"` rather than plain `extern "C"` so a +/// panic propagated from a Rust-side `ud_drop` (declared as +/// [`sandlock_handler_ud_drop_t`], itself `extern "C-unwind"`) unwinds +/// the caller rather than aborting the process. Pure-C callers see no +/// difference (C has no unwinding). +/// +/// # Safety +/// `h` must be either null or a pointer previously returned by +/// `sandlock_handler_new` that has not yet been registered with the +/// supervisor and has not already been freed. +#[no_mangle] +pub unsafe extern "C-unwind" fn sandlock_handler_free(h: *mut sandlock_handler_t) { + if h.is_null() { return; } + drop(Box::from_raw(h)); +} + +/// C-side pair of `(syscall_nr, handler*)` consumed by +/// `sandlock_run_with_handlers`. Ownership of `handler` transfers into +/// the run on success; the supervisor frees the container. +#[repr(C)] +#[derive(Clone, Copy)] +#[allow(non_camel_case_types)] +pub struct sandlock_handler_registration_t { + pub syscall_nr: i64, + pub handler: *mut sandlock_handler_t, +} + +// Safety: the raw pointer field is opaque to Rust. The supervisor moves +// the registration array into a worker thread once it has been turned +// into `(i64, FfiHandler)` pairs; the registration struct itself never +// crosses thread boundaries while holding the raw pointer. We mark +// `Send` to allow the input array to be borrowed inside `unsafe` +// contexts without per-call wrapper structs. +unsafe impl Send for sandlock_handler_registration_t {} diff --git a/crates/sandlock-ffi/src/handler/adapter.rs b/crates/sandlock-ffi/src/handler/adapter.rs new file mode 100644 index 0000000..8d90903 --- /dev/null +++ b/crates/sandlock-ffi/src/handler/adapter.rs @@ -0,0 +1,270 @@ +//! Rust-side adapter wiring the C ABI to the `Handler` trait. +//! +//! `FfiHandler` owns the `sandlock_handler_t` container produced by +//! `sandlock_handler_new` and implements `Handler` so the supervisor's +//! dispatch loop can invoke C callbacks transparently. + +use std::future::Future; +use std::os::unix::io::FromRawFd; +use std::pin::Pin; + +use sandlock_core::seccomp::dispatch::{Handler, HandlerCtx}; +use sandlock_core::seccomp::notif::NotifAction; + +use super::abi::{ + sandlock_action_kind_t, sandlock_action_out_t, sandlock_exception_policy_t, + sandlock_handler_t, sandlock_mem_handle_t, +}; + +/// Rust adapter wrapping an owned `sandlock_handler_t` and implementing +/// `Handler`. Constructed when the supervisor accepts handlers passed +/// through `sandlock_run_with_handlers`. +pub struct FfiHandler { + inner: Box, +} + +impl FfiHandler { + /// Take ownership of a raw `sandlock_handler_t*` produced by + /// `sandlock_handler_new`. + /// + /// # Safety + /// `raw` must be a non-null pointer returned by `sandlock_handler_new` + /// and never freed via `sandlock_handler_free`. After this call the + /// supervisor owns the container. + pub unsafe fn from_raw(raw: *mut sandlock_handler_t) -> Self { + assert!(!raw.is_null(), "FfiHandler::from_raw on null pointer"); + Self { inner: Box::from_raw(raw) } + } + + fn exception_action(&self, child_pgid: i32) -> NotifAction { + match self.inner.on_exception { + sandlock_exception_policy_t::Kill => { + NotifAction::Kill { sig: libc::SIGKILL, pgid: child_pgid } + } + sandlock_exception_policy_t::DenyEperm => NotifAction::Errno(libc::EPERM), + sandlock_exception_policy_t::Continue => NotifAction::Continue, + } + } +} + +/// `Send`-only wrapper around the C user-data pointer so it can travel +/// into `spawn_blocking`. Only the move (not sharing across threads) is +/// required; the deeper Send/Sync rationale for the underlying handler +/// container lives on `sandlock_handler_t`. +struct UdPtr(*mut std::ffi::c_void); +// Safety: ud is opaque to Rust; the spawn_blocking pipeline only moves +// (not shares) the wrapper. See `sandlock_handler_t` for the deeper +// Send/Sync rationale that justifies the underlying handler container. +unsafe impl Send for UdPtr {} + +impl Handler for FfiHandler { + fn handle<'a>( + &'a self, + cx: &'a HandlerCtx, + ) -> Pin + Send + 'a>> { + // Capture the pieces we need by value so spawn_blocking can run + // the C callback on a worker thread without &self lifetime games. + let notif_snap = crate::notif_repr::sandlock_notif_data_t::from(&cx.notif); + let notif_fd = cx.notif_fd; + let notif_id = cx.notif.id; + let pid = cx.notif.pid; + // Resolve the trapped child's process group id for use as a fallback + // pgid in Kill actions where the caller passed pgid == 0. Three guard + // rails: + // + // 1. `notif.pid == 0` can occur in nested PID namespaces (e.g., + // Kubernetes pod-in-pod, KubeVirt, DinD). `getpgid(0)` returns + // the supervisor's own pgid — substituting that into a Kill + // action would be a supervisor-suicide vector. + // + // 2. `getpgid(pid) <= 0` indicates ESRCH (child exited between + // notif and our query) or another kernel-side failure. + // + // 3. Even on success, the resolved pgid must differ from the + // supervisor's own pgid. If sandlock-core does not call + // `setpgid(0, 0)` after fork, the child inherits the parent's + // pgid — sending `killpg(supervisor_pgid)` would kill the + // supervisor along with the child. + // + // In all three failure cases, fall back to the bare pid. A `killpg(pid)` + // when `pid` does not name a valid process group will fail with ESRCH + // inside the supervisor's response path — safer than killing the + // supervisor. + let child_pgid = { + let pid = cx.notif.pid as i32; + // SAFETY: `getpgid(0)` is signal-safe and has no preconditions. + let supervisor_pgid = unsafe { libc::getpgid(0) }; + if pid <= 0 { + pid + } else { + // SAFETY: `getpgid` is signal-safe; positive pid is the only + // documented precondition. + let pgid = unsafe { libc::getpgid(pid) }; + if pgid <= 0 || pgid == supervisor_pgid { + pid + } else { + pgid + } + } + }; + let handler_fn = self.inner.handler_fn; + let ud = UdPtr(self.inner.ud); + let on_exception_fallback = self.exception_action(child_pgid); + + Box::pin(async move { + let join = tokio::task::spawn_blocking(move || { + // Rust 2021 disjoint closure captures (RFC 2229) would + // otherwise capture `ud.0` (a bare `*mut c_void`, not + // `Send`) rather than the whole `UdPtr`. Binding `ud` to + // a fresh local at the top of the closure forces a + // whole-struct capture so the `Send` impl on `UdPtr` + // applies to the outer closure. + let ud = ud; + let UdPtr(ud_raw) = ud; + let mut mem = sandlock_mem_handle_t::new(notif_fd, notif_id, pid); + let mut out = sandlock_action_out_t::zeroed(); + let rc = match handler_fn { + Some(f) => std::panic::catch_unwind(std::panic::AssertUnwindSafe( + || f(ud_raw, ¬if_snap, &mut mem, &mut out), + )), + None => Ok(-1), + }; + (rc, out) + }).await; + + let (rc_or_panic, out) = match join { + Ok(pair) => pair, + Err(_join_err) => return on_exception_fallback, + }; + + match rc_or_panic { + Ok(0) => match translate_action(&out, child_pgid) { + Some(action) => action, + None => { + // Action kind ended up Unset, unknown, or the + // reserved InjectFdSendTracked discriminant. + // Drain any inject-fd payload before falling + // back to the exception policy — otherwise the + // supervisor leaks the srcfd that was armed by + // the (failed) callback. + // SAFETY: `drain_pending_inject_fd` inspects + // `out.kind` itself before touching the union, + // and `out.kind` matches the union variant per + // the action setters' contract. + unsafe { drain_pending_inject_fd(&out) }; + on_exception_fallback + } + }, + _ => { + // Either the callback returned a non-zero rc OR + // `catch_unwind` caught a panic. The callback may + // have armed an InjectFdSend{,Tracked} payload + // before failing; drain it so its srcfd doesn't + // leak in the supervisor. + // SAFETY: see the `Ok(0) -> None` branch above. + unsafe { drain_pending_inject_fd(&out) }; + on_exception_fallback + } + } + }) + } +} + +/// Drains a still-pending `InjectFdSend` or `InjectFdSendTracked` +/// payload by consuming the contained `srcfd` into an `OwnedFd` and +/// dropping it (which closes the fd). Called from error paths in +/// [`FfiHandler::handle`] that fall back to the exception policy +/// without dispatching the action — without this, the supervisor +/// silently leaks fds armed by a C handler that subsequently panicked +/// or returned a non-zero rc. +/// +/// No-op for any other action kind (including `Unset`). +/// +/// # Safety +/// `out` must point at a fully-initialised `sandlock_action_out_t`. +/// The function inspects only `out.kind` and the union arm matching +/// that kind, which is sound because the action setters establish the +/// invariant "the `kind` tag selects the union arm". +unsafe fn drain_pending_inject_fd(out: &sandlock_action_out_t) { + use sandlock_action_kind_t as K; + if out.kind == K::InjectFdSend as u32 { + // SAFETY: `kind == InjectFdSend` selects the `inject_send` + // arm per the setter contract. Wrapping the raw fd in an + // `OwnedFd` and dropping it closes the fd. + drop(std::os::unix::io::OwnedFd::from_raw_fd( + out.payload.inject_send.srcfd, + )); + } else if out.kind == K::InjectFdSendTracked as u32 { + // The C header exposes the discriminant value publicly even + // though we don't ship a setter for it. A C caller can still + // assign `out->kind = 5; out->payload.inject_send_tracked.srcfd = X;` + // by hand. Treat it like `InjectFdSend` for cleanup purposes: + // the srcfd was armed and must be released. + // SAFETY: see `InjectFdSend` arm above. + drop(std::os::unix::io::OwnedFd::from_raw_fd( + out.payload.inject_send_tracked.srcfd, + )); + } +} + +/// Convert the C-side decision into a `NotifAction`. Returns `None` if +/// the kind is `Unset`, unknown, or `InjectFdSendTracked` (no setter +/// exposed; treated as fallback). The caller then falls back to the +/// exception policy, and is responsible for invoking +/// [`drain_pending_inject_fd`] to release any armed inject-fd payload. +/// +/// Note: this function takes `&sandlock_action_out_t` rather than +/// consuming the struct so that the caller can still inspect `out.kind` +/// on the `None` branch and drain any pending fd payload. The +/// `InjectFdSend` arm uses `OwnedFd::from_raw_fd` on the union field, +/// which is what materialises the ownership transfer from the C caller +/// to the supervisor when this branch is taken. +fn translate_action(out: &sandlock_action_out_t, child_pgid: i32) -> Option { + use sandlock_action_kind_t as K; + let kind = match out.kind { + x if x == K::Continue as u32 => K::Continue, + x if x == K::Errno as u32 => K::Errno, + x if x == K::ReturnValue as u32 => K::ReturnValue, + x if x == K::InjectFdSend as u32 => K::InjectFdSend, + // Discriminant reserved for a future tracker-injection ABI; no + // setter is exposed in this release. A C caller can still set + // it by hand (the value is public in the C header). Return + // `None` so the caller drains the srcfd and falls back to the + // exception policy. + x if x == K::InjectFdSendTracked as u32 => return None, + x if x == K::Hold as u32 => K::Hold, + x if x == K::Kill as u32 => K::Kill, + _ => return None, // Unset or unknown + }; + + // Safety: the `out.payload` union variant matched here was just + // selected by the `kind` discriminant above. The C action setters + // documented in this module pair each `kind` value with exactly one + // payload variant, so reading that variant is the only legal access. + // For `InjectFdSend` the documented contract on + // `sandlock_action_set_inject_fd_send` transfers ownership of + // `srcfd` to the supervisor; wrapping it in an `OwnedFd` here is + // what materialises that transfer. + let action = unsafe { + match kind { + K::Continue => NotifAction::Continue, + K::Errno => NotifAction::Errno(out.payload.errno), + K::ReturnValue => NotifAction::ReturnValue(out.payload.return_value), + K::Hold => NotifAction::Hold, + K::Kill => { + let pgid = if out.payload.kill.pgid == 0 { + child_pgid + } else { + out.payload.kill.pgid + }; + NotifAction::Kill { sig: out.payload.kill.sig, pgid } + } + K::InjectFdSend => NotifAction::InjectFdSend { + srcfd: std::os::unix::io::OwnedFd::from_raw_fd(out.payload.inject_send.srcfd), + newfd_flags: out.payload.inject_send.newfd_flags, + }, + K::InjectFdSendTracked | K::Unset => unreachable!(), + } + }; + Some(action) +} diff --git a/crates/sandlock-ffi/src/handler/mod.rs b/crates/sandlock-ffi/src/handler/mod.rs new file mode 100644 index 0000000..f269aba --- /dev/null +++ b/crates/sandlock-ffi/src/handler/mod.rs @@ -0,0 +1,16 @@ +//! FFI surface for the sandlock `Handler` trait. See `docs/extension-handlers.md`. +//! +//! Split across three submodules for clarity: +//! * [`abi`] — public ABI types, setters, and accessor entry points. +//! * [`adapter`] — `FfiHandler` adapter implementing `Handler`. +//! * [`run`] — `sandlock_run_with_handlers` entry points and helpers. + +pub mod abi; +pub mod adapter; +pub mod run; + +// Re-export every symbol that was at `sandlock_ffi::handler::FOO` before +// the split so external tests and downstream consumers do not break. +pub use abi::*; +pub use adapter::FfiHandler; +pub use run::{sandlock_run_interactive_with_handlers, sandlock_run_with_handlers}; diff --git a/crates/sandlock-ffi/src/handler/run.rs b/crates/sandlock-ffi/src/handler/run.rs new file mode 100644 index 0000000..b177540 --- /dev/null +++ b/crates/sandlock-ffi/src/handler/run.rs @@ -0,0 +1,236 @@ +//! `sandlock_run_with_handlers` entry points and their plumbing helpers. +//! +//! This module owns the FFI surface that takes an array of +//! `sandlock_handler_registration_t`, converts them into `FfiHandler` +//! instances, and drives the supervisor runtime. + +use std::ffi::CStr; +use std::slice; + +use sandlock_core::{RunResult, Sandbox, SandlockError}; + +use super::abi::sandlock_handler_registration_t; +use super::adapter::FfiHandler; + +fn argv_from_c( + argv: *const *const std::os::raw::c_char, + argc: u32, +) -> Option> { + if argv.is_null() { + return None; + } + // Reject argc == 0 here: an empty argv would have us hand the + // sandbox an empty command vector, which the supervisor cannot + // execute. Failing fast keeps the error surfacing at the FFI + // boundary where the C caller can react. + if argc == 0 { + return None; + } + let mut out = Vec::with_capacity(argc as usize); + for i in 0..(argc as isize) { + let p = unsafe { *argv.offset(i) }; + if p.is_null() { + return None; + } + let s = unsafe { CStr::from_ptr(p) }.to_str().ok()?.to_owned(); + out.push(s); + } + Some(out) +} + +fn collect_registrations( + regs: *const sandlock_handler_registration_t, + nregs: usize, +) -> Option> { + if regs.is_null() && nregs > 0 { + return None; + } + if nregs == 0 { + return Some(Vec::new()); + } + let slice = unsafe { slice::from_raw_parts(regs, nregs) }; + // First pass: validate all entries before taking ownership of any. + // Without this, a null pointer at index k+1 would leave us having + // already consumed handlers [0..k] via `Box::from_raw`; dropping the + // partial `out` would free them while the C caller still believes it + // owns the originals — a latent double-free via + // `sandlock_handler_free`. + for r in slice { + if r.handler.is_null() { + return None; + } + } + // Second pass: ownership transfer. Every pointer is non-null per the + // pass above. + let mut out = Vec::with_capacity(nregs); + for r in slice { + // SAFETY: validated non-null above; caller provided pointer from + // `sandlock_handler_new` and must not reuse after this call (the + // public C ABI doc states ownership transfers in). + let h = unsafe { FfiHandler::from_raw(r.handler) }; + out.push((r.syscall_nr, h)); + } + Some(out) +} + +fn block_on_run( + sandbox: &Sandbox, + name: Option, + cmd: Vec, + handlers: Vec<(i64, FfiHandler)>, + interactive: bool, +) -> Option> { + // Use a fresh runtime — sandlock-core already pulls in tokio with + // rt-multi-thread; this matches the pattern used by the existing + // `sandlock_run` path. A panic in an `extern "C"`-reachable path is + // UB, so we report runtime-build failure to the caller via `None` + // instead of unwrapping. + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .ok()?; + let cmd_refs: Vec<&str> = cmd.iter().map(String::as_str).collect(); + // Apply `name` via the builder method on a clone — mirrors the + // pattern used by `sandlock_run` in lib.rs. A `None` here means + // "auto-generate `sandbox-{pid}`", matching the C ABI contract. + let mut sb = match name { + Some(n) => sandbox.clone().with_name(n), + None => sandbox.clone(), + }; + Some(rt.block_on(async move { + if interactive { + sb.run_interactive_with_extra_handlers(&cmd_refs, handlers).await + } else { + sb.run_with_extra_handlers(&cmd_refs, handlers).await + } + })) +} + +/// Run the policy with extra C handlers. Returns NULL on failure. +/// +/// `name` may be NULL to auto-generate `sandbox-{pid}`, or a valid +/// NUL-terminated UTF-8 C string; the placement mirrors the existing +/// `sandlock_run` entry point in `lib.rs`. +/// +/// # Safety +/// All pointer arguments must be valid for their documented lifetimes: +/// `policy` must come from `sandlock_sandbox_build`, `argv` must be a +/// readable array of `argc` NUL-terminated strings, and each handler +/// pointer must come from `sandlock_handler_new` and must not be reused +/// after this call (ownership transfers in). +#[no_mangle] +pub unsafe extern "C" fn sandlock_run_with_handlers( + policy: *const crate::sandlock_sandbox_t, + name: *const std::os::raw::c_char, + argv: *const *const std::os::raw::c_char, + argc: u32, + registrations: *const sandlock_handler_registration_t, + nregistrations: usize, +) -> *mut crate::sandlock_result_t { + run_with_handlers_inner(policy, name, argv, argc, registrations, nregistrations, false) +} + +/// Interactive-stdio variant of `sandlock_run_with_handlers`. +/// +/// `name` follows the same convention as `sandlock_run_with_handlers`. +/// +/// # Safety +/// Same constraints as `sandlock_run_with_handlers`. +#[no_mangle] +pub unsafe extern "C" fn sandlock_run_interactive_with_handlers( + policy: *const crate::sandlock_sandbox_t, + name: *const std::os::raw::c_char, + argv: *const *const std::os::raw::c_char, + argc: u32, + registrations: *const sandlock_handler_registration_t, + nregistrations: usize, +) -> *mut crate::sandlock_result_t { + run_with_handlers_inner(policy, name, argv, argc, registrations, nregistrations, true) +} + +/// Drops every non-null handler pointer in the registration array. +/// Used by [`run_with_handlers_inner`] on early-return paths where +/// `collect_registrations` was not reached — guarantees the C ABI +/// contract "all handler pointers are consumed by this call". +/// +/// # Safety +/// `regs` is either null (no-op) or points to `nregs` valid +/// `sandlock_handler_registration_t` slots whose `handler` pointer is +/// either null or comes from `sandlock_handler_new` and has not been +/// freed by anyone else. +unsafe fn release_registrations( + regs: *const sandlock_handler_registration_t, + nregs: usize, +) { + if regs.is_null() || nregs == 0 { + return; + } + let slice = slice::from_raw_parts(regs, nregs); + for r in slice { + if !r.handler.is_null() { + // Reclaim and drop the container so its `ud_drop` runs. + drop(Box::from_raw(r.handler)); + } + } +} + +unsafe fn run_with_handlers_inner( + policy: *const crate::sandlock_sandbox_t, + name: *const std::os::raw::c_char, + argv: *const *const std::os::raw::c_char, + argc: u32, + registrations: *const sandlock_handler_registration_t, + nregistrations: usize, + interactive: bool, +) -> *mut crate::sandlock_result_t { + if policy.is_null() { + // Honour the documented contract: ownership of every handler + // pointer transfers in on entry, regardless of return value. + release_registrations(registrations, nregistrations); + return std::ptr::null_mut(); + } + // Decode the optional name eagerly so a malformed (non-UTF-8) C + // string fails the call fast, before we take ownership of any + // handler containers via `collect_registrations`. Matches the + // contract used by `sandlock_run`. + let name_opt: Option = if name.is_null() { + None + } else { + match CStr::from_ptr(name).to_str() { + Ok(s) => Some(s.to_owned()), + Err(_) => { + release_registrations(registrations, nregistrations); + return std::ptr::null_mut(); + } + } + }; + let cmd = match argv_from_c(argv, argc) { + Some(v) => v, + None => { + release_registrations(registrations, nregistrations); + return std::ptr::null_mut(); + } + }; + let handlers = match collect_registrations(registrations, nregistrations) { + Some(v) => v, + None => { + // Validation failed (null handler in the array). The + // non-null handlers in the array have not been taken into + // FfiHandler ownership by `collect_registrations` (it is + // validate-first), but the public C-ABI contract guarantees + // "array consumed as a whole" — release them here so the C + // caller is never responsible for any registered pointer + // after this call returns. + release_registrations(registrations, nregistrations); + return std::ptr::null_mut(); + } + }; + let sandbox_ref: &Sandbox = (*policy).inner(); + match block_on_run(sandbox_ref, name_opt, cmd, handlers, interactive) { + Some(Ok(rr)) => { + let boxed = Box::new(crate::sandlock_result_t::from_run_result(rr)); + Box::into_raw(boxed) + } + _ => std::ptr::null_mut(), + } +} From 598cebb557263e6e7be35afae6bb6411c3666247 Mon Sep 17 00:00:00 2001 From: dzerik Date: Thu, 14 May 2026 11:37:06 +0300 Subject: [PATCH 21/24] fix: refuse Kill action when no safe pgid is available MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The K1 fix (5deb8de) substituted `pid` for `child_pgid` when `notif.pid <= 0`. The substituted `pid` then flowed into the Kill action as `pgid = 0`, which `killpg(0, sig)` interprets per POSIX as "signal the caller's process group" — i.e., the supervisor. Same suicide vector K1 was supposed to close, just one syscall later. Introduce an UNSAFE_PGID sentinel (i32::MIN). When pid resolution cannot produce a safe value (pid<=0, getpgid failure, or resolved pgid matches the supervisor's), set child_pgid = UNSAFE_PGID. Both translate_action's Kill arm and exception_action's Kill arm check the sentinel and refuse to produce a Kill action — translate_action returns None (routing to exception policy), exception_action's Kill arm degrades to Errno(EPERM). The k1 regression test was a "for show" test that asserted `Kill { pgid: 0 }` as the expected outcome, locking in the suicide-vector behavior. Update it to assert exception-policy fallback. Add a second test that registers a SIGURG handler in the test process and verifies it is not delivered after a lethal-pgid kill action runs through dispatch. k2 and k3 tests also updated: their previous "fall back to bare pid" assertions baked in a behaviour the new resolution rejects via UNSAFE_PGID. They now assert the exception-policy fallback. Four other tests (a1/a2 fd-drain hooks, kill-policy-on-rc-nonzero, recovers-from-panic) used `fake_ctx()` whose pid is the test process's own — getpgid then matches supervisor_pgid and the Kill exception policy correctly degrades to EPERM. The two non-pipe tests switch to an isolated-child helper so the Kill assertion stays observable; the two pipe-bearing tests assert EPERM (their load-bearing check is the drain, not the action kind). --- crates/sandlock-ffi/src/handler/adapter.rs | 97 +++++++-- crates/sandlock-ffi/tests/handler_smoke.rs | 230 ++++++++++++++++++--- 2 files changed, 273 insertions(+), 54 deletions(-) diff --git a/crates/sandlock-ffi/src/handler/adapter.rs b/crates/sandlock-ffi/src/handler/adapter.rs index 8d90903..a98cac7 100644 --- a/crates/sandlock-ffi/src/handler/adapter.rs +++ b/crates/sandlock-ffi/src/handler/adapter.rs @@ -16,6 +16,23 @@ use super::abi::{ sandlock_handler_t, sandlock_mem_handle_t, }; +/// Sentinel for "we cannot safely resolve a process-group id for the +/// trapped child." `i32::MIN` is not a valid pgid: `killpg(i32::MIN, _)` +/// returns `ESRCH` rather than harming the supervisor or any real +/// process group. Used in two places: +/// +/// * adapter.rs `child_pgid` resolution falls back to this when the +/// bare pid would otherwise be `0` (POSIX `killpg(0)` would target +/// the supervisor's own group) or whenever `getpgid(pid)` failed or +/// produced the supervisor's pgid. +/// * `translate_action`'s `Kill` arm refuses to produce an action +/// with this pgid, routing the dispatcher onto the configured +/// exception policy instead. +/// * `exception_action`'s `Kill` arm degrades to `Errno(EPERM)` if +/// it sees the sentinel, so the policy default never lets the +/// suicide vector through either. +pub(crate) const UNSAFE_PGID: i32 = i32::MIN; + /// Rust adapter wrapping an owned `sandlock_handler_t` and implementing /// `Handler`. Constructed when the supervisor accepts handlers passed /// through `sandlock_run_with_handlers`. @@ -39,7 +56,18 @@ impl FfiHandler { fn exception_action(&self, child_pgid: i32) -> NotifAction { match self.inner.on_exception { sandlock_exception_policy_t::Kill => { - NotifAction::Kill { sig: libc::SIGKILL, pgid: child_pgid } + if child_pgid == UNSAFE_PGID { + // No safe pgid is available (nested PID namespace, + // ESRCH, or supervisor-pgid collision). Degrading + // to EPERM keeps the suicide vector closed: the + // syscall is rejected with EPERM and the child can + // retry. Killing the supervisor would be strictly + // worse than letting the sandboxed process see one + // failed syscall. + NotifAction::Errno(libc::EPERM) + } else { + NotifAction::Kill { sig: libc::SIGKILL, pgid: child_pgid } + } } sandlock_exception_policy_t::DenyEperm => NotifAction::Errno(libc::EPERM), sandlock_exception_policy_t::Continue => NotifAction::Continue, @@ -70,15 +98,19 @@ impl Handler for FfiHandler { let pid = cx.notif.pid; // Resolve the trapped child's process group id for use as a fallback // pgid in Kill actions where the caller passed pgid == 0. Three guard - // rails: + // rails, all routed through the `UNSAFE_PGID` sentinel when they + // trip: // - // 1. `notif.pid == 0` can occur in nested PID namespaces (e.g., - // Kubernetes pod-in-pod, KubeVirt, DinD). `getpgid(0)` returns - // the supervisor's own pgid — substituting that into a Kill - // action would be a supervisor-suicide vector. + // 1. `notif.pid <= 0` can occur in nested PID namespaces (e.g., + // Kubernetes pod-in-pod, KubeVirt, DinD). The kernel reports + // the trapped task as invisible. The bare pid is unusable + // (POSIX `killpg(0)` targets the caller's group — supervisor + // suicide) and there is no other safe substitute. Signal + // failure via `UNSAFE_PGID`. // // 2. `getpgid(pid) <= 0` indicates ESRCH (child exited between - // notif and our query) or another kernel-side failure. + // notif and our query) or another kernel-side failure — no + // pgid we can safely use. // // 3. Even on success, the resolved pgid must differ from the // supervisor's own pgid. If sandlock-core does not call @@ -86,22 +118,26 @@ impl Handler for FfiHandler { // pgid — sending `killpg(supervisor_pgid)` would kill the // supervisor along with the child. // - // In all three failure cases, fall back to the bare pid. A `killpg(pid)` - // when `pid` does not name a valid process group will fail with ESRCH - // inside the supervisor's response path — safer than killing the - // supervisor. + // Earlier versions fell back to the bare `pid`. That looked safe + // for guards 2 and 3 (kernel rejects `killpg(pid)` with ESRCH if + // it does not name a group), but for guard 1 the bare pid is `0` + // or negative; `killpg(0, sig)` is supervisor suicide per POSIX, + // and `killpg(-1, sig)` broadcasts to every process the caller + // can signal. Routing all three branches through `UNSAFE_PGID` + // is the only way to keep guard 1 from re-introducing the very + // suicide vector this resolution exists to close. let child_pgid = { let pid = cx.notif.pid as i32; - // SAFETY: `getpgid(0)` is signal-safe and has no preconditions. - let supervisor_pgid = unsafe { libc::getpgid(0) }; if pid <= 0 { - pid + UNSAFE_PGID } else { // SAFETY: `getpgid` is signal-safe; positive pid is the only - // documented precondition. + // documented precondition. `getpgid(0)` reports the caller's + // (supervisor's) pgid; both calls have no other preconditions. + let supervisor_pgid = unsafe { libc::getpgid(0) }; let pgid = unsafe { libc::getpgid(pid) }; if pgid <= 0 || pgid == supervisor_pgid { - pid + UNSAFE_PGID } else { pgid } @@ -252,12 +288,31 @@ fn translate_action(out: &sandlock_action_out_t, child_pgid: i32) -> Option NotifAction::ReturnValue(out.payload.return_value), K::Hold => NotifAction::Hold, K::Kill => { - let pgid = if out.payload.kill.pgid == 0 { - child_pgid + let user_pgid = out.payload.kill.pgid; + if user_pgid == 0 { + // Caller asked us to substitute the child's pgid. + // Refuse if we have no safe value: routing through + // `None` falls back to the configured exception + // policy, whose `Kill` arm also checks for the + // sentinel and degrades to EPERM. + if child_pgid == UNSAFE_PGID { + return None; + } + NotifAction::Kill { sig: out.payload.kill.sig, pgid: child_pgid } } else { - out.payload.kill.pgid - }; - NotifAction::Kill { sig: out.payload.kill.sig, pgid } + // Caller passed an explicit pgid. Defence in depth: + // refuse if it matches the supervisor's own group + // (malicious or confused caller). `getpgid(0)` is + // safe and signal-safe; we re-query here because + // the earlier resolution path only computes + // supervisor_pgid in the `pid > 0` branch. Already + // inside the outer `unsafe` block. + let supervisor_pgid = libc::getpgid(0); + if user_pgid == supervisor_pgid { + return None; + } + NotifAction::Kill { sig: out.payload.kill.sig, pgid: user_pgid } + } } K::InjectFdSend => NotifAction::InjectFdSend { srcfd: std::os::unix::io::OwnedFd::from_raw_fd(out.payload.inject_send.srcfd), diff --git a/crates/sandlock-ffi/tests/handler_smoke.rs b/crates/sandlock-ffi/tests/handler_smoke.rs index c46d48c..f30bb94 100644 --- a/crates/sandlock-ffi/tests/handler_smoke.rs +++ b/crates/sandlock-ffi/tests/handler_smoke.rs @@ -256,6 +256,66 @@ fn fake_ctx() -> HandlerCtx { } } +/// Spawn a `sleep 30` child that immediately calls `setpgid(0, 0)` so +/// it becomes its own pgid leader (distinct from the supervisor's +/// pgid). Returns a `HandlerCtx` carrying the child's pid plus the +/// `Child` handle so the caller can reap it. +/// +/// Use this in tests that need `FfiHandler::handle` to produce +/// `child_pgid != UNSAFE_PGID` — i.e., where the exception policy's +/// `Kill` arm must remain observable. `fake_ctx()` cannot satisfy +/// that requirement because the test process IS the supervisor, so +/// `getpgid(std::process::id()) == getpgid(0)` and the +/// `pgid == supervisor_pgid` guard would trip, yielding `UNSAFE_PGID` +/// and degrading the policy to `Errno(EPERM)`. +fn fake_ctx_with_isolated_child() -> (HandlerCtx, std::process::Child) { + use std::os::unix::process::CommandExt; + let mut cmd = std::process::Command::new("sleep"); + cmd.arg("30"); + // SAFETY: `setpgid` is async-signal-safe; pid=0 acts on the + // calling process; pgid=0 creates a new group whose leader is the + // calling process. + unsafe { + cmd.pre_exec(|| { + if libc::setpgid(0, 0) != 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + let child = cmd.spawn().expect("spawn sleep child"); + let child_pid = child.id() as i32; + // pre_exec runs after fork; poll briefly for the kernel to + // observe the pgid change. + let supervisor_pgid = unsafe { libc::getpgid(0) }; + for _ in 0..50 { + // SAFETY: signal-safe; positive pid. + let resolved = unsafe { libc::getpgid(child_pid) }; + if resolved == child_pid && resolved != supervisor_pgid { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let resolved = unsafe { libc::getpgid(child_pid) }; + assert_eq!( + resolved, child_pid, + "precondition: pre_exec setpgid(0,0) did not take effect (resolved={resolved})", + ); + assert_ne!( + resolved, supervisor_pgid, + "precondition: child's pgid must differ from supervisor's", + ); + let ctx = HandlerCtx { + notif: SeccompNotif { + id: 1, pid: child_pid as u32, flags: 0, + data: SeccompData { nr: 39, arch: 0xC000003E, + instruction_pointer: 0, args: [0; 6] }, + }, + notif_fd: -1, + }; + (ctx, child) +} + extern "C-unwind" fn return_value_42( _ud: *mut std::ffi::c_void, _notif: *const sandlock_ffi::notif_repr::sandlock_notif_data_t, @@ -724,14 +784,16 @@ extern "C-unwind" fn k_handler_set_kill_sigkill_zero_pgid( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn k1_pgid_resolution_rejects_pid_zero() { // notif.pid == 0 can occur in nested PID namespaces (Kubernetes - // pod-in-pod). Without the `pid <= 0` guard, `getpgid(0)` would - // return the supervisor's own pgid, and a Kill { pgid: 0 } action - // would be substituted with `pgid == supervisor_pgid`, turning a - // killpg into supervisor suicide. + // pod-in-pod, KubeVirt, DinD). The earlier resolution fell back to + // the bare pid (`0`) here, and `translate_action`'s `Kill` arm then + // produced `Kill { pgid: 0 }`. POSIX `killpg(0, sig)` is "signal + // the caller's process group" — supervisor suicide, the very + // vector this resolution exists to close. // - // With the guard: we fall back to the bare pid (here `0`). The - // kernel will reject `killpg(0)` inside the response path, but the - // supervisor survives. + // The new resolution flags this case via `UNSAFE_PGID`. + // `translate_action`'s `Kill` arm refuses substitution and returns + // `None`, which routes the dispatcher onto the configured + // exception policy (here `Continue`). let raw = unsafe { sandlock_ffi::handler::sandlock_handler_new( Some(k_handler_set_kill_sigkill_zero_pgid), @@ -744,8 +806,73 @@ async fn k1_pgid_resolution_rejects_pid_zero() { let cx = fake_ctx_with_pid(0); let action = handler.handle(&cx).await; assert!( - matches!(action, NotifAction::Kill { pgid: 0, .. }), - "expected Kill {{ pgid: 0 }} (guard refused getpgid(0) substitution), got {action:?}", + matches!(action, NotifAction::Continue), + "expected exception-policy fallback (Continue) when no safe pgid available, got {action:?}", + ); +} + +// Defence-in-depth: in addition to the unit-level assertion above, +// verify directly that the supervisor's process group is NOT signalled +// when the lethal-pgid path triggers. We register a SIGURG handler on +// the test process (a signal not used by tokio or by the test runtime), +// run a callback that arms a `Kill { sig: SIGURG, pgid: 0 }` action +// through the FFI handler dispatch, and assert the counter never +// increments. If the old behaviour (substitute pgid=0 and dispatch) +// regressed, the supervisor's group would receive SIGURG and the +// assertion would fail. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn k1_no_supervisor_signal_on_pid_zero_kill() { + use std::sync::atomic::{AtomicUsize, Ordering}; + static SIGURG_COUNT: AtomicUsize = AtomicUsize::new(0); + + extern "C" fn sigurg_handler(_: libc::c_int) { + SIGURG_COUNT.fetch_add(1, Ordering::SeqCst); + } + // SAFETY: installing a signal handler is signal-safe; the handler + // itself touches only an AtomicUsize (lock-free, async-signal-safe + // on Linux). + unsafe { + let mut sa: libc::sigaction = std::mem::zeroed(); + sa.sa_sigaction = sigurg_handler as usize; + libc::sigemptyset(&mut sa.sa_mask); + libc::sigaction(libc::SIGURG, &sa, std::ptr::null_mut()); + } + SIGURG_COUNT.store(0, Ordering::SeqCst); + + extern "C-unwind" fn arm_lethal_kill( + _ud: *mut std::ffi::c_void, + _notif: *const sandlock_ffi::notif_repr::sandlock_notif_data_t, + _mem: *mut sandlock_ffi::handler::sandlock_mem_handle_t, + out: *mut sandlock_ffi::handler::sandlock_action_out_t, + ) -> i32 { + unsafe { sandlock_ffi::handler::sandlock_action_set_kill(out, libc::SIGURG, 0) }; + 0 + } + let raw = unsafe { + sandlock_ffi::handler::sandlock_handler_new( + Some(arm_lethal_kill), + std::ptr::null_mut(), + None, + sandlock_exception_policy_t::Continue as u32, + ) + }; + let handler = unsafe { FfiHandler::from_raw(raw) }; + let cx = fake_ctx_with_pid(0); // pid=0 -> UNSAFE_PGID + let action = handler.handle(&cx).await; + + // The action must be Continue (exception-policy fallback), NOT a + // Kill that send_response would forward to killpg(0). + assert!( + matches!(action, NotifAction::Continue), + "action must not be Kill when no safe pgid is available; got {action:?}", + ); + + // Give the OS a moment in case SIGURG was actually delivered. + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + assert_eq!( + SIGURG_COUNT.load(Ordering::SeqCst), + 0, + "supervisor's process group must NOT receive the signal", ); } @@ -753,10 +880,13 @@ async fn k1_pgid_resolution_rejects_pid_zero() { async fn k2_pgid_resolution_rejects_supervisor_pgid_match() { // Spawn a child WITHOUT pre_exec setpgid, so it inherits the // supervisor's process group. `getpgid(child_pid) == getpgid(0) == - // supervisor_pgid`. Without the `pgid == supervisor_pgid` guard, - // the substitution would yield Kill { pgid: supervisor_pgid }, the - // supervisor-suicide vector. With the guard, we fall back to the - // bare child pid. + // supervisor_pgid`. Earlier versions fell back to the bare pid here + // (Kill { pgid: child_pid }), but that left the substitution + // semantics under-defined: `killpg(child_pid)` succeeds only if + // child_pid happens to also be a pgid. With the new resolution + // we flag the case via `UNSAFE_PGID`, and `translate_action`'s + // `Kill` arm refuses substitution — routing the dispatcher onto + // the exception policy (here `Continue`). let supervisor_pgid = unsafe { libc::getpgid(0) }; let mut child = std::process::Command::new("sleep") .arg("30") @@ -771,10 +901,6 @@ async fn k2_pgid_resolution_rejects_supervisor_pgid_match() { resolved_pgid, supervisor_pgid, "precondition: child should inherit supervisor's pgid; got {resolved_pgid}, supervisor={supervisor_pgid}", ); - assert_ne!( - child_pid, supervisor_pgid, - "precondition: child_pid must differ from supervisor_pgid; otherwise the assertion cannot discriminate substitution vs fallback", - ); let raw = unsafe { sandlock_ffi::handler::sandlock_handler_new( @@ -793,17 +919,22 @@ async fn k2_pgid_resolution_rejects_supervisor_pgid_match() { let _ = child.wait(); assert!( - matches!(action, NotifAction::Kill { pgid, .. } if pgid == child_pid), - "expected Kill {{ pgid: child_pid={child_pid} }} (guard refused supervisor_pgid={supervisor_pgid} substitution), got {action:?}", + matches!(action, NotifAction::Continue), + "expected exception-policy fallback (Continue) when child's pgid matches supervisor's (supervisor_pgid={supervisor_pgid}), got {action:?}", ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn k3_pgid_resolution_falls_back_on_esrch() { // Use a clearly-dead pid that will never exist on this host. - // `getpgid(i32::MAX)` returns -1 with ESRCH on Linux. Without the - // `pgid <= 0` guard, the action would be Kill { pgid: -1 }; with - // the guard, we fall back to the bare pid. + // `getpgid(i32::MAX)` returns -1 with ESRCH on Linux. Earlier + // versions fell back to the bare pid here, producing + // `Kill { pgid: i32::MAX }` — which the kernel would reject with + // ESRCH in the response path, but only after `translate_action` + // had emitted a Kill action. The new resolution flags the case + // via `UNSAFE_PGID`; `translate_action`'s `Kill` arm refuses + // substitution and routes through the exception policy + // (here `Continue`). let dead_pid: u32 = i32::MAX as u32; let raw = unsafe { @@ -818,8 +949,8 @@ async fn k3_pgid_resolution_falls_back_on_esrch() { let cx = fake_ctx_with_pid(dead_pid); let action = handler.handle(&cx).await; assert!( - matches!(action, NotifAction::Kill { pgid, .. } if pgid == dead_pid as i32), - "expected Kill {{ pgid: dead_pid={dead_pid} }} (ESRCH fallback to bare pid), got {action:?}", + matches!(action, NotifAction::Continue), + "expected exception-policy fallback (Continue) on ESRCH, got {action:?}", ); } @@ -837,7 +968,15 @@ async fn ffi_handler_kill_policy_on_callback_rc_nonzero() { }; // Safety: see `ffi_handler_translates_continue`. let h = unsafe { FfiHandler::from_raw(raw) }; - let action = h.handle(&fake_ctx()).await; + // Use an isolated child so the resolved child_pgid is not + // UNSAFE_PGID — otherwise the exception policy's Kill arm + // (correctly) degrades to Errno(EPERM) to avoid supervisor + // suicide, and the assertion below would not exercise the + // Kill-path the test exists to cover. + let (cx, mut child) = fake_ctx_with_isolated_child(); + let action = h.handle(&cx).await; + let _ = child.kill(); + let _ = child.wait(); assert!(matches!(action, NotifAction::Kill { sig, .. } if sig == libc::SIGKILL)); } @@ -887,7 +1026,12 @@ async fn ffi_handler_recovers_from_callback_panic() { }; // Safety: see `ffi_handler_translates_continue`. let h = unsafe { FfiHandler::from_raw(raw) }; - let action = h.handle(&fake_ctx()).await; + // Use an isolated child so the Kill exception policy is observable + // (rationale identical to `ffi_handler_kill_policy_on_callback_rc_nonzero`). + let (cx, mut child) = fake_ctx_with_isolated_child(); + let action = h.handle(&cx).await; + let _ = child.kill(); + let _ = child.wait(); // The `catch_unwind` inside `spawn_blocking` swallows the panic and // the dispatcher falls back to the configured exception policy. assert!(matches!(action, NotifAction::Kill { sig, .. } if sig == libc::SIGKILL)); @@ -1495,11 +1639,19 @@ fn mem_read_cstr_reads_path_from_intercepted_openat() { // the supervisor as the "inject" srcfd; the read end stays in this // test and observes EOF once the drain path closes the write end. fn make_pipe() -> (i32, i32) { - // SAFETY: `libc::pipe` writes exactly two fds into the array on + // Use `pipe2` with `O_CLOEXEC` so concurrent tests that spawn + // children (via std::process::Command, including + // `fake_ctx_with_isolated_child`) do not inherit a copy of the + // write end. Without this, an inherited duplicate keeps the read + // end from observing EOF even after the supervisor's drain path + // closes its own copy — the EOF-drain assertion would then hang + // on EAGAIN instead of returning 0. + // + // SAFETY: `libc::pipe2` writes exactly two fds into the array on // success and returns 0; we assert success below. let mut fds = [0i32; 2]; - let rc = unsafe { libc::pipe(fds.as_mut_ptr()) }; - assert_eq!(rc, 0, "pipe() failed: errno={}", std::io::Error::last_os_error()); + let rc = unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC) }; + assert_eq!(rc, 0, "pipe2() failed: errno={}", std::io::Error::last_os_error()); (fds[0], fds[1]) } @@ -1539,6 +1691,15 @@ async fn a1_ffi_handler_drains_inject_fd_on_panic() { // `sandlock_action_set_inject_fd_send` and then panics used to leak // the supervisor-side srcfd. After the fix, the dispatcher's // catch-unwind path drains the pending payload, closing the fd. + // + // The exception policy below is `Kill`. With `fake_ctx()` (test + // process's own pid), the pgid resolution sees + // `pgid == supervisor_pgid` and yields `UNSAFE_PGID`. The Kill + // exception arm then degrades to `Errno(EPERM)` (D-new-1: avoid + // supervisor suicide via killpg(0)). The drain assertion below is + // the load-bearing one for this regression hook — the exception + // action just demonstrates that the dispatcher routed onto the + // policy fallback at all. let (read_fd, write_fd) = make_pipe(); // Heap-allocated so the pointer stays valid across spawn_blocking. let fd_holder: Box = Box::new(write_fd); @@ -1556,8 +1717,8 @@ async fn a1_ffi_handler_drains_inject_fd_on_panic() { let h = unsafe { FfiHandler::from_raw(raw) }; let action = h.handle(&fake_ctx()).await; assert!( - matches!(action, NotifAction::Kill { sig, .. } if sig == libc::SIGKILL), - "panic must route to the exception policy fallback", + matches!(action, NotifAction::Errno(e) if e == libc::EPERM), + "panic must route to the exception-policy fallback (Kill degraded to EPERM under UNSAFE_PGID), got {action:?}", ); // After `handle` returns, the drain path should have closed @@ -1609,6 +1770,9 @@ async fn a2_ffi_handler_drains_inject_fd_tracked_discriminant() { // `InjectFdSendTracked` discriminant directly used to leak the // srcfd because `translate_action`'s `K::InjectFdSendTracked` arm // returned None and dropped the value without reclaiming the fd. + // + // See `a1_ffi_handler_drains_inject_fd_on_panic` for why the + // exception action below is `Errno(EPERM)` rather than `Kill`. let (read_fd, write_fd) = make_pipe(); let fd_holder: Box = Box::new(write_fd); let fd_ptr = Box::into_raw(fd_holder) as *mut std::ffi::c_void; @@ -1625,8 +1789,8 @@ async fn a2_ffi_handler_drains_inject_fd_tracked_discriminant() { let h = unsafe { FfiHandler::from_raw(raw) }; let action = h.handle(&fake_ctx()).await; assert!( - matches!(action, NotifAction::Kill { sig, .. } if sig == libc::SIGKILL), - "unsupported tracked discriminant must route to the exception policy fallback", + matches!(action, NotifAction::Errno(e) if e == libc::EPERM), + "unsupported tracked discriminant must route to the exception-policy fallback (Kill degraded to EPERM under UNSAFE_PGID), got {action:?}", ); let n = read_eof_or_eagain(read_fd); From 0d5b480a45ea21d4ba205b80eeedad9b81b197fc Mon Sep 17 00:00:00 2001 From: dzerik Date: Thu, 14 May 2026 11:40:57 +0300 Subject: [PATCH 22/24] fix: propagate ud_drop panics safely through the run path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A5 (d1807c3) made sandlock_handler_free use extern "C-unwind" so a panicking user-supplied ud_drop unwinds cleanly. But the much-more- trafficked sandlock_run_with_handlers / _interactive entry points were still extern "C", and release_registrations dropped multiple boxes in a loop without catch_unwind. A mid-loop ud_drop panic: (1) aborted the process at the extern "C" boundary, and (2) skipped cleanup of the remaining handlers in the array, which violated the "array consumed as a whole" contract. Switch the run entry points to extern "C-unwind". Wrap each per-element drop in release_registrations with catch_unwind, then re-raise the first captured panic after the loop completes so the outer caller's panic-recovery flow still observes one unwinding panic — but only after all handlers have been freed. Add release_registrations_continues_after_mid_loop_panic as a regression hook. --- crates/sandlock-ffi/src/handler/run.rs | 53 ++++++++++++-- crates/sandlock-ffi/tests/handler_smoke.rs | 82 ++++++++++++++++++++++ 2 files changed, 131 insertions(+), 4 deletions(-) diff --git a/crates/sandlock-ffi/src/handler/run.rs b/crates/sandlock-ffi/src/handler/run.rs index b177540..ec7e864 100644 --- a/crates/sandlock-ffi/src/handler/run.rs +++ b/crates/sandlock-ffi/src/handler/run.rs @@ -112,6 +112,16 @@ fn block_on_run( /// NUL-terminated UTF-8 C string; the placement mirrors the existing /// `sandlock_run` entry point in `lib.rs`. /// +/// Declared `extern "C-unwind"` because the handler containers reach +/// this entry point as part of the registration array and their +/// user-supplied `ud_drop` may panic when the supervisor frees them +/// (either during a normal Box-drop or on the early-return cleanup in +/// `release_registrations`). Unwinding across an `extern "C"` boundary +/// is undefined behaviour and aborts the process under modern +/// rustc — `extern "C-unwind"` is the only legal way to let such a +/// panic propagate to the caller, who can then decide whether to +/// catch it. +/// /// # Safety /// All pointer arguments must be valid for their documented lifetimes: /// `policy` must come from `sandlock_sandbox_build`, `argv` must be a @@ -119,7 +129,7 @@ fn block_on_run( /// pointer must come from `sandlock_handler_new` and must not be reused /// after this call (ownership transfers in). #[no_mangle] -pub unsafe extern "C" fn sandlock_run_with_handlers( +pub unsafe extern "C-unwind" fn sandlock_run_with_handlers( policy: *const crate::sandlock_sandbox_t, name: *const std::os::raw::c_char, argv: *const *const std::os::raw::c_char, @@ -133,11 +143,14 @@ pub unsafe extern "C" fn sandlock_run_with_handlers( /// Interactive-stdio variant of `sandlock_run_with_handlers`. /// /// `name` follows the same convention as `sandlock_run_with_handlers`. +/// The `extern "C-unwind"` declaration carries the same rationale: a +/// panicking `ud_drop` must be able to unwind out of this entry point +/// without process abort. /// /// # Safety /// Same constraints as `sandlock_run_with_handlers`. #[no_mangle] -pub unsafe extern "C" fn sandlock_run_interactive_with_handlers( +pub unsafe extern "C-unwind" fn sandlock_run_interactive_with_handlers( policy: *const crate::sandlock_sandbox_t, name: *const std::os::raw::c_char, argv: *const *const std::os::raw::c_char, @@ -153,6 +166,15 @@ pub unsafe extern "C" fn sandlock_run_interactive_with_handlers( /// `collect_registrations` was not reached — guarantees the C ABI /// contract "all handler pointers are consumed by this call". /// +/// Each per-element drop runs an arbitrary, user-supplied `ud_drop` +/// that may panic. Without protection, a panic mid-loop would unwind +/// past the remaining handlers — leaving them allocated and violating +/// the "array consumed as a whole" contract (partial-consume leak). +/// We wrap each drop in `catch_unwind`, remember the first panic, and +/// re-raise it after the loop completes via `resume_unwind`. The +/// caller is `extern "C-unwind"` so the propagated panic is legal at +/// the FFI boundary, while every handler container is still released. +/// /// # Safety /// `regs` is either null (no-op) or points to `nregs` valid /// `sandlock_handler_registration_t` slots whose `handler` pointer is @@ -166,12 +188,35 @@ unsafe fn release_registrations( return; } let slice = slice::from_raw_parts(regs, nregs); + let mut first_panic: Option> = None; for r in slice { if !r.handler.is_null() { - // Reclaim and drop the container so its `ud_drop` runs. - drop(Box::from_raw(r.handler)); + let h = r.handler; + // SAFETY: `h` is non-null and came from `sandlock_handler_new` + // per the type contract. The closure is `AssertUnwindSafe` + // because the only state crossing the unwind boundary is the + // raw pointer (consumed by `Box::from_raw`) — no shared + // references with broken invariants. + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + drop(Box::from_raw(h)); + })); + if let Err(payload) = result { + if first_panic.is_none() { + first_panic = Some(payload); + } + // Subsequent panics are dropped: they would compose + // into "panic during panic" → abort. Keeping only the + // first preserves the original failure context for the + // outer caller while still finishing the loop. + } } } + if let Some(payload) = first_panic { + // Re-raise the first captured panic. The outer entry point is + // `extern "C-unwind"` so this propagates legally to the C + // caller, who can decide whether to catch it. + std::panic::resume_unwind(payload); + } } unsafe fn run_with_handlers_inner( diff --git a/crates/sandlock-ffi/tests/handler_smoke.rs b/crates/sandlock-ffi/tests/handler_smoke.rs index f30bb94..ae81bfc 100644 --- a/crates/sandlock-ffi/tests/handler_smoke.rs +++ b/crates/sandlock-ffi/tests/handler_smoke.rs @@ -1858,6 +1858,88 @@ extern "C-unwind" fn a5_panicking_dropper(_ud: *mut std::ffi::c_void) { panic!("test panic from dropper"); } +static C_NEW_1_DROPPER_A: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(0); +static C_NEW_1_DROPPER_B: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(0); + +extern "C-unwind" fn c_new_1_dropper_a(_ud: *mut std::ffi::c_void) { + C_NEW_1_DROPPER_A.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + panic!("c_new_1 dropper_a panic"); +} + +extern "C-unwind" fn c_new_1_dropper_b(_ud: *mut std::ffi::c_void) { + C_NEW_1_DROPPER_B.fetch_add(1, std::sync::atomic::Ordering::SeqCst); +} + +#[test] +fn release_registrations_continues_after_mid_loop_panic() { + // Bug C-new-1 regression hook: `release_registrations` used to + // drop each container in a bare loop. A mid-loop panic from a + // user-supplied `ud_drop` would unwind past the remaining slots, + // leaving handler containers leaked (partial-consume — violates + // the "array consumed as a whole" C-ABI contract). After the fix, + // each drop runs inside `catch_unwind`, the first panic is + // captured, the loop completes, and the panic is then re-raised + // through the `extern "C-unwind"` entry point. + C_NEW_1_DROPPER_A.store(0, std::sync::atomic::Ordering::SeqCst); + C_NEW_1_DROPPER_B.store(0, std::sync::atomic::Ordering::SeqCst); + + let h1 = unsafe { + sandlock_handler_new( + Some(test_handler as sandlock_handler_fn_t), + // Non-null ud so the `Drop` impl fires the dropper. The + // dropper does not read the pointer. + 0xDEAD_BEEFusize as *mut std::ffi::c_void, + Some(c_new_1_dropper_a), + sandlock_exception_policy_t::Kill as u32, + ) + }; + let h2 = unsafe { + sandlock_handler_new( + Some(test_handler as sandlock_handler_fn_t), + 0xCAFE_F00Dusize as *mut std::ffi::c_void, + Some(c_new_1_dropper_b), + sandlock_exception_policy_t::Kill as u32, + ) + }; + assert!(!h1.is_null() && !h2.is_null(), "handler_new must succeed"); + let regs = [ + sandlock_handler_registration_t { syscall_nr: libc::SYS_getpid, handler: h1 }, + sandlock_handler_registration_t { syscall_nr: libc::SYS_getppid, handler: h2 }, + ]; + // Null policy triggers `release_registrations` on the + // early-return path. With the fix, `sandlock_run_with_handlers` + // unwinds (extern "C-unwind") because dropper_a panics; + // `catch_unwind` here captures it. + let result = std::panic::catch_unwind(|| { + unsafe { + sandlock_run_with_handlers( + std::ptr::null(), + std::ptr::null(), + std::ptr::null(), + 0, + regs.as_ptr(), + regs.len(), + ) + } + }); + assert!( + result.is_err(), + "expected sandlock_run_with_handlers to propagate the captured panic out of release_registrations", + ); + assert_eq!( + C_NEW_1_DROPPER_A.load(std::sync::atomic::Ordering::SeqCst), + 1, + "dropper_a must have fired exactly once", + ); + assert_eq!( + C_NEW_1_DROPPER_B.load(std::sync::atomic::Ordering::SeqCst), + 1, + "dropper_b must have fired despite dropper_a panicking (no partial-consume leak)", + ); +} + #[test] fn a5_handler_free_unwinds_on_panicking_dropper() { // Bug A5 regression hook: `sandlock_handler_free` used to be From 611aba412fd6a6cf416ad07911cf06bc26bb8580 Mon Sep 17 00:00:00 2001 From: dzerik Date: Thu, 14 May 2026 11:46:25 +0300 Subject: [PATCH 23/24] fix: invoke ud_drop on container free regardless of ud null state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The C header documents sandlock_handler_ud_drop_t as "invoked exactly once when the container is freed" — but the Rust Drop impl gated the call on a non-null ud check, contradicting the contract. A C caller using ud_drop for lifecycle logging (or any side effect not keyed on ud) silently lost the cleanup whenever the container had a null ud. The previous regression test (handler_new_with_null_ud_and_dropper_does_not_invoke_dropper) used a panicking dropper and asserted the dropper did NOT fire — locking in the buggy behavior as expected output. Rename and rewrite to assert the dropper does fire exactly once. Idiomatic C: free(NULL) is well-defined no-op; user droppers can mirror that on their own side if they care. --- crates/sandlock-ffi/src/handler/abi.rs | 12 +++-- crates/sandlock-ffi/tests/handler_smoke.rs | 56 ++++++++++++++-------- 2 files changed, 45 insertions(+), 23 deletions(-) diff --git a/crates/sandlock-ffi/src/handler/abi.rs b/crates/sandlock-ffi/src/handler/abi.rs index 983ecc1..ecc0f92 100644 --- a/crates/sandlock-ffi/src/handler/abi.rs +++ b/crates/sandlock-ffi/src/handler/abi.rs @@ -370,10 +370,14 @@ unsafe impl Sync for sandlock_handler_t {} impl Drop for sandlock_handler_t { fn drop(&mut self) { if let Some(drop_fn) = self.ud_drop.take() { - if !self.ud.is_null() { - (drop_fn)(self.ud); - self.ud = std::ptr::null_mut(); - } + // Per the C header contract on `sandlock_handler_ud_drop_t`: + // the dropper fires exactly once when the container is freed, + // regardless of whether `ud` is null. C callers that store + // metadata via `ud_drop` (e.g., for lifecycle logging) need + // the call even with null ud; idiomatic C dropper code can + // mirror `free(NULL)` semantics on its own. + (drop_fn)(self.ud); + self.ud = std::ptr::null_mut(); } } } diff --git a/crates/sandlock-ffi/tests/handler_smoke.rs b/crates/sandlock-ffi/tests/handler_smoke.rs index ae81bfc..32d3974 100644 --- a/crates/sandlock-ffi/tests/handler_smoke.rs +++ b/crates/sandlock-ffi/tests/handler_smoke.rs @@ -1068,8 +1068,13 @@ async fn ffi_handler_callback_returns_zero_but_never_sets_action_triggers_fallba // ---- Group F: handler_new edge cases ------------------------------------ -extern "C-unwind" fn panicking_dropper(_ud: *mut std::ffi::c_void) { - panic!("dropper invoked when it should not have been"); +static NULL_UD_DROP_CALLS: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(0); + +extern "C-unwind" fn counting_null_ud_dropper(ud: *mut std::ffi::c_void) { + // Sanity: confirm the dropper sees the null ud we passed in. + assert!(ud.is_null(), "dropper invoked with non-null ud unexpectedly"); + NULL_UD_DROP_CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); } #[test] @@ -1086,22 +1091,32 @@ fn handler_new_with_null_handler_fn_returns_null() { } #[test] -fn handler_new_with_null_ud_and_dropper_does_not_invoke_dropper() { - // Allocates a container with a destructor but null ud; the `Drop` - // impl on `sandlock_handler_t` must skip the destructor in that case - // because there is nothing to free. +fn handler_new_with_null_ud_still_invokes_dropper() { + // C header guarantees ud_drop fires exactly once on free, regardless + // of whether ud is null. C-side droppers can mirror free(NULL) + // semantics themselves; the Rust container does not gate on ud. + + NULL_UD_DROP_CALLS.store(0, std::sync::atomic::Ordering::SeqCst); let h = unsafe { sandlock_handler_new( Some(test_handler as sandlock_handler_fn_t), - std::ptr::null_mut(), - Some(panicking_dropper), + std::ptr::null_mut(), // <-- null ud + Some(counting_null_ud_dropper), sandlock_exception_policy_t::Kill as u32, ) }; - assert!(!h.is_null(), "expected a valid handler container"); - // Safety: `h` was just produced and not yet freed. If the guard in - // `Drop` were missing the dropper would panic and abort the test. + assert!(!h.is_null()); + assert_eq!( + NULL_UD_DROP_CALLS.load(std::sync::atomic::Ordering::SeqCst), + 0, + "dropper must not fire before sandlock_handler_free", + ); unsafe { sandlock_handler_free(h) }; + assert_eq!( + NULL_UD_DROP_CALLS.load(std::sync::atomic::Ordering::SeqCst), + 1, + "dropper must fire exactly once during Drop", + ); } // ---- Group G: run_with_handlers failure paths --------------------------- @@ -1820,9 +1835,10 @@ fn a3_run_with_handlers_releases_registrations_on_null_policy() { // consumes every non-null handler pointer on entry, regardless of // return value. A3_UD_DROPPER_CALLS.store(0, std::sync::atomic::Ordering::SeqCst); - // Non-null ud — the `Drop` impl on `sandlock_handler_t` only fires - // the dropper when `ud` is non-null. The dropper itself ignores - // the value, so any non-null bit pattern works. + // Non-null ud — the dropper itself ignores the value, so any + // non-null bit pattern works. (Null ud would also fire the + // dropper per the C header contract; we just pick a non-null + // sentinel here for clarity of intent.) let h = unsafe { sandlock_handler_new( Some(test_handler as sandlock_handler_fn_t), @@ -1888,8 +1904,9 @@ fn release_registrations_continues_after_mid_loop_panic() { let h1 = unsafe { sandlock_handler_new( Some(test_handler as sandlock_handler_fn_t), - // Non-null ud so the `Drop` impl fires the dropper. The - // dropper does not read the pointer. + // Non-null ud sentinel; the dropper does not read the + // pointer. Null ud would also fire the dropper per the + // C header contract. 0xDEAD_BEEFusize as *mut std::ffi::c_void, Some(c_new_1_dropper_a), sandlock_exception_policy_t::Kill as u32, @@ -1955,9 +1972,10 @@ fn a5_handler_free_unwinds_on_panicking_dropper() { let h = unsafe { sandlock_handler_new( Some(test_handler as sandlock_handler_fn_t), - // Non-null ud so the `Drop` impl invokes the dropper. Any - // non-null bit pattern works because the dropper itself - // never reads through the pointer — it just panics. + // Any non-null bit pattern works because the dropper + // itself never reads through the pointer — it just panics. + // Null ud would also fire the dropper per the C header + // contract. 0xDEAD_BEEFusize as *mut std::ffi::c_void, Some(a5_panicking_dropper), sandlock_exception_policy_t::Kill as u32, From f312d90045b4401f9699aa924bc43bcce6178d99 Mon Sep 17 00:00:00 2001 From: dzerik Date: Thu, 14 May 2026 11:56:45 +0300 Subject: [PATCH 24/24] =?UTF-8?q?fix:=20minor=20audit=20cleanup=20?= =?UTF-8?q?=E2=80=94=20exact=20stdout=20match,=20payload-stomp=20checks,?= =?UTF-8?q?=20=20=20=20=20=20max=5Flen=3D1=20path,=20argc/nreg=20bounds,?= =?UTF-8?q?=20errno=20field=20rename?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five minor findings from the deep bug-pattern audit: - B-new-2: end-to-end tests asserted `stdout.contains("777")` / `contains("111")` / `contains("222")` — a real child pid containing those substrings (~1.1% per run) would mask a regression. Switch to exact match. - B-new-4: action setter tests verified the kind tag and the written payload field but not that tag-only setters (set_continue, set_hold) leave the union payload untouched. Plant a sentinel in payload.none and assert it survives. - A-new-3: sandlock_mem_read_cstr rejected max_len==1 even though the header doc says max_len >= 1 is sufficient to fit a NUL. Add a fast-path that probes the target for readability and writes a NUL on success. - D-new-2: argc and nregistrations had no upper bound; a malicious or buggy C caller passing argc = u32::MAX with a small argv backing was unbounded-deref UB. Cap both at 4096 and add regression tests. - E-new-1: the Rust union field `errno` had the C-side counterpart named `errno_value` (to avoid the macro collision). The asymmetry was a documentation hazard. Rename the Rust field to `errno_value` to match. --- crates/sandlock-ffi/src/handler/abi.rs | 34 +++++- crates/sandlock-ffi/src/handler/adapter.rs | 2 +- crates/sandlock-ffi/src/handler/run.rs | 38 +++++++ crates/sandlock-ffi/tests/handler_smoke.rs | 115 +++++++++++++++++++-- 4 files changed, 178 insertions(+), 11 deletions(-) diff --git a/crates/sandlock-ffi/src/handler/abi.rs b/crates/sandlock-ffi/src/handler/abi.rs index ecc0f92..5241674 100644 --- a/crates/sandlock-ffi/src/handler/abi.rs +++ b/crates/sandlock-ffi/src/handler/abi.rs @@ -49,7 +49,26 @@ pub unsafe extern "C" fn sandlock_mem_read_cstr( } let h = &*handle; // `max_len` is the caller-supplied buffer size including space for the - // trailing NUL; reserve one byte so we can always terminate the string. + // trailing NUL. The C header documents `max_len >= 1` as sufficient + // (the buffer holds at least the NUL terminator), so a 1-byte buffer + // must succeed when the target string is empty. The general path + // below computes `cap = max_len - 1`, which is 0 for `max_len == 1` + // — and `read_child_cstr` rejects `max_len == 0` outright. Take the + // edge case via an explicit fast-path: probe the target for one + // byte; on a NUL (= empty string) write the terminator and return + // success, otherwise the caller's buffer cannot fit the payload. + if max_len == 1 { + match read_child_cstr(h.notif_fd, h.notif_id, h.pid, addr, 1) { + Some(s) if s.is_empty() => { + *buf = 0; + *out_len = 0; + return 0; + } + // Either the target string is non-empty (we have no room + // for it) or the read failed entirely. Either way, -1. + _ => return -1, + } + } let cap = max_len - 1; let s = match read_child_cstr(h.notif_fd, h.notif_id, h.pid, addr, cap) { Some(s) => s, @@ -170,7 +189,11 @@ pub struct sandlock_action_inject_tracked_t { #[allow(non_camel_case_types)] pub union sandlock_action_payload_t { pub none: u64, - pub errno: i32, + /// `errno_value` rather than `errno` to mirror the C header field + /// (the C side avoids the name `errno` because `` macros + /// it). Keeping both languages in sync removes a documentation + /// hazard for callers that grep across Rust and C sources. + pub errno_value: i32, pub return_value: i64, pub inject_send: sandlock_action_inject_t, pub inject_send_tracked: sandlock_action_inject_tracked_t, @@ -216,10 +239,13 @@ pub unsafe extern "C" fn sandlock_action_set_continue(out: *mut sandlock_action_ /// # Safety /// Same constraints as `sandlock_action_set_continue`. #[no_mangle] -pub unsafe extern "C" fn sandlock_action_set_errno(out: *mut sandlock_action_out_t, errno: i32) { +pub unsafe extern "C" fn sandlock_action_set_errno( + out: *mut sandlock_action_out_t, + errno_value: i32, +) { if out.is_null() { return; } (*out).kind = sandlock_action_kind_t::Errno as u32; - (*out).payload.errno = errno; + (*out).payload.errno_value = errno_value; } /// Return a specific value from the syscall without entering the kernel. diff --git a/crates/sandlock-ffi/src/handler/adapter.rs b/crates/sandlock-ffi/src/handler/adapter.rs index a98cac7..a6b9af8 100644 --- a/crates/sandlock-ffi/src/handler/adapter.rs +++ b/crates/sandlock-ffi/src/handler/adapter.rs @@ -284,7 +284,7 @@ fn translate_action(out: &sandlock_action_out_t, child_pgid: i32) -> Option NotifAction::Continue, - K::Errno => NotifAction::Errno(out.payload.errno), + K::Errno => NotifAction::Errno(out.payload.errno_value), K::ReturnValue => NotifAction::ReturnValue(out.payload.return_value), K::Hold => NotifAction::Hold, K::Kill => { diff --git a/crates/sandlock-ffi/src/handler/run.rs b/crates/sandlock-ffi/src/handler/run.rs index ec7e864..8a1bed5 100644 --- a/crates/sandlock-ffi/src/handler/run.rs +++ b/crates/sandlock-ffi/src/handler/run.rs @@ -12,6 +12,20 @@ use sandlock_core::{RunResult, Sandbox, SandlockError}; use super::abi::sandlock_handler_registration_t; use super::adapter::FfiHandler; +/// Defensive upper bound on `argc`. Linux's `ARG_MAX` is typically +/// 128 KiB-2 MiB of *characters* across all argv+envp; an argv with +/// 4096 entries is already preposterous in practice. Bounding here +/// turns a malicious or buggy caller passing `argc = u32::MAX` (which +/// would otherwise drive an unbounded deref loop) into a fast NULL +/// return at the FFI boundary. +const MAX_ARGV: u32 = 4096; + +/// Defensive upper bound on `nregistrations`. The kernel exposes +/// ~400-500 syscalls on Linux; registering even all of them is well +/// under this cap. Bounding here closes the same unbounded-deref vector +/// for the registration array. +const MAX_REGISTRATIONS: usize = 4096; + fn argv_from_c( argv: *const *const std::os::raw::c_char, argc: u32, @@ -26,6 +40,12 @@ fn argv_from_c( if argc == 0 { return None; } + // Reject implausible `argc` values before we start dereferencing + // `argv`. Without this cap, a caller passing `argc = u32::MAX` + // would have us walk 4 billion pointer slots looking for nulls. + if argc > MAX_ARGV { + return None; + } let mut out = Vec::with_capacity(argc as usize); for i in 0..(argc as isize) { let p = unsafe { *argv.offset(i) }; @@ -48,6 +68,13 @@ fn collect_registrations( if nregs == 0 { return Some(Vec::new()); } + // Bound `nregs` before we materialise the slice. An attacker-supplied + // `nregs = usize::MAX` would otherwise hand `slice::from_raw_parts` + // a length larger than the underlying allocation — UB. The cap is + // generous enough for any legitimate caller. + if nregs > MAX_REGISTRATIONS { + return None; + } let slice = unsafe { slice::from_raw_parts(regs, nregs) }; // First pass: validate all entries before taking ownership of any. // Without this, a null pointer at index k+1 would leave us having @@ -187,6 +214,17 @@ unsafe fn release_registrations( if regs.is_null() || nregs == 0 { return; } + // Apply the same defensive cap as `collect_registrations`. Reach + // here from early-return paths in `run_with_handlers_inner` where + // `collect_registrations` may not have validated yet — without the + // cap, an attacker-supplied `nregs = usize::MAX` would feed + // `slice::from_raw_parts` a bogus length. Out-of-range counts + // can't have come from a valid registration array; refuse the + // walk entirely. The C-ABI "always consume" contract is then + // moot because no legitimate caller can hit this branch. + if nregs > MAX_REGISTRATIONS { + return; + } let slice = slice::from_raw_parts(regs, nregs); let mut first_panic: Option> = None; for r in slice { diff --git a/crates/sandlock-ffi/tests/handler_smoke.rs b/crates/sandlock-ffi/tests/handler_smoke.rs index 32d3974..2e41224 100644 --- a/crates/sandlock-ffi/tests/handler_smoke.rs +++ b/crates/sandlock-ffi/tests/handler_smoke.rs @@ -75,19 +75,36 @@ use sandlock_ffi::handler::{ #[test] fn action_setters_record_kind_and_payload() { let mut a = sandlock_action_out_t::zeroed(); + + // Plant a sentinel covering the first 8 bytes of the union (the + // largest scalar variant) before each tag-only setter. A setter + // documented as "kind only" that accidentally stomps the payload + // would clobber the sentinel. + const SENTINEL: u64 = 0xDEAD_BEEF_CAFE_F00D; + + // Writing through a union field is safe; reading is unsafe (we + // might be looking at bytes deposited by a different variant). The + // sentinel writes therefore need no `unsafe`, the post-condition + // reads do. + a.payload.none = SENTINEL; unsafe { sandlock_action_set_continue(&mut a) }; assert_eq!(a.kind, sandlock_action_kind_t::Continue as u32); + assert_eq!(unsafe { a.payload.none }, SENTINEL, + "set_continue must be tag-only and leave payload untouched"); unsafe { sandlock_action_set_errno(&mut a, 13) }; assert_eq!(a.kind, sandlock_action_kind_t::Errno as u32); - assert_eq!(unsafe { a.payload.errno }, 13); + assert_eq!(unsafe { a.payload.errno_value }, 13); unsafe { sandlock_action_set_return_value(&mut a, -1) }; assert_eq!(a.kind, sandlock_action_kind_t::ReturnValue as u32); assert_eq!(unsafe { a.payload.return_value }, -1); + a.payload.none = SENTINEL; unsafe { sandlock_action_set_hold(&mut a) }; assert_eq!(a.kind, sandlock_action_kind_t::Hold as u32); + assert_eq!(unsafe { a.payload.none }, SENTINEL, + "set_hold must be tag-only and leave payload untouched"); unsafe { sandlock_action_set_kill(&mut a, libc::SIGKILL, 4321) }; assert_eq!(a.kind, sandlock_action_kind_t::Kill as u32); @@ -474,9 +491,14 @@ fn run_with_handlers_intercepts_getpid() { let stdout_str = String::from_utf8_lossy(&stdout); let stderr_str = String::from_utf8_lossy(&stderr); let exit_code = unsafe { sandlock_result_exit_code(rr) }; - assert!(stdout_str.contains("777"), - "expected getpid to be intercepted; exit={} stdout={:?} stderr={:?}", - exit_code, stdout_str, stderr_str); + // The child script writes exactly `str(os.getpid())` with + // `sys.stdout.write`, so no trailing newline is expected. Match + // the full stdout — a substring check would silently pass on a + // mutation that broke dispatch when the real pid happened to + // contain "777" (pids 7770-7779, 17770-17779, ...). + assert_eq!(stdout_str.trim_end_matches('\n'), "777", + "expected getpid to be intercepted; exit={} stdout={:?} stderr={:?}", + exit_code, stdout_str, stderr_str); unsafe { sandlock_result_free(rr); } unsafe { sandlock_sandbox_free(policy); } @@ -1220,6 +1242,82 @@ fn run_with_handlers_null_registrations_with_nonzero_count_returns_null() { unsafe { sandlock_sandbox_free(policy); } } +#[test] +fn run_with_handlers_rejects_oversize_argc() { + // Defence-in-depth: `argc` is a `u32` from C, so a malicious or + // buggy caller could pass e.g. `u32::MAX` with a small backing + // array. Without an upper bound, `argv_from_c` would dereference + // four billion pointer slots before returning. We cap at 4096 + // (vastly larger than any plausible argv) and reject anything + // above. + use sandlock_ffi::*; + let builder = sandlock_sandbox_builder_new(); + let policy = { + let mut err: i32 = 0; + unsafe { sandlock_sandbox_build(builder, &mut err, std::ptr::null_mut()) } + }; + assert!(!policy.is_null(), "policy build failed"); + + let arg0 = CString::new("/bin/true").unwrap(); + // Backing argv has only one real entry; we lie about argc to + // exercise the bound check. The FFI must reject before reading + // past the first slot. + let argv = [arg0.as_ptr()]; + let rr = unsafe { + sandlock_run_with_handlers( + policy, + std::ptr::null(), + argv.as_ptr(), + 5000, // > MAX_ARGV (4096) + std::ptr::null(), + 0, + ) + }; + assert!(rr.is_null(), "expected null result for argc > MAX_ARGV"); + + unsafe { sandlock_sandbox_free(policy); } +} + +#[test] +fn run_with_handlers_rejects_oversize_nregistrations() { + // Mirror of `..._oversize_argc` for the registration count. + // A `nregistrations = usize::MAX` with a small backing array + // would hand `slice::from_raw_parts` a length larger than the + // allocation — UB. The FFI must refuse before that point. + use sandlock_ffi::*; + let builder = sandlock_sandbox_builder_new(); + let policy = { + let mut err: i32 = 0; + unsafe { sandlock_sandbox_build(builder, &mut err, std::ptr::null_mut()) } + }; + assert!(!policy.is_null(), "policy build failed"); + + let arg0 = CString::new("/bin/true").unwrap(); + let argv = [arg0.as_ptr()]; + // Single real registration slot; we lie about the count. + // `handler` is null so even if the bound check were bypassed the + // validation pass would still fail — that is fine because the + // bound check must trip first (a missing check would have us + // walk 5000 invalid slots before noticing). + let regs = [sandlock_handler_registration_t { + syscall_nr: libc::SYS_getpid, + handler: std::ptr::null_mut(), + }]; + let rr = unsafe { + sandlock_run_with_handlers( + policy, + std::ptr::null(), + argv.as_ptr(), + argv.len() as u32, + regs.as_ptr(), + 5000, // > MAX_REGISTRATIONS (4096) + ) + }; + assert!(rr.is_null(), "expected null result for nregistrations > MAX_REGISTRATIONS"); + + unsafe { sandlock_sandbox_free(policy); } +} + #[test] fn run_with_handlers_empty_registrations_runs_normally() { use sandlock_ffi::*; @@ -1477,8 +1575,13 @@ fn run_with_handlers_two_handlers_each_fires_for_own_syscall() { let stdout_str = String::from_utf8_lossy(&stdout); let stderr_str = String::from_utf8_lossy(&stderr); let exit_code = unsafe { sandlock_result_exit_code(rr) }; - assert!( - stdout_str.contains("111") && stdout_str.contains("222"), + // The child writes exactly `getpid|getppid` with `sys.stdout.write` + // — no trailing newline. Exact-match catches mutations where one + // handler silently fails but the real pid/ppid still contains the + // sentinel substring. + assert_eq!( + stdout_str.trim_end_matches('\n'), + "111|222", "expected both handlers to fire; exit={} stdout={:?} stderr={:?}", exit_code, stdout_str, stderr_str, );