Skip to content

Commit c4dfd04

Browse files
dzerikcongwang-mk
authored andcommitted
feat: surface the terminating reason (timeout/signal/kill) through the C ABI and SDKs (#131)
sandlock_result_exit_code flattens Signal/Killed/Timeout to -1, so a caller can't tell a timeout from a signal from a normal exit. Add a systemd-style reason taxonomy — deliberately WITHOUT an oom-kill reason: Linux bottoms both a timeout and an OOM kill out in SIGKILL, and telling OOM apart needs cgroup v2 memory.events introspection, so KILLED (SIGKILL, no recoverable number) and TIMEOUT (sandlock-enforced) are the honest split we can report. - C ABI: `sandlock_exit_reason` enum { EXITED, SIGNALED, KILLED, TIMEOUT } (#[repr(u32)]) + `sandlock_result_reason`/`_signal` and the dry-run pair. - Python: `ExitReason` IntEnum + `Result.reason`/`.signal` (and DryRunResult), wired into every native-result path (run/wait/spawn/reduce/Process.wait/ Pipeline/GatherPipeline/dry_run); the fragile `exit_code == -1` timeout heuristic is replaced by `reason == TIMEOUT`. reason defaults to None on the error paths that never produced a native result. - Go: `ExitReason` + `Result.Reason`/`.Signal` via readResult and DryRun. - Tests: Python + Go cover exited/timeout/signaled(SIGTERM)/killed(SIGKILL); a Rust FFI unit test pins all four ExitStatus variants incl. KILLED and the null-result contract.
1 parent b351fe4 commit c4dfd04

10 files changed

Lines changed: 403 additions & 16 deletions

File tree

crates/sandlock-ffi/cbindgen.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ exclude = [
7373
# than the verbose SANDLOCK_EXCEPTION_POLICY_T_* / SANDLOCK_ACTION_KIND_T_*.
7474
"sandlock_exception_policy_t" = "sandlock_exception"
7575
"sandlock_action_kind_t" = "sandlock_action"
76+
"sandlock_exit_reason_t" = "sandlock_exit_reason"
7677

7778
[enum]
7879
rename_variants = "ScreamingSnakeCase"

crates/sandlock-ffi/include/sandlock.h

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,39 @@ typedef struct sandlock_handler_t sandlock_handler_t;
3434
*/
3535
#define SANDLOCK_INJECT_NO_CLOEXEC (1 << 1)
3636

37+
/**
38+
* Why a sandboxed process terminated. `EXITED` carries an exit code
39+
* (`sandlock_result_exit_code`); `SIGNALED` carries the signal number
40+
* (`sandlock_result_signal`). Linux bottoms both a timeout and an OOM kill out
41+
* in `SIGKILL`, so there is no distinct OOM reason: a timeout sandlock enforced
42+
* is `TIMEOUT`, any other kill is `KILLED`.
43+
*/
44+
enum sandlock_exit_reason
45+
#ifdef __cplusplus
46+
: uint32_t
47+
#endif // __cplusplus
48+
{
49+
/**
50+
* Exited normally with a code (`sandlock_result_exit_code`).
51+
*/
52+
SANDLOCK_EXIT_REASON_EXITED = 0,
53+
/**
54+
* Terminated by a signal (`sandlock_result_signal`).
55+
*/
56+
SANDLOCK_EXIT_REASON_SIGNALED = 1,
57+
/**
58+
* Killed with no recoverable signal number.
59+
*/
60+
SANDLOCK_EXIT_REASON_KILLED = 2,
61+
/**
62+
* Killed by sandlock because it exceeded its timeout.
63+
*/
64+
SANDLOCK_EXIT_REASON_TIMEOUT = 3,
65+
};
66+
#ifndef __cplusplus
67+
typedef uint32_t sandlock_exit_reason;
68+
#endif // __cplusplus
69+
3770
/**
3871
* Tag distinguishing payload variants of `sandlock_action_out_t`.
3972
*/
@@ -807,6 +840,25 @@ int sandlock_run_interactive(const sandlock_sandbox_t *policy,
807840
*/
808841
int sandlock_result_exit_code(const sandlock_result_t *r);
809842

843+
/**
844+
* Terminating reason (normal exit / signal / kill / timeout). Pair with
845+
* `sandlock_result_exit_code` (for `EXITED`) and `sandlock_result_signal`
846+
* (for `SIGNALED`). Returns `KILLED` for a null result.
847+
*
848+
* # Safety
849+
* `r` must be null or a valid result pointer.
850+
*/
851+
sandlock_exit_reason sandlock_result_reason(const sandlock_result_t *r);
852+
853+
/**
854+
* Signal number for a `SIGNALED` result, or `-1` for any other reason
855+
* (including a null result).
856+
*
857+
* # Safety
858+
* `r` must be null or a valid result pointer.
859+
*/
860+
int sandlock_result_signal(const sandlock_result_t *r);
861+
810862
/**
811863
* # Safety
812864
* `r` must be null or a valid result pointer.
@@ -882,6 +934,24 @@ sandlock_dry_run_result_t *sandlock_dry_run(const sandlock_sandbox_t *policy,
882934
*/
883935
int sandlock_dry_run_result_exit_code(const sandlock_dry_run_result_t *r);
884936

937+
/**
938+
* Terminating reason of a dry-run result (parity with
939+
* `sandlock_result_reason`). Returns `KILLED` for a null result.
940+
*
941+
* # Safety
942+
* `r` must be null or a valid dry-run result pointer.
943+
*/
944+
sandlock_exit_reason sandlock_dry_run_result_reason(const sandlock_dry_run_result_t *r);
945+
946+
/**
947+
* Signal number for a `SIGNALED` dry-run result, or `-1` otherwise (parity
948+
* with `sandlock_result_signal`).
949+
*
950+
* # Safety
951+
* `r` must be null or a valid dry-run result pointer.
952+
*/
953+
int sandlock_dry_run_result_signal(const sandlock_dry_run_result_t *r);
954+
885955
/**
886956
* Check if the dry-run result indicates success.
887957
*

crates/sandlock-ffi/src/lib.rs

Lines changed: 133 additions & 1 deletion
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, StdioMode};
13+
use sandlock_core::{ExitStatus, Protection, RunResult, Sandbox, StdioMode};
1414

1515
pub mod handler;
1616
pub mod notif_repr;
@@ -1425,6 +1425,43 @@ pub unsafe extern "C" fn sandlock_run_interactive(
14251425
// Result accessors
14261426
// ----------------------------------------------------------------
14271427

1428+
/// Why a sandboxed process terminated. `EXITED` carries an exit code
1429+
/// (`sandlock_result_exit_code`); `SIGNALED` carries the signal number
1430+
/// (`sandlock_result_signal`). Linux bottoms both a timeout and an OOM kill out
1431+
/// in `SIGKILL`, so there is no distinct OOM reason: a timeout sandlock enforced
1432+
/// is `TIMEOUT`, any other kill is `KILLED`.
1433+
// `#[repr(u32)]` (not `#[repr(C)]`) pins the discriminant to a `uint32_t`,
1434+
// matching the sibling FFI enums and the Go (`uint32`) / Python (`c_uint`)
1435+
// bindings; a bare C enum's width is implementation-defined (`-fshort-enums`).
1436+
#[allow(non_camel_case_types)]
1437+
#[repr(u32)]
1438+
pub enum sandlock_exit_reason_t {
1439+
/// Exited normally with a code (`sandlock_result_exit_code`).
1440+
Exited = 0,
1441+
/// Terminated by a signal (`sandlock_result_signal`).
1442+
Signaled = 1,
1443+
/// Killed with no recoverable signal number.
1444+
Killed = 2,
1445+
/// Killed by sandlock because it exceeded its timeout.
1446+
Timeout = 3,
1447+
}
1448+
1449+
fn exit_reason(status: &ExitStatus) -> sandlock_exit_reason_t {
1450+
match status {
1451+
ExitStatus::Code(_) => sandlock_exit_reason_t::Exited,
1452+
ExitStatus::Signal(_) => sandlock_exit_reason_t::Signaled,
1453+
ExitStatus::Killed => sandlock_exit_reason_t::Killed,
1454+
ExitStatus::Timeout => sandlock_exit_reason_t::Timeout,
1455+
}
1456+
}
1457+
1458+
fn exit_signal(status: &ExitStatus) -> c_int {
1459+
match status {
1460+
ExitStatus::Signal(n) => *n,
1461+
_ => -1,
1462+
}
1463+
}
1464+
14281465
/// # Safety
14291466
/// `r` must be null or a valid result pointer.
14301467
#[no_mangle]
@@ -1435,6 +1472,35 @@ pub unsafe extern "C" fn sandlock_result_exit_code(r: *const sandlock_result_t)
14351472
(*r)._private.code().unwrap_or(-1)
14361473
}
14371474

1475+
/// Terminating reason (normal exit / signal / kill / timeout). Pair with
1476+
/// `sandlock_result_exit_code` (for `EXITED`) and `sandlock_result_signal`
1477+
/// (for `SIGNALED`). Returns `KILLED` for a null result.
1478+
///
1479+
/// # Safety
1480+
/// `r` must be null or a valid result pointer.
1481+
#[no_mangle]
1482+
pub unsafe extern "C" fn sandlock_result_reason(
1483+
r: *const sandlock_result_t,
1484+
) -> sandlock_exit_reason_t {
1485+
if r.is_null() {
1486+
return sandlock_exit_reason_t::Killed;
1487+
}
1488+
exit_reason(&(*r)._private.exit_status)
1489+
}
1490+
1491+
/// Signal number for a `SIGNALED` result, or `-1` for any other reason
1492+
/// (including a null result).
1493+
///
1494+
/// # Safety
1495+
/// `r` must be null or a valid result pointer.
1496+
#[no_mangle]
1497+
pub unsafe extern "C" fn sandlock_result_signal(r: *const sandlock_result_t) -> c_int {
1498+
if r.is_null() {
1499+
return -1;
1500+
}
1501+
exit_signal(&(*r)._private.exit_status)
1502+
}
1503+
14381504
/// # Safety
14391505
/// `r` must be null or a valid result pointer.
14401506
#[no_mangle]
@@ -1615,6 +1681,36 @@ pub unsafe extern "C" fn sandlock_dry_run_result_exit_code(
16151681
(*r)._private.run_result.code().unwrap_or(-1) as c_int
16161682
}
16171683

1684+
/// Terminating reason of a dry-run result (parity with
1685+
/// `sandlock_result_reason`). Returns `KILLED` for a null result.
1686+
///
1687+
/// # Safety
1688+
/// `r` must be null or a valid dry-run result pointer.
1689+
#[no_mangle]
1690+
pub unsafe extern "C" fn sandlock_dry_run_result_reason(
1691+
r: *const sandlock_dry_run_result_t,
1692+
) -> sandlock_exit_reason_t {
1693+
if r.is_null() {
1694+
return sandlock_exit_reason_t::Killed;
1695+
}
1696+
exit_reason(&(*r)._private.run_result.exit_status)
1697+
}
1698+
1699+
/// Signal number for a `SIGNALED` dry-run result, or `-1` otherwise (parity
1700+
/// with `sandlock_result_signal`).
1701+
///
1702+
/// # Safety
1703+
/// `r` must be null or a valid dry-run result pointer.
1704+
#[no_mangle]
1705+
pub unsafe extern "C" fn sandlock_dry_run_result_signal(
1706+
r: *const sandlock_dry_run_result_t,
1707+
) -> c_int {
1708+
if r.is_null() {
1709+
return -1;
1710+
}
1711+
exit_signal(&(*r)._private.run_result.exit_status)
1712+
}
1713+
16181714
/// Check if the dry-run result indicates success.
16191715
///
16201716
/// # Safety
@@ -2737,6 +2833,42 @@ mod tests {
27372833
use super::policy_ret_to_verdict;
27382834
use sandlock_core::policy_fn::Verdict;
27392835

2836+
use super::{
2837+
exit_reason, exit_signal, sandlock_dry_run_result_reason, sandlock_dry_run_result_signal,
2838+
sandlock_exit_reason_t, sandlock_result_reason, sandlock_result_signal,
2839+
};
2840+
use sandlock_core::ExitStatus;
2841+
2842+
#[test]
2843+
fn exit_reason_maps_every_exit_status() {
2844+
assert!(matches!(exit_reason(&ExitStatus::Code(0)), sandlock_exit_reason_t::Exited));
2845+
assert!(matches!(exit_reason(&ExitStatus::Signal(15)), sandlock_exit_reason_t::Signaled));
2846+
// SIGKILL folds into Killed in core, so KILLED is a distinct reason with
2847+
// no recoverable signal — the "systemd-style minus oom-kill" boundary.
2848+
assert!(matches!(exit_reason(&ExitStatus::Killed), sandlock_exit_reason_t::Killed));
2849+
assert!(matches!(exit_reason(&ExitStatus::Timeout), sandlock_exit_reason_t::Timeout));
2850+
assert_eq!(exit_signal(&ExitStatus::Signal(15)), 15);
2851+
for s in [ExitStatus::Code(7), ExitStatus::Killed, ExitStatus::Timeout] {
2852+
assert_eq!(exit_signal(&s), -1, "only Signal(n) yields a number");
2853+
}
2854+
}
2855+
2856+
#[test]
2857+
fn null_result_reason_is_killed_and_signal_minus_one() {
2858+
unsafe {
2859+
assert!(matches!(
2860+
sandlock_result_reason(std::ptr::null()),
2861+
sandlock_exit_reason_t::Killed
2862+
));
2863+
assert_eq!(sandlock_result_signal(std::ptr::null()), -1);
2864+
assert!(matches!(
2865+
sandlock_dry_run_result_reason(std::ptr::null()),
2866+
sandlock_exit_reason_t::Killed
2867+
));
2868+
assert_eq!(sandlock_dry_run_result_signal(std::ptr::null()), -1);
2869+
}
2870+
}
2871+
27402872
#[test]
27412873
fn policy_ret_maps_documented_values() {
27422874
assert_eq!(policy_ret_to_verdict(0), Verdict::Allow);

go/sandbox.go

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -277,12 +277,31 @@ type Sandbox struct {
277277
PolicyFn PolicyFunc
278278
}
279279

280+
// ExitReason is why a sandboxed process terminated. It mirrors the C
281+
// sandlock_exit_reason enum. Linux bottoms both a timeout and an OOM kill out in
282+
// SIGKILL, so there is no distinct OOM reason: a timeout sandlock enforced is
283+
// ReasonTimeout, any other kill is ReasonKilled.
284+
type ExitReason uint32
285+
286+
const (
287+
// ReasonExited: exited normally with a code (Result.ExitCode).
288+
ReasonExited ExitReason = 0
289+
// ReasonSignaled: terminated by a signal (Result.Signal).
290+
ReasonSignaled ExitReason = 1
291+
// ReasonKilled: killed with no recoverable signal number.
292+
ReasonKilled ExitReason = 2
293+
// ReasonTimeout: killed by sandlock because it exceeded its timeout.
294+
ReasonTimeout ExitReason = 3
295+
)
296+
280297
// Result is the outcome of a captured run.
281298
type Result struct {
282-
ExitCode int // process exit code, or -1 if terminated abnormally
283-
Success bool // true when the process exited 0
284-
Stdout []byte // captured standard output
285-
Stderr []byte // captured standard error
299+
ExitCode int // process exit code, or -1 if terminated abnormally
300+
Reason ExitReason // why the process terminated (exit / signal / kill / timeout)
301+
Signal int // signal number for a ReasonSignaled result, else -1
302+
Success bool // true when the process exited 0
303+
Stdout []byte // captured standard output
304+
Stderr []byte // captured standard error
286305
}
287306

288307
// StdioMode selects how one of a Popen'd process's standard streams is wired.

go/sandlock_linux.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,8 @@ func timeoutMs(ctx context.Context) C.uint64_t {
589589
func readResult(r *C.sandlock_result_t) *Result {
590590
res := &Result{
591591
ExitCode: int(C.sandlock_result_exit_code(r)),
592+
Reason: ExitReason(C.sandlock_result_reason(r)),
593+
Signal: int(C.sandlock_result_signal(r)),
592594
Success: bool(C.sandlock_result_success(r)),
593595
}
594596
res.Stdout = readBytes(r, true)
@@ -717,6 +719,8 @@ func (s *Sandbox) DryRun(ctx context.Context, cmd ...string) (*DryRunResult, err
717719

718720
out := &DryRunResult{Result: Result{
719721
ExitCode: int(C.sandlock_dry_run_result_exit_code(r)),
722+
Reason: ExitReason(C.sandlock_dry_run_result_reason(r)),
723+
Signal: int(C.sandlock_dry_run_result_signal(r)),
720724
Success: bool(C.sandlock_dry_run_result_success(r)),
721725
}}
722726
var n C.uintptr_t

go/sandlock_linux_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,60 @@ func TestRunExitCode(t *testing.T) {
8080
}
8181
}
8282

83+
// TestExitReason pins the #131 terminating-reason surface across the four
84+
// ExitStatus variants. SIGKILL folds into ReasonKilled in core (no signal
85+
// number), distinct from a catchable signal (ReasonSignaled + number); a context
86+
// deadline enforced by sandlock is ReasonTimeout.
87+
func TestExitReason(t *testing.T) {
88+
requireLandlock(t)
89+
90+
t.Run("exited", func(t *testing.T) {
91+
sb := &sandlock.Sandbox{FSReadable: rootfs}
92+
res, err := sb.Run(context.Background(), "sh", "-c", "exit 7")
93+
if err != nil {
94+
t.Fatalf("Run: %v", err)
95+
}
96+
if res.Reason != sandlock.ReasonExited || res.ExitCode != 7 || res.Signal != -1 {
97+
t.Fatalf("got reason=%d exit=%d signal=%d, want Exited/7/-1", res.Reason, res.ExitCode, res.Signal)
98+
}
99+
})
100+
101+
t.Run("timeout", func(t *testing.T) {
102+
sb := &sandlock.Sandbox{FSReadable: rootfs}
103+
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
104+
defer cancel()
105+
res, err := sb.Run(ctx, "sleep", "60")
106+
if err != nil {
107+
t.Fatalf("Run: %v", err)
108+
}
109+
if res.Reason != sandlock.ReasonTimeout || res.Success {
110+
t.Fatalf("got reason=%d success=%v, want ReasonTimeout / not success", res.Reason, res.Success)
111+
}
112+
})
113+
114+
t.Run("signaled", func(t *testing.T) {
115+
sb := &sandlock.Sandbox{FSReadable: rootfs}
116+
res, err := sb.Run(context.Background(), "sh", "-c", "kill -TERM $$")
117+
if err != nil {
118+
t.Fatalf("Run: %v", err)
119+
}
120+
if res.Reason != sandlock.ReasonSignaled || res.Signal != 15 {
121+
t.Fatalf("got reason=%d signal=%d, want ReasonSignaled/15", res.Reason, res.Signal)
122+
}
123+
})
124+
125+
t.Run("killed", func(t *testing.T) {
126+
sb := &sandlock.Sandbox{FSReadable: rootfs}
127+
res, err := sb.Run(context.Background(), "sh", "-c", "kill -KILL $$")
128+
if err != nil {
129+
t.Fatalf("Run: %v", err)
130+
}
131+
if res.Reason != sandlock.ReasonKilled || res.Signal != -1 {
132+
t.Fatalf("got reason=%d signal=%d, want ReasonKilled/-1", res.Reason, res.Signal)
133+
}
134+
})
135+
}
136+
83137
func TestRunEmptyCommand(t *testing.T) {
84138
sb := &sandlock.Sandbox{}
85139
if _, err := sb.Run(context.Background()); err == nil {

python/src/sandlock/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
from ._version import __version__
99
from ._sdk import (
10-
Stage, Pipeline, Result, SyscallEvent, PolicyContext, Checkpoint, SkippedFd,
10+
Stage, Pipeline, Result, ExitReason, SyscallEvent, PolicyContext, Checkpoint, SkippedFd,
1111
NamedStage, Gather, GatherPipeline,
1212
Protection,
1313
landlock_abi_version, min_landlock_abi, confine,
@@ -40,6 +40,7 @@
4040
"Stage",
4141
"Pipeline",
4242
"Result",
43+
"ExitReason",
4344
"SyscallEvent",
4445
"PolicyContext",
4546
"Checkpoint",

0 commit comments

Comments
 (0)