Skip to content

Commit c043845

Browse files
committed
feat(ffi): C ABI for streaming-stdio popen — sandlock_popen + handle_kill
Second piece of the streaming-stdio process API (RFC #67): expose the core Sandbox::popen over the C ABI so the Python/Go bindings can drive a confined process's stdio while it runs. The Python/Go wrapper follows as the next PR in the series; this lands the C surface it builds on. - sandlock_popen(policy, name, argv, argc, stdin_mode, stdout_mode, stderr_mode, out_stdin_fd, out_stdout_fd, out_stderr_fd): create+start a live handle with per-stream StdioMode (0=inherit, 1=piped, 2=null). Each piped stream's owned fd is returned through its out pointer; inherit/null yield -1. Uses the multi-threaded live runtime so the supervisor keeps pumping while the caller blocks on the fds. - sandlock_handle_kill: SIGKILL the handle's process group without freeing the handle, so the caller can still collect the exit status via sandlock_handle_wait. - Existing sandlock_handle_wait/_pid/_free already cover popen handles; their safety docs now name sandlock_popen as a second handle source. Fail-loud / no-leak (the binding boundary): an unknown StdioMode returns a null handle with every out fd left -1 and logs the rejected discriminant to stderr (not swallowed into an indistinguishable null); a piped fd whose out pointer is null is closed rather than leaked. The owned fds are converted to raw ints only on the infallible side of the result match, so an error return drops and closes them instead of leaking a dangling fd. Because the FFI always takes each piped stream, the core wait()-time stdin EOF safety net cannot fire here, so the deadlock warning (close a piped stdin and drain piped stdout/stderr before wait) is carried explicitly on the sandlock_popen and sandlock_handle_wait docs. Header regenerated with cbindgen (additive only, doc-only delta). Tests drive the symbols directly: stdout streaming + exit, stdin/stdout roundtrip through cat, stderr piped, Null yields no fd, unknown mode rejected with out fds reset, the kill lifecycle (kill->wait->free, kill->free without wait, idempotent kill), and a destructive fd-leak test (PIPED stream with a null out pointer must close its fd — verified to fail if the close branch regresses). Refs #67
1 parent e7f2993 commit c043845

3 files changed

Lines changed: 624 additions & 5 deletions

File tree

crates/sandlock-ffi/include/sandlock.h

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -684,6 +684,42 @@ sandlock_handle_t *sandlock_create_for_run(const sandlock_sandbox_t *policy,
684684
const char *const *argv,
685685
unsigned int argc);
686686

687+
/**
688+
* Spawn a confined process with per-stream stdio wiring and return a live
689+
* handle (the streaming counterpart of `sandlock_create` + `sandlock_start`).
690+
*
691+
* `stdin_mode`/`stdout_mode`/`stderr_mode` are `StdioMode` discriminants
692+
* (0=inherit, 1=piped, 2=null). For each stream set to *piped*, the matching
693+
* `out_*_fd` receives an owned fd the caller must `close()`; for inherit/null
694+
* it receives -1. Pass null for an `out_*_fd` to discard that fd (it is closed
695+
* rather than leaked). Returns null on error (unknown mode, build/fork
696+
* failure) with every `out_*_fd` left -1.
697+
*
698+
* The handle uses a multi-threaded runtime so the seccomp supervisor keeps
699+
* pumping while the caller does blocking IO on the returned fds. Wait for the
700+
* process with `sandlock_handle_wait`, terminate with `sandlock_handle_kill`,
701+
* and release with `sandlock_handle_free`.
702+
*
703+
* Deadlock warning: a piped fd is yours once returned — `close()` a piped
704+
* `out_stdin_fd` *before* `sandlock_handle_wait`, or a child that reads to EOF
705+
* (e.g. `cat`) never exits and the wait blocks forever. Likewise drain a piped
706+
* `out_stdout_fd`/`out_stderr_fd` before waiting: a child that fills the pipe
707+
* buffer blocks on write and never reaches exit.
708+
*
709+
* # Safety
710+
* As `sandlock_create`; `out_*_fd` must each be null or a valid `*mut c_int`.
711+
*/
712+
sandlock_handle_t *sandlock_popen(const sandlock_sandbox_t *policy,
713+
const char *name,
714+
const char *const *argv,
715+
unsigned int argc,
716+
uint32_t stdin_mode,
717+
uint32_t stdout_mode,
718+
uint32_t stderr_mode,
719+
int *out_stdin_fd,
720+
int *out_stdout_fd,
721+
int *out_stderr_fd);
722+
687723
/**
688724
* Release a previously `sandlock_create`d child to execve. Returns 0 on
689725
* success, -1 on error.
@@ -701,11 +737,26 @@ int sandlock_start(sandlock_handle_t *h);
701737
*/
702738
int32_t sandlock_handle_pid(const sandlock_handle_t *h);
703739

740+
/**
741+
* Send SIGKILL to the handle's entire process group. Idempotent: a process
742+
* that already exited is not an error. Returns 0 on success, -1 on error.
743+
* The handle remains valid; call `sandlock_handle_wait` to collect the exit
744+
* status and `sandlock_handle_free` to release it.
745+
*
746+
* # Safety
747+
* `h` must be a valid handle from `sandlock_create` / `sandlock_popen`.
748+
*/
749+
int sandlock_handle_kill(sandlock_handle_t *h);
750+
704751
/**
705752
* Wait for the sandbox to exit. Returns a result handle with stdout/stderr.
706753
*
754+
* For a `sandlock_popen` handle, close a piped `out_stdin_fd` and drain any
755+
* piped `out_stdout_fd`/`out_stderr_fd` *before* calling this, or a child that
756+
* reads to EOF or fills a pipe buffer never exits and this blocks forever.
757+
*
707758
* # Safety
708-
* `h` must be a valid handle from `sandlock_create`.
759+
* `h` must be a valid handle from `sandlock_create` / `sandlock_popen`.
709760
*/
710761
sandlock_result_t *sandlock_handle_wait(sandlock_handle_t *h);
711762

@@ -733,7 +784,7 @@ char *sandlock_handle_port_mappings(const sandlock_handle_t *h);
733784
* Free a sandbox handle. Kills the process if still running.
734785
*
735786
* # Safety
736-
* `h` must be null or a valid handle from `sandlock_create`.
787+
* `h` must be null or a valid handle from `sandlock_create` / `sandlock_popen`.
737788
*/
738789
void sandlock_handle_free(sandlock_handle_t *h);
739790

crates/sandlock-ffi/src/lib.rs

Lines changed: 161 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use std::time::Duration;
1010

1111
use sandlock_core::pipeline::Stage;
1212
use sandlock_core::sandbox::{BranchAction, ByteSize, SandboxBuilder};
13-
use sandlock_core::{Protection, RunResult, Sandbox};
13+
use sandlock_core::{Protection, RunResult, Sandbox, StdioMode};
1414

1515
pub mod handler;
1616
pub mod notif_repr;
@@ -1098,6 +1098,141 @@ pub unsafe extern "C" fn sandlock_create_for_run(
10981098
sandlock_create_with_runtime(policy, name, argv, argc, build_runtime)
10991099
}
11001100

1101+
/// Map a raw `StdioMode` discriminant (0=inherit, 1=piped, 2=null) to the enum.
1102+
/// Returns `None` for any other value so callers can reject it loudly.
1103+
fn stdio_mode_from_raw(v: u32) -> Option<StdioMode> {
1104+
match v {
1105+
0 => Some(StdioMode::Inherit),
1106+
1 => Some(StdioMode::Piped),
1107+
2 => Some(StdioMode::Null),
1108+
_ => None,
1109+
}
1110+
}
1111+
1112+
/// Write `fd` through `out` if non-null; otherwise close it (a piped stream
1113+
/// whose fd the caller did not ask for must not leak).
1114+
unsafe fn write_or_close_fd(out: *mut c_int, fd: c_int) {
1115+
if out.is_null() {
1116+
if fd >= 0 {
1117+
libc::close(fd);
1118+
}
1119+
} else {
1120+
*out = fd;
1121+
}
1122+
}
1123+
1124+
/// Spawn a confined process with per-stream stdio wiring and return a live
1125+
/// handle (the streaming counterpart of `sandlock_create` + `sandlock_start`).
1126+
///
1127+
/// `stdin_mode`/`stdout_mode`/`stderr_mode` are `StdioMode` discriminants
1128+
/// (0=inherit, 1=piped, 2=null). For each stream set to *piped*, the matching
1129+
/// `out_*_fd` receives an owned fd the caller must `close()`; for inherit/null
1130+
/// it receives -1. Pass null for an `out_*_fd` to discard that fd (it is closed
1131+
/// rather than leaked). Returns null on error (unknown mode, build/fork
1132+
/// failure) with every `out_*_fd` left -1.
1133+
///
1134+
/// The handle uses a multi-threaded runtime so the seccomp supervisor keeps
1135+
/// pumping while the caller does blocking IO on the returned fds. Wait for the
1136+
/// process with `sandlock_handle_wait`, terminate with `sandlock_handle_kill`,
1137+
/// and release with `sandlock_handle_free`.
1138+
///
1139+
/// Deadlock warning: a piped fd is yours once returned — `close()` a piped
1140+
/// `out_stdin_fd` *before* `sandlock_handle_wait`, or a child that reads to EOF
1141+
/// (e.g. `cat`) never exits and the wait blocks forever. Likewise drain a piped
1142+
/// `out_stdout_fd`/`out_stderr_fd` before waiting: a child that fills the pipe
1143+
/// buffer blocks on write and never reaches exit.
1144+
///
1145+
/// # Safety
1146+
/// As `sandlock_create`; `out_*_fd` must each be null or a valid `*mut c_int`.
1147+
#[no_mangle]
1148+
pub unsafe extern "C" fn sandlock_popen(
1149+
policy: *const sandlock_sandbox_t,
1150+
name: *const c_char,
1151+
argv: *const *const c_char,
1152+
argc: c_uint,
1153+
stdin_mode: u32,
1154+
stdout_mode: u32,
1155+
stderr_mode: u32,
1156+
out_stdin_fd: *mut c_int,
1157+
out_stdout_fd: *mut c_int,
1158+
out_stderr_fd: *mut c_int,
1159+
) -> *mut sandlock_handle_t {
1160+
use std::os::fd::IntoRawFd;
1161+
1162+
if !out_stdin_fd.is_null() {
1163+
*out_stdin_fd = -1;
1164+
}
1165+
if !out_stdout_fd.is_null() {
1166+
*out_stdout_fd = -1;
1167+
}
1168+
if !out_stderr_fd.is_null() {
1169+
*out_stderr_fd = -1;
1170+
}
1171+
1172+
if policy.is_null() || argv.is_null() {
1173+
return ptr::null_mut();
1174+
}
1175+
let (stdin, stdout, stderr) = match (
1176+
stdio_mode_from_raw(stdin_mode),
1177+
stdio_mode_from_raw(stdout_mode),
1178+
stdio_mode_from_raw(stderr_mode),
1179+
) {
1180+
(Some(a), Some(b), Some(c)) => (a, b, c),
1181+
_ => {
1182+
// Fail loud, not into an indistinguishable NULL: name the offending
1183+
// discriminant(s) so the binding author sees why the handle is null.
1184+
eprintln!(
1185+
"sandlock: sandlock_popen rejected an unknown StdioMode \
1186+
(stdin={stdin_mode}, stdout={stdout_mode}, stderr={stderr_mode}); \
1187+
valid values are 0=inherit, 1=piped, 2=null"
1188+
);
1189+
return ptr::null_mut();
1190+
}
1191+
};
1192+
let policy = &(*policy)._private;
1193+
let name = match optional_name(name) {
1194+
Ok(name) => name,
1195+
Err(_) => return ptr::null_mut(),
1196+
};
1197+
let args = read_argv(argv, argc);
1198+
let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
1199+
1200+
let rt = match build_live_runtime() {
1201+
Some(rt) => rt,
1202+
None => return ptr::null_mut(),
1203+
};
1204+
let mut sb = match name {
1205+
Some(ref n) => policy.clone().with_name(n.clone()),
1206+
None => policy.clone(),
1207+
};
1208+
1209+
// popen returns a Process borrowing `sb`; take the owned fds and let it drop
1210+
// so `sb` can move into the handle. The process keeps running (owned by `sb`).
1211+
// Return OwnedFds (not raw ints) from the future: on the Err/None branch
1212+
// below they drop-close themselves instead of leaking a dangling raw fd.
1213+
let owned = block_on_runtime(&rt, async {
1214+
let mut proc = sb.popen(&arg_refs, stdin, stdout, stderr).await?;
1215+
Ok::<_, sandlock_core::SandlockError>((
1216+
proc.take_stdin(),
1217+
proc.take_stdout(),
1218+
proc.take_stderr(),
1219+
))
1220+
});
1221+
let (in_fd, out_fd, err_fd) = match owned {
1222+
Some(Ok(t)) => t,
1223+
_ => return ptr::null_mut(),
1224+
};
1225+
// Convert to raw only on the infallible side of the match.
1226+
write_or_close_fd(out_stdin_fd, in_fd.map(|f| f.into_raw_fd()).unwrap_or(-1));
1227+
write_or_close_fd(out_stdout_fd, out_fd.map(|f| f.into_raw_fd()).unwrap_or(-1));
1228+
write_or_close_fd(out_stderr_fd, err_fd.map(|f| f.into_raw_fd()).unwrap_or(-1));
1229+
1230+
Box::into_raw(Box::new(sandlock_handle_t {
1231+
sandbox: sb,
1232+
runtime: rt,
1233+
}))
1234+
}
1235+
11011236
/// Release a previously `sandlock_create`d child to execve. Returns 0 on
11021237
/// success, -1 on error.
11031238
///
@@ -1127,10 +1262,33 @@ pub unsafe extern "C" fn sandlock_handle_pid(h: *const sandlock_handle_t) -> i32
11271262
(*h).sandbox.pid().unwrap_or(0)
11281263
}
11291264

1265+
/// Send SIGKILL to the handle's entire process group. Idempotent: a process
1266+
/// that already exited is not an error. Returns 0 on success, -1 on error.
1267+
/// The handle remains valid; call `sandlock_handle_wait` to collect the exit
1268+
/// status and `sandlock_handle_free` to release it.
1269+
///
1270+
/// # Safety
1271+
/// `h` must be a valid handle from `sandlock_create` / `sandlock_popen`.
1272+
#[no_mangle]
1273+
pub unsafe extern "C" fn sandlock_handle_kill(h: *mut sandlock_handle_t) -> c_int {
1274+
if h.is_null() {
1275+
return -1;
1276+
}
1277+
let h = &mut *h;
1278+
if h.sandbox.kill().is_err() {
1279+
return -1;
1280+
}
1281+
0
1282+
}
1283+
11301284
/// Wait for the sandbox to exit. Returns a result handle with stdout/stderr.
11311285
///
1286+
/// For a `sandlock_popen` handle, close a piped `out_stdin_fd` and drain any
1287+
/// piped `out_stdout_fd`/`out_stderr_fd` *before* calling this, or a child that
1288+
/// reads to EOF or fills a pipe buffer never exits and this blocks forever.
1289+
///
11321290
/// # Safety
1133-
/// `h` must be a valid handle from `sandlock_create`.
1291+
/// `h` must be a valid handle from `sandlock_create` / `sandlock_popen`.
11341292
#[no_mangle]
11351293
pub unsafe extern "C" fn sandlock_handle_wait(h: *mut sandlock_handle_t) -> *mut sandlock_result_t {
11361294
if h.is_null() {
@@ -1211,7 +1369,7 @@ pub unsafe extern "C" fn sandlock_handle_port_mappings(h: *const sandlock_handle
12111369
/// Free a sandbox handle. Kills the process if still running.
12121370
///
12131371
/// # Safety
1214-
/// `h` must be null or a valid handle from `sandlock_create`.
1372+
/// `h` must be null or a valid handle from `sandlock_create` / `sandlock_popen`.
12151373
#[no_mangle]
12161374
pub unsafe extern "C" fn sandlock_handle_free(h: *mut sandlock_handle_t) {
12171375
if !h.is_null() {

0 commit comments

Comments
 (0)