Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/sandlock-ffi/cbindgen.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
70 changes: 70 additions & 0 deletions crates/sandlock-ffi/include/sandlock.h
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
*/
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
*
Expand Down
134 changes: 133 additions & 1 deletion crates/sandlock-ffi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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]
Expand All @@ -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]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
27 changes: 23 additions & 4 deletions go/sandbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions go/sandlock_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions go/sandlock_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion python/src/sandlock/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -40,6 +40,7 @@
"Stage",
"Pipeline",
"Result",
"ExitReason",
"SyscallEvent",
"PolicyContext",
"Checkpoint",
Expand Down
Loading
Loading