diff --git a/crates/sandlock-ffi/cbindgen.toml b/crates/sandlock-ffi/cbindgen.toml index 5fbdc6f1..042a958a 100644 --- a/crates/sandlock-ffi/cbindgen.toml +++ b/crates/sandlock-ffi/cbindgen.toml @@ -73,6 +73,7 @@ exclude = [ # than the verbose SANDLOCK_EXCEPTION_POLICY_T_* / SANDLOCK_ACTION_KIND_T_*. "sandlock_exception_policy_t" = "sandlock_exception" "sandlock_action_kind_t" = "sandlock_action" +"sandlock_exit_reason_t" = "sandlock_exit_reason" [enum] rename_variants = "ScreamingSnakeCase" diff --git a/crates/sandlock-ffi/include/sandlock.h b/crates/sandlock-ffi/include/sandlock.h index caee7c88..81378b6d 100644 --- a/crates/sandlock-ffi/include/sandlock.h +++ b/crates/sandlock-ffi/include/sandlock.h @@ -34,6 +34,39 @@ typedef struct sandlock_handler_t sandlock_handler_t; */ #define SANDLOCK_INJECT_NO_CLOEXEC (1 << 1) +/** + * Why a sandboxed process terminated. `EXITED` carries an exit code + * (`sandlock_result_exit_code`); `SIGNALED` carries the signal number + * (`sandlock_result_signal`). Linux bottoms both a timeout and an OOM kill out + * in `SIGKILL`, so there is no distinct OOM reason: a timeout sandlock enforced + * is `TIMEOUT`, any other kill is `KILLED`. + */ +enum sandlock_exit_reason +#ifdef __cplusplus + : uint32_t +#endif // __cplusplus + { + /** + * Exited normally with a code (`sandlock_result_exit_code`). + */ + SANDLOCK_EXIT_REASON_EXITED = 0, + /** + * Terminated by a signal (`sandlock_result_signal`). + */ + SANDLOCK_EXIT_REASON_SIGNALED = 1, + /** + * Killed with no recoverable signal number. + */ + SANDLOCK_EXIT_REASON_KILLED = 2, + /** + * Killed by sandlock because it exceeded its timeout. + */ + SANDLOCK_EXIT_REASON_TIMEOUT = 3, +}; +#ifndef __cplusplus +typedef uint32_t sandlock_exit_reason; +#endif // __cplusplus + /** * Tag distinguishing payload variants of `sandlock_action_out_t`. */ @@ -807,6 +840,25 @@ int sandlock_run_interactive(const sandlock_sandbox_t *policy, */ int sandlock_result_exit_code(const sandlock_result_t *r); +/** + * Terminating reason (normal exit / signal / kill / timeout). Pair with + * `sandlock_result_exit_code` (for `EXITED`) and `sandlock_result_signal` + * (for `SIGNALED`). Returns `KILLED` for a null result. + * + * # Safety + * `r` must be null or a valid result pointer. + */ +sandlock_exit_reason sandlock_result_reason(const sandlock_result_t *r); + +/** + * Signal number for a `SIGNALED` result, or `-1` for any other reason + * (including a null result). + * + * # Safety + * `r` must be null or a valid result pointer. + */ +int sandlock_result_signal(const sandlock_result_t *r); + /** * # Safety * `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, */ int sandlock_dry_run_result_exit_code(const sandlock_dry_run_result_t *r); +/** + * Terminating reason of a dry-run result (parity with + * `sandlock_result_reason`). Returns `KILLED` for a null result. + * + * # Safety + * `r` must be null or a valid dry-run result pointer. + */ +sandlock_exit_reason sandlock_dry_run_result_reason(const sandlock_dry_run_result_t *r); + +/** + * Signal number for a `SIGNALED` dry-run result, or `-1` otherwise (parity + * with `sandlock_result_signal`). + * + * # Safety + * `r` must be null or a valid dry-run result pointer. + */ +int sandlock_dry_run_result_signal(const sandlock_dry_run_result_t *r); + /** * Check if the dry-run result indicates success. * diff --git a/crates/sandlock-ffi/src/lib.rs b/crates/sandlock-ffi/src/lib.rs index 31668536..548d5336 100644 --- a/crates/sandlock-ffi/src/lib.rs +++ b/crates/sandlock-ffi/src/lib.rs @@ -10,7 +10,7 @@ use std::time::Duration; use sandlock_core::pipeline::Stage; use sandlock_core::sandbox::{BranchAction, ByteSize, SandboxBuilder}; -use sandlock_core::{Protection, RunResult, Sandbox, StdioMode}; +use sandlock_core::{ExitStatus, Protection, RunResult, Sandbox, StdioMode}; pub mod handler; pub mod notif_repr; @@ -1425,6 +1425,43 @@ pub unsafe extern "C" fn sandlock_run_interactive( // Result accessors // ---------------------------------------------------------------- +/// Why a sandboxed process terminated. `EXITED` carries an exit code +/// (`sandlock_result_exit_code`); `SIGNALED` carries the signal number +/// (`sandlock_result_signal`). Linux bottoms both a timeout and an OOM kill out +/// in `SIGKILL`, so there is no distinct OOM reason: a timeout sandlock enforced +/// is `TIMEOUT`, any other kill is `KILLED`. +// `#[repr(u32)]` (not `#[repr(C)]`) pins the discriminant to a `uint32_t`, +// matching the sibling FFI enums and the Go (`uint32`) / Python (`c_uint`) +// bindings; a bare C enum's width is implementation-defined (`-fshort-enums`). +#[allow(non_camel_case_types)] +#[repr(u32)] +pub enum sandlock_exit_reason_t { + /// Exited normally with a code (`sandlock_result_exit_code`). + Exited = 0, + /// Terminated by a signal (`sandlock_result_signal`). + Signaled = 1, + /// Killed with no recoverable signal number. + Killed = 2, + /// Killed by sandlock because it exceeded its timeout. + Timeout = 3, +} + +fn exit_reason(status: &ExitStatus) -> sandlock_exit_reason_t { + match status { + ExitStatus::Code(_) => sandlock_exit_reason_t::Exited, + ExitStatus::Signal(_) => sandlock_exit_reason_t::Signaled, + ExitStatus::Killed => sandlock_exit_reason_t::Killed, + ExitStatus::Timeout => sandlock_exit_reason_t::Timeout, + } +} + +fn exit_signal(status: &ExitStatus) -> c_int { + match status { + ExitStatus::Signal(n) => *n, + _ => -1, + } +} + /// # Safety /// `r` must be null or a valid result pointer. #[no_mangle] @@ -1435,6 +1472,35 @@ pub unsafe extern "C" fn sandlock_result_exit_code(r: *const sandlock_result_t) (*r)._private.code().unwrap_or(-1) } +/// Terminating reason (normal exit / signal / kill / timeout). Pair with +/// `sandlock_result_exit_code` (for `EXITED`) and `sandlock_result_signal` +/// (for `SIGNALED`). Returns `KILLED` for a null result. +/// +/// # Safety +/// `r` must be null or a valid result pointer. +#[no_mangle] +pub unsafe extern "C" fn sandlock_result_reason( + r: *const sandlock_result_t, +) -> sandlock_exit_reason_t { + if r.is_null() { + return sandlock_exit_reason_t::Killed; + } + exit_reason(&(*r)._private.exit_status) +} + +/// Signal number for a `SIGNALED` result, or `-1` for any other reason +/// (including a null result). +/// +/// # Safety +/// `r` must be null or a valid result pointer. +#[no_mangle] +pub unsafe extern "C" fn sandlock_result_signal(r: *const sandlock_result_t) -> c_int { + if r.is_null() { + return -1; + } + exit_signal(&(*r)._private.exit_status) +} + /// # Safety /// `r` must be null or a valid result pointer. #[no_mangle] @@ -1615,6 +1681,36 @@ pub unsafe extern "C" fn sandlock_dry_run_result_exit_code( (*r)._private.run_result.code().unwrap_or(-1) as c_int } +/// Terminating reason of a dry-run result (parity with +/// `sandlock_result_reason`). Returns `KILLED` for a null result. +/// +/// # Safety +/// `r` must be null or a valid dry-run result pointer. +#[no_mangle] +pub unsafe extern "C" fn sandlock_dry_run_result_reason( + r: *const sandlock_dry_run_result_t, +) -> sandlock_exit_reason_t { + if r.is_null() { + return sandlock_exit_reason_t::Killed; + } + exit_reason(&(*r)._private.run_result.exit_status) +} + +/// Signal number for a `SIGNALED` dry-run result, or `-1` otherwise (parity +/// with `sandlock_result_signal`). +/// +/// # Safety +/// `r` must be null or a valid dry-run result pointer. +#[no_mangle] +pub unsafe extern "C" fn sandlock_dry_run_result_signal( + r: *const sandlock_dry_run_result_t, +) -> c_int { + if r.is_null() { + return -1; + } + exit_signal(&(*r)._private.run_result.exit_status) +} + /// Check if the dry-run result indicates success. /// /// # Safety @@ -2737,6 +2833,42 @@ mod tests { use super::policy_ret_to_verdict; use sandlock_core::policy_fn::Verdict; + use super::{ + exit_reason, exit_signal, sandlock_dry_run_result_reason, sandlock_dry_run_result_signal, + sandlock_exit_reason_t, sandlock_result_reason, sandlock_result_signal, + }; + use sandlock_core::ExitStatus; + + #[test] + fn exit_reason_maps_every_exit_status() { + assert!(matches!(exit_reason(&ExitStatus::Code(0)), sandlock_exit_reason_t::Exited)); + assert!(matches!(exit_reason(&ExitStatus::Signal(15)), sandlock_exit_reason_t::Signaled)); + // SIGKILL folds into Killed in core, so KILLED is a distinct reason with + // no recoverable signal — the "systemd-style minus oom-kill" boundary. + assert!(matches!(exit_reason(&ExitStatus::Killed), sandlock_exit_reason_t::Killed)); + assert!(matches!(exit_reason(&ExitStatus::Timeout), sandlock_exit_reason_t::Timeout)); + assert_eq!(exit_signal(&ExitStatus::Signal(15)), 15); + for s in [ExitStatus::Code(7), ExitStatus::Killed, ExitStatus::Timeout] { + assert_eq!(exit_signal(&s), -1, "only Signal(n) yields a number"); + } + } + + #[test] + fn null_result_reason_is_killed_and_signal_minus_one() { + unsafe { + assert!(matches!( + sandlock_result_reason(std::ptr::null()), + sandlock_exit_reason_t::Killed + )); + assert_eq!(sandlock_result_signal(std::ptr::null()), -1); + assert!(matches!( + sandlock_dry_run_result_reason(std::ptr::null()), + sandlock_exit_reason_t::Killed + )); + assert_eq!(sandlock_dry_run_result_signal(std::ptr::null()), -1); + } + } + #[test] fn policy_ret_maps_documented_values() { assert_eq!(policy_ret_to_verdict(0), Verdict::Allow); diff --git a/go/sandbox.go b/go/sandbox.go index 45bb99cf..57a28e61 100644 --- a/go/sandbox.go +++ b/go/sandbox.go @@ -277,12 +277,31 @@ type Sandbox struct { PolicyFn PolicyFunc } +// ExitReason is why a sandboxed process terminated. It mirrors the C +// sandlock_exit_reason enum. Linux bottoms both a timeout and an OOM kill out in +// SIGKILL, so there is no distinct OOM reason: a timeout sandlock enforced is +// ReasonTimeout, any other kill is ReasonKilled. +type ExitReason uint32 + +const ( + // ReasonExited: exited normally with a code (Result.ExitCode). + ReasonExited ExitReason = 0 + // ReasonSignaled: terminated by a signal (Result.Signal). + ReasonSignaled ExitReason = 1 + // ReasonKilled: killed with no recoverable signal number. + ReasonKilled ExitReason = 2 + // ReasonTimeout: killed by sandlock because it exceeded its timeout. + ReasonTimeout ExitReason = 3 +) + // Result is the outcome of a captured run. type Result struct { - ExitCode int // process exit code, or -1 if terminated abnormally - Success bool // true when the process exited 0 - Stdout []byte // captured standard output - Stderr []byte // captured standard error + ExitCode int // process exit code, or -1 if terminated abnormally + Reason ExitReason // why the process terminated (exit / signal / kill / timeout) + Signal int // signal number for a ReasonSignaled result, else -1 + Success bool // true when the process exited 0 + Stdout []byte // captured standard output + Stderr []byte // captured standard error } // StdioMode selects how one of a Popen'd process's standard streams is wired. diff --git a/go/sandlock_linux.go b/go/sandlock_linux.go index b0b9a71d..b35db46c 100644 --- a/go/sandlock_linux.go +++ b/go/sandlock_linux.go @@ -589,6 +589,8 @@ func timeoutMs(ctx context.Context) C.uint64_t { func readResult(r *C.sandlock_result_t) *Result { res := &Result{ ExitCode: int(C.sandlock_result_exit_code(r)), + Reason: ExitReason(C.sandlock_result_reason(r)), + Signal: int(C.sandlock_result_signal(r)), Success: bool(C.sandlock_result_success(r)), } res.Stdout = readBytes(r, true) @@ -717,6 +719,8 @@ func (s *Sandbox) DryRun(ctx context.Context, cmd ...string) (*DryRunResult, err out := &DryRunResult{Result: Result{ ExitCode: int(C.sandlock_dry_run_result_exit_code(r)), + Reason: ExitReason(C.sandlock_dry_run_result_reason(r)), + Signal: int(C.sandlock_dry_run_result_signal(r)), Success: bool(C.sandlock_dry_run_result_success(r)), }} var n C.uintptr_t diff --git a/go/sandlock_linux_test.go b/go/sandlock_linux_test.go index 9529a19a..cc1acf75 100644 --- a/go/sandlock_linux_test.go +++ b/go/sandlock_linux_test.go @@ -80,6 +80,60 @@ func TestRunExitCode(t *testing.T) { } } +// TestExitReason pins the #131 terminating-reason surface across the four +// ExitStatus variants. SIGKILL folds into ReasonKilled in core (no signal +// number), distinct from a catchable signal (ReasonSignaled + number); a context +// deadline enforced by sandlock is ReasonTimeout. +func TestExitReason(t *testing.T) { + requireLandlock(t) + + t.Run("exited", func(t *testing.T) { + sb := &sandlock.Sandbox{FSReadable: rootfs} + res, err := sb.Run(context.Background(), "sh", "-c", "exit 7") + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.Reason != sandlock.ReasonExited || res.ExitCode != 7 || res.Signal != -1 { + t.Fatalf("got reason=%d exit=%d signal=%d, want Exited/7/-1", res.Reason, res.ExitCode, res.Signal) + } + }) + + t.Run("timeout", func(t *testing.T) { + sb := &sandlock.Sandbox{FSReadable: rootfs} + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + res, err := sb.Run(ctx, "sleep", "60") + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.Reason != sandlock.ReasonTimeout || res.Success { + t.Fatalf("got reason=%d success=%v, want ReasonTimeout / not success", res.Reason, res.Success) + } + }) + + t.Run("signaled", func(t *testing.T) { + sb := &sandlock.Sandbox{FSReadable: rootfs} + res, err := sb.Run(context.Background(), "sh", "-c", "kill -TERM $$") + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.Reason != sandlock.ReasonSignaled || res.Signal != 15 { + t.Fatalf("got reason=%d signal=%d, want ReasonSignaled/15", res.Reason, res.Signal) + } + }) + + t.Run("killed", func(t *testing.T) { + sb := &sandlock.Sandbox{FSReadable: rootfs} + res, err := sb.Run(context.Background(), "sh", "-c", "kill -KILL $$") + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.Reason != sandlock.ReasonKilled || res.Signal != -1 { + t.Fatalf("got reason=%d signal=%d, want ReasonKilled/-1", res.Reason, res.Signal) + } + }) +} + func TestRunEmptyCommand(t *testing.T) { sb := &sandlock.Sandbox{} if _, err := sb.Run(context.Background()); err == nil { diff --git a/python/src/sandlock/__init__.py b/python/src/sandlock/__init__.py index 4579e3cf..80169fe3 100644 --- a/python/src/sandlock/__init__.py +++ b/python/src/sandlock/__init__.py @@ -7,7 +7,7 @@ from ._version import __version__ from ._sdk import ( - Stage, Pipeline, Result, SyscallEvent, PolicyContext, Checkpoint, SkippedFd, + Stage, Pipeline, Result, ExitReason, SyscallEvent, PolicyContext, Checkpoint, SkippedFd, NamedStage, Gather, GatherPipeline, Protection, landlock_abi_version, min_landlock_abi, confine, @@ -40,6 +40,7 @@ "Stage", "Pipeline", "Result", + "ExitReason", "SyscallEvent", "PolicyContext", "Checkpoint", diff --git a/python/src/sandlock/_sdk.py b/python/src/sandlock/_sdk.py index 35d55c70..eee3c413 100644 --- a/python/src/sandlock/_sdk.py +++ b/python/src/sandlock/_sdk.py @@ -340,6 +340,12 @@ def confine(policy: "PolicyDataclass") -> None: _lib.sandlock_result_success.restype = ctypes.c_bool _lib.sandlock_result_success.argtypes = [_c_result_p] +_lib.sandlock_result_reason.restype = ctypes.c_uint # sandlock_exit_reason (repr u32) +_lib.sandlock_result_reason.argtypes = [_c_result_p] + +_lib.sandlock_result_signal.restype = ctypes.c_int +_lib.sandlock_result_signal.argtypes = [_c_result_p] + _lib.sandlock_result_stdout_bytes.restype = ctypes.c_void_p _lib.sandlock_result_stdout_bytes.argtypes = [_c_result_p, ctypes.POINTER(ctypes.c_size_t)] @@ -358,6 +364,12 @@ def confine(policy: "PolicyDataclass") -> None: _lib.sandlock_dry_run_result_exit_code.restype = ctypes.c_int _lib.sandlock_dry_run_result_exit_code.argtypes = [_c_dry_run_p] +_lib.sandlock_dry_run_result_reason.restype = ctypes.c_uint +_lib.sandlock_dry_run_result_reason.argtypes = [_c_dry_run_p] + +_lib.sandlock_dry_run_result_signal.restype = ctypes.c_int +_lib.sandlock_dry_run_result_signal.argtypes = [_c_dry_run_p] + _lib.sandlock_dry_run_result_success.restype = ctypes.c_bool _lib.sandlock_dry_run_result_success.argtypes = [_c_dry_run_p] @@ -748,6 +760,23 @@ def _read_result_bytes(result_p, fn) -> bytes: # Result # ---------------------------------------------------------------- +class ExitReason(IntEnum): + """Why a sandboxed process terminated (mirrors the C ``sandlock_exit_reason``). + + Linux bottoms both a timeout and an OOM kill out in ``SIGKILL``, so there is + no distinct OOM reason: a timeout sandlock enforced is ``TIMEOUT``, any other + kill is ``KILLED``. + """ + EXITED = 0 + """Exited normally with a code (see ``Result.exit_code``).""" + SIGNALED = 1 + """Terminated by a signal (see ``Result.signal``).""" + KILLED = 2 + """Killed with no recoverable signal number.""" + TIMEOUT = 3 + """Killed by sandlock because it exceeded its timeout.""" + + @dataclass class Result: """Result of a sandboxed command.""" @@ -756,6 +785,12 @@ class Result: stdout: bytes = field(default=b"", repr=False) stderr: bytes = field(default=b"", repr=False) error: str | None = None + # Appended after the original fields so positional construction is unchanged. + reason: ExitReason | None = None + """Why the process terminated (timeout / signal / kill / normal exit); + ``None`` on an error raised before a native result was produced.""" + signal: int = -1 + """Signal number for a ``SIGNALED`` result, else ``-1``.""" # ---------------------------------------------------------------- @@ -1368,17 +1403,21 @@ def run(self, timeout: float | None = None) -> Result: exit_code = _lib.sandlock_result_exit_code(result_p) success = _lib.sandlock_result_success(result_p) + reason = ExitReason(_lib.sandlock_result_reason(result_p)) + signal = _lib.sandlock_result_signal(result_p) out_bytes = _read_result_bytes(result_p, _lib.sandlock_result_stdout_bytes) stderr = _read_result_bytes(result_p, _lib.sandlock_result_stderr_bytes) _lib.sandlock_result_free(result_p) error = None - if exit_code == -1 and not success and timeout: + if reason == ExitReason.TIMEOUT: error = "Gather timed out" return Result( success=bool(success), exit_code=exit_code, + reason=reason, + signal=signal, stdout=out_bytes, stderr=stderr, error=error, @@ -1435,6 +1474,8 @@ def run( exit_code = _lib.sandlock_result_exit_code(result_p) success = _lib.sandlock_result_success(result_p) + reason = ExitReason(_lib.sandlock_result_reason(result_p)) + signal = _lib.sandlock_result_signal(result_p) out_bytes = _read_result_bytes(result_p, _lib.sandlock_result_stdout_bytes) stderr = _read_result_bytes(result_p, _lib.sandlock_result_stderr_bytes) _lib.sandlock_result_free(result_p) @@ -1444,14 +1485,15 @@ def run( os.write(stdout, out_bytes) out_bytes = b"" - # Detect timeout (exit_code == -1 from ExitStatus::Timeout) error = None - if exit_code == -1 and not success and timeout: + if reason == ExitReason.TIMEOUT: error = "Pipeline timed out" return Result( success=bool(success), exit_code=exit_code, + reason=reason, + signal=signal, stdout=out_bytes, stderr=stderr, error=error, diff --git a/python/src/sandlock/sandbox.py b/python/src/sandlock/sandbox.py index 9ec9d165..2e0359a1 100644 --- a/python/src/sandlock/sandbox.py +++ b/python/src/sandlock/sandbox.py @@ -17,6 +17,7 @@ if TYPE_CHECKING: from ._notif_policy import NotifPolicy + from ._sdk import ExitReason # DryRunResult.reason annotation (runtime import is circular) # --- Memory size parsing (from branching/process/limits.py) --- @@ -128,6 +129,12 @@ class DryRunResult: stderr: bytes = field(default=b"", repr=False) changes: list = field(default_factory=list) error: str | None = None + # Appended after the original fields so positional construction is unchanged. + reason: "ExitReason | None" = None + """Why the process terminated (parity with ``Result.reason``); ``None`` on an + error raised before a native result was produced.""" + signal: int = -1 + """Signal number for a ``SIGNALED`` result, else ``-1``.""" @dataclass @@ -596,7 +603,7 @@ def run(self, cmd: Sequence[str], timeout: float | None = None): killed and a timeout result is returned if exceeded. None means no timeout. """ - from ._sdk import _lib, _make_argv, _read_result_bytes, Result + from ._sdk import _lib, _make_argv, _read_result_bytes, Result, ExitReason self._check_not_running() @@ -632,6 +639,8 @@ def run(self, cmd: Sequence[str], timeout: float | None = None): exit_code = _lib.sandlock_result_exit_code(result_p) success = _lib.sandlock_result_success(result_p) + reason = ExitReason(_lib.sandlock_result_reason(result_p)) + signal = _lib.sandlock_result_signal(result_p) stdout = _read_result_bytes(result_p, _lib.sandlock_result_stdout_bytes) stderr = _read_result_bytes(result_p, _lib.sandlock_result_stderr_bytes) _lib.sandlock_result_free(result_p) @@ -639,6 +648,8 @@ def run(self, cmd: Sequence[str], timeout: float | None = None): return Result( success=bool(success), exit_code=exit_code, + reason=reason, + signal=signal, stdout=stdout, stderr=stderr, ) @@ -692,6 +703,7 @@ def run_with_handlers( _make_argv, _read_result_bytes, Result, + ExitReason, ) self._check_not_running() @@ -812,6 +824,8 @@ def run_with_handlers( exit_code = _lib.sandlock_result_exit_code(result_p) success = _lib.sandlock_result_success(result_p) + reason = ExitReason(_lib.sandlock_result_reason(result_p)) + signal = _lib.sandlock_result_signal(result_p) stdout = _read_result_bytes(result_p, _lib.sandlock_result_stdout_bytes) stderr = _read_result_bytes(result_p, _lib.sandlock_result_stderr_bytes) _lib.sandlock_result_free(result_p) @@ -819,6 +833,8 @@ def run_with_handlers( return Result( success=bool(success), exit_code=exit_code, + reason=reason, + signal=signal, stdout=stdout, stderr=stderr, ) @@ -886,7 +902,7 @@ def wait(self): :meth:`popen` :class:`Process` (wait on that Process instead — freeing its handle here would break it). """ - from ._sdk import _lib, _read_result_bytes, Result + from ._sdk import _lib, _read_result_bytes, Result, ExitReason self._reject_if_popen() if self._handle is None: @@ -903,6 +919,8 @@ def wait(self): exit_code = _lib.sandlock_result_exit_code(result_p) success = _lib.sandlock_result_success(result_p) + reason = ExitReason(_lib.sandlock_result_reason(result_p)) + signal = _lib.sandlock_result_signal(result_p) stdout = _read_result_bytes(result_p, _lib.sandlock_result_stdout_bytes) stderr = _read_result_bytes(result_p, _lib.sandlock_result_stderr_bytes) _lib.sandlock_result_free(result_p) @@ -910,6 +928,8 @@ def wait(self): return Result( success=bool(success), exit_code=exit_code, + reason=reason, + signal=signal, stdout=stdout, stderr=stderr, ) @@ -1007,7 +1027,7 @@ def dry_run(self, cmd: Sequence[str], timeout: float | None = None) -> "DryRunRe Returns: DryRunResult with exit info and list of filesystem changes. """ - from ._sdk import _lib, _make_argv, _read_result_bytes + from ._sdk import _lib, _make_argv, _read_result_bytes, ExitReason native = self._ensure_native() argv, argc = _make_argv(list(cmd)) @@ -1021,6 +1041,8 @@ def dry_run(self, cmd: Sequence[str], timeout: float | None = None) -> "DryRunRe try: exit_code = _lib.sandlock_dry_run_result_exit_code(result_p) success = _lib.sandlock_dry_run_result_success(result_p) + reason = ExitReason(_lib.sandlock_dry_run_result_reason(result_p)) + signal = _lib.sandlock_dry_run_result_signal(result_p) stdout = _read_result_bytes(result_p, _lib.sandlock_dry_run_result_stdout_bytes) stderr = _read_result_bytes(result_p, _lib.sandlock_dry_run_result_stderr_bytes) @@ -1043,6 +1065,8 @@ def dry_run(self, cmd: Sequence[str], timeout: float | None = None) -> "DryRunRe return DryRunResult( success=bool(success), exit_code=exit_code, + reason=reason, + signal=signal, stdout=stdout, stderr=stderr, changes=changes, @@ -1131,7 +1155,7 @@ def reduce(self, cmd: list[str], fork_result: "ForkResult") -> "Result": clones = mapper.fork(4) result = reducer.reduce(["python3", "sum.py"], clones) """ - from ._sdk import _lib, _make_argv, _read_result_bytes, Result + from ._sdk import _lib, _make_argv, _read_result_bytes, Result, ExitReason if fork_result._ptr is None: return Result(success=False, exit_code=-1, error="no fork result") @@ -1148,6 +1172,8 @@ def reduce(self, cmd: list[str], fork_result: "ForkResult") -> "Result": exit_code = _lib.sandlock_result_exit_code(result_p) success = _lib.sandlock_result_success(result_p) + reason = ExitReason(_lib.sandlock_result_reason(result_p)) + signal = _lib.sandlock_result_signal(result_p) stdout = _read_result_bytes(result_p, _lib.sandlock_result_stdout_bytes) stderr = _read_result_bytes(result_p, _lib.sandlock_result_stderr_bytes) _lib.sandlock_result_free(result_p) @@ -1155,6 +1181,8 @@ def reduce(self, cmd: list[str], fork_result: "ForkResult") -> "Result": return Result( success=bool(success), exit_code=exit_code, + reason=reason, + signal=signal, stdout=stdout, stderr=stderr, ) @@ -1527,7 +1555,7 @@ def wait(self, timeout: float | None = None) -> "Result": ``stdout``/``stderr`` you have not drained can block the child on a full pipe and hang the wait forever; pass a ``timeout`` or drain first. """ - from ._sdk import _lib, Result + from ._sdk import _lib, Result, ExitReason # Reserve the handle under the lock so a concurrent kill()/pid sees a # consistent state, then run the blocking wait WITHOUT the lock so kill() @@ -1575,10 +1603,14 @@ def wait(self, timeout: float | None = None) -> "Result": exit_code = _lib.sandlock_result_exit_code(result_p) success = _lib.sandlock_result_success(result_p) + reason = ExitReason(_lib.sandlock_result_reason(result_p)) + signal = _lib.sandlock_result_signal(result_p) # stdout/stderr were handed to the caller as fds, so the RunResult holds # none — read them off the streams, not the Result. _lib.sandlock_result_free(result_p) - self._result = Result(success=bool(success), exit_code=exit_code) + self._result = Result( + success=bool(success), exit_code=exit_code, reason=reason, signal=signal, + ) return self._result def __enter__(self) -> "Process": diff --git a/python/tests/test_lifecycle.py b/python/tests/test_lifecycle.py index e7f30d24..423c0aa7 100644 --- a/python/tests/test_lifecycle.py +++ b/python/tests/test_lifecycle.py @@ -8,7 +8,7 @@ import pytest -from sandlock import Sandbox +from sandlock import Sandbox, ExitReason from sandlock._sdk import _lib @@ -34,6 +34,38 @@ def _proc_state(pid: int) -> str | None: return None +class TestExitReason: + """The terminating reason (issue #131) is surfaced on Result.""" + + def test_normal_exit_is_exited(self): + result = _policy().run(["sh", "-c", "exit 7"]) + assert result.reason is ExitReason.EXITED + assert result.exit_code == 7 + assert result.signal == -1 + + def test_timeout_is_timeout(self): + # sandlock kills the process when it exceeds the timeout; the reason must + # be TIMEOUT, distinct from a plain signal kill, and never a success. + result = _policy().run(["sleep", "60"], timeout=1) + assert result.reason is ExitReason.TIMEOUT + assert not result.success + + def test_signal_is_signaled_with_number(self): + # A catchable signal (SIGTERM=15) is SIGNALED and carries the signal + # number — not TIMEOUT (sandlock did not initiate the kill). + result = _policy().run(["sh", "-c", "kill -TERM $$"]) + assert result.reason is ExitReason.SIGNALED + assert result.signal == 15 + + def test_sigkill_is_killed_without_number(self): + # SIGKILL folds into KILLED (core sandbox.rs: `sig == SIGKILL => Killed`), + # distinct from SIGNALED, so no signal number is recoverable. This pins + # the "systemd-style minus oom-kill" boundary the C ABI promises. + result = _policy().run(["sh", "-c", "kill -KILL $$"]) + assert result.reason is ExitReason.KILLED + assert result.signal == -1 + + class TestSpawn: def test_spawn_then_wait_returns_result(self): with _policy() as sb: