From 15b09ce7405808e243fdbbd606965cb8d47e1130 Mon Sep 17 00:00:00 2001 From: dzerik Date: Mon, 25 May 2026 18:04:15 +0300 Subject: [PATCH 01/17] core: introduce Protection enum with per-variant ABI floor --- crates/sandlock-core/src/lib.rs | 2 + crates/sandlock-core/src/protection.rs | 84 ++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 crates/sandlock-core/src/protection.rs diff --git a/crates/sandlock-core/src/lib.rs b/crates/sandlock-core/src/lib.rs index e64f6ed..1ca4e84 100644 --- a/crates/sandlock-core/src/lib.rs +++ b/crates/sandlock-core/src/lib.rs @@ -6,6 +6,7 @@ pub mod result; pub(crate) mod arch; pub(crate) mod sys; pub mod landlock; +pub mod protection; pub mod seccomp; pub(crate) mod resource; pub(crate) mod network; @@ -30,6 +31,7 @@ pub(crate) mod http_acl; pub use error::SandlockError; pub use sys::structs::{SeccompData, SeccompNotif}; pub use checkpoint::Checkpoint; +pub use protection::Protection; pub use sandbox::{Confinement, ConfinementBuilder, Sandbox, SandboxBuilder}; pub use result::{RunResult, ExitStatus}; pub use pipeline::{Stage, Pipeline, Gather}; diff --git a/crates/sandlock-core/src/protection.rs b/crates/sandlock-core/src/protection.rs new file mode 100644 index 0000000..524f8ac --- /dev/null +++ b/crates/sandlock-core/src/protection.rs @@ -0,0 +1,84 @@ +//! Per-protection ABI floor for Landlock protections. +//! +//! Sandlock relies on a set of Landlock-provided protections, each +//! introduced in a specific Landlock ABI version. This module names +//! them as `Protection` variants and maps each to the minimum ABI the +//! host kernel must support. +//! +//! The actual policy that decides whether a protection is enforced, +//! degraded, or disabled lives in the higher-level +//! `ProtectionPolicy` (also in this module). The decision-vs-availability +//! resolution happens in `landlock::confine_inner`. + +/// A single Landlock-provided protection, ABI-gated. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Protection { + /// `LANDLOCK_ACCESS_FS_REFER` — ABI v2+. + FsRefer, + /// `LANDLOCK_ACCESS_FS_TRUNCATE` — ABI v3+. + FsTruncate, + /// `LANDLOCK_ACCESS_NET_BIND_TCP` / `_CONNECT_TCP` — ABI v4+. + NetTcp, + /// `LANDLOCK_ACCESS_FS_IOCTL_DEV` — ABI v5+. + FsIoctlDev, + /// `LANDLOCK_SCOPE_SIGNAL` — ABI v6+. + SignalScope, + /// `LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET` — ABI v6+. + AbstractUnixScope, +} + +impl Protection { + /// Minimum Landlock ABI version the host kernel must support for + /// this protection to be available. + pub const fn min_abi(self) -> u32 { + match self { + Protection::FsRefer => 2, + Protection::FsTruncate => 3, + Protection::NetTcp => 4, + Protection::FsIoctlDev => 5, + Protection::SignalScope => 6, + Protection::AbstractUnixScope => 6, + } + } + + /// Iterator over every known protection. + pub fn all() -> impl Iterator { + [ + Protection::FsRefer, + Protection::FsTruncate, + Protection::NetTcp, + Protection::FsIoctlDev, + Protection::SignalScope, + Protection::AbstractUnixScope, + ] + .into_iter() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn min_abi_matches_kernel_documented_floors() { + // These numbers come from the kernel Landlock documentation + // (https://docs.kernel.org/userspace-api/landlock.html); + // they MUST NOT drift. + assert_eq!(Protection::FsRefer.min_abi(), 2); + assert_eq!(Protection::FsTruncate.min_abi(), 3); + assert_eq!(Protection::NetTcp.min_abi(), 4); + assert_eq!(Protection::FsIoctlDev.min_abi(), 5); + assert_eq!(Protection::SignalScope.min_abi(), 6); + assert_eq!(Protection::AbstractUnixScope.min_abi(), 6); + } + + #[test] + fn all_iterates_every_variant_exactly_once() { + let collected: Vec = Protection::all().collect(); + assert_eq!(collected.len(), 6); + // No duplicates. + for p in &collected { + assert_eq!(collected.iter().filter(|&q| q == p).count(), 1); + } + } +} From fee0f0a88ca45782ec1f10929dedd743bfb7db20 Mon Sep 17 00:00:00 2001 From: dzerik Date: Mon, 25 May 2026 18:18:12 +0300 Subject: [PATCH 02/17] core: add ProtectionState and ProtectionPolicy with Strict default --- crates/sandlock-core/src/lib.rs | 2 +- crates/sandlock-core/src/protection.rs | 93 ++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/crates/sandlock-core/src/lib.rs b/crates/sandlock-core/src/lib.rs index 1ca4e84..b3ffdaa 100644 --- a/crates/sandlock-core/src/lib.rs +++ b/crates/sandlock-core/src/lib.rs @@ -31,7 +31,7 @@ pub(crate) mod http_acl; pub use error::SandlockError; pub use sys::structs::{SeccompData, SeccompNotif}; pub use checkpoint::Checkpoint; -pub use protection::Protection; +pub use protection::{Protection, ProtectionState, ProtectionPolicy}; pub use sandbox::{Confinement, ConfinementBuilder, Sandbox, SandboxBuilder}; pub use result::{RunResult, ExitStatus}; pub use pipeline::{Stage, Pipeline, Gather}; diff --git a/crates/sandlock-core/src/protection.rs b/crates/sandlock-core/src/protection.rs index 524f8ac..0183f31 100644 --- a/crates/sandlock-core/src/protection.rs +++ b/crates/sandlock-core/src/protection.rs @@ -10,6 +10,8 @@ //! `ProtectionPolicy` (also in this module). The decision-vs-availability //! resolution happens in `landlock::confine_inner`. +use std::collections::HashMap; + /// A single Landlock-provided protection, ABI-gated. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Protection { @@ -82,3 +84,94 @@ mod tests { } } } + +/// What a `ProtectionPolicy` instructs sandlock to do with a given +/// `Protection`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProtectionState { + /// Enforce; if the host kernel cannot provide this protection, + /// `confine_inner` returns an error naming the protection and the + /// kernel's actual ABI version. This is the default for every + /// protection. + Strict, + /// Enforce where the host kernel supports it; skip silently when + /// it does not. The skip is observable via `Sandbox::active_protections()` + /// and `sandlock check`. + Degradable, + /// Never enforce, even on a host kernel that supports the protection. + /// Intended for workloads that genuinely need the capability the + /// protection blocks. + Disabled, +} + +/// Per-`Protection` state collection. The default for any protection +/// not explicitly named is `ProtectionState::Strict` — meaning a +/// freshly-constructed `ProtectionPolicy` produces the same behaviour +/// as the current hard `MIN_ABI = 6` floor. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ProtectionPolicy { + states: HashMap, +} + +impl ProtectionPolicy { + /// A policy with no overrides — every protection defaults to + /// `Strict`. Equivalent to the pre-Protection behaviour. + pub fn strict_all() -> Self { + Self::default() + } + + /// Look up the state for a given protection. Returns `Strict` + /// for protections not explicitly named. + pub fn state(&self, protection: Protection) -> ProtectionState { + self.states.get(&protection).copied().unwrap_or(ProtectionState::Strict) + } + + /// Set the state for a single protection. Internal API — public + /// builder methods (in the polarity-dependent layer landing later) + /// call this. + pub(crate) fn set(&mut self, protection: Protection, state: ProtectionState) { + self.states.insert(protection, state); + } + + /// Iterator over every protection paired with its resolved state + /// (including the implicit `Strict` for unnamed ones). + pub fn iter(&self) -> impl Iterator + '_ { + Protection::all().map(|p| (p, self.state(p))) + } +} + +#[cfg(test)] +mod policy_tests { + use super::*; + + #[test] + fn strict_all_returns_strict_for_every_protection() { + let pol = ProtectionPolicy::strict_all(); + for p in Protection::all() { + assert_eq!(pol.state(p), ProtectionState::Strict); + } + } + + #[test] + fn unnamed_protections_default_to_strict_even_after_other_overrides() { + let mut pol = ProtectionPolicy::strict_all(); + pol.set(Protection::SignalScope, ProtectionState::Degradable); + assert_eq!(pol.state(Protection::SignalScope), ProtectionState::Degradable); + assert_eq!(pol.state(Protection::FsTruncate), ProtectionState::Strict); + assert_eq!(pol.state(Protection::AbstractUnixScope), ProtectionState::Strict); + } + + #[test] + fn iter_yields_every_protection_with_resolved_state() { + let mut pol = ProtectionPolicy::strict_all(); + pol.set(Protection::FsIoctlDev, ProtectionState::Disabled); + let collected: Vec<_> = pol.iter().collect(); + assert_eq!(collected.len(), 6); + assert!(collected.iter().any(|(p, s)| *p == Protection::FsIoctlDev && *s == ProtectionState::Disabled)); + for (p, s) in &collected { + if *p != Protection::FsIoctlDev { + assert_eq!(*s, ProtectionState::Strict, "{:?} should default to Strict", p); + } + } + } +} From 45f34f6d8fde72bad2053f8fb43f155ea84cf88e Mon Sep 17 00:00:00 2001 From: dzerik Date: Mon, 25 May 2026 18:37:04 +0300 Subject: [PATCH 03/17] core: add Sandbox::protection_policy field defaulting to strict-all --- crates/sandlock-core/src/sandbox.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index 0d4ceec..d112b17 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -11,6 +11,7 @@ use crate::context; use crate::error::SandboxError; pub use crate::http::{http_acl_check, normalize_path, prefix_or_exact_match, HttpRule}; pub use crate::network::{NetAllow, Protocol}; +use crate::protection::ProtectionPolicy; /// A byte size value. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -224,6 +225,18 @@ pub struct Sandbox { pub extra_deny_syscalls: Vec, pub extra_allow_syscalls: Vec, + /// Per-protection enforcement policy. Default + /// (`ProtectionPolicy::strict_all()`) preserves the historical hard + /// `MIN_ABI = 6` behaviour. Builder methods to deviate from + /// strict-all are added in a follow-up. + /// + /// Not serialized — policy-file representation of per-protection + /// state arrives with the public builder API in a later change. + /// Deserialized sandboxes get `ProtectionPolicy::default()`, which + /// is identical to `strict_all()`. + #[serde(skip)] + pub protection_policy: ProtectionPolicy, + // Network /// Outbound endpoint allowlist as a list of `(protocol, host?, ports)` /// rules. Each rule names a protocol (TCP/UDP/ICMP) and either a @@ -365,6 +378,7 @@ impl Clone for Sandbox { fs_denied: self.fs_denied.clone(), extra_deny_syscalls: self.extra_deny_syscalls.clone(), extra_allow_syscalls: self.extra_allow_syscalls.clone(), + protection_policy: self.protection_policy.clone(), net_allow: self.net_allow.clone(), net_bind: self.net_bind.clone(), http_allow: self.http_allow.clone(), @@ -2343,6 +2357,7 @@ impl SandboxBuilder { fs_denied: self.fs_denied, extra_deny_syscalls: self.extra_deny_syscalls, extra_allow_syscalls: self.extra_allow_syscalls, + protection_policy: ProtectionPolicy::strict_all(), net_allow, net_bind: self.net_bind, http_allow, From 43e05519965b6f101682190a67ac4b6878b33d9c Mon Sep 17 00:00:00 2001 From: dzerik Date: Mon, 25 May 2026 18:41:21 +0300 Subject: [PATCH 04/17] core: add ConfinementError::ProtectionUnavailable variant --- crates/sandlock-core/src/error.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/sandlock-core/src/error.rs b/crates/sandlock-core/src/error.rs index 2158f76..90fd924 100644 --- a/crates/sandlock-core/src/error.rs +++ b/crates/sandlock-core/src/error.rs @@ -66,6 +66,19 @@ pub enum ConfinementError { feature: String, }, + /// A `Protection` in `ProtectionState::Strict` is unavailable + /// because the host kernel's Landlock ABI is below the + /// protection's `min_abi()`. Build (or `confine`) refuses to + /// proceed; the caller can resolve by setting that protection to + /// `Degradable` or `Disabled`, or by running on a kernel that + /// supports it. + #[error("required protection {protection:?} is not available: host Landlock ABI is v{host_abi}, requires v{required_abi}")] + ProtectionUnavailable { + protection: crate::protection::Protection, + required_abi: u32, + host_abi: u32, + }, + #[error("landlock error: {0}")] Landlock(String), From 14993ab706b0e33377b987f9551760c5ac2413c5 Mon Sep 17 00:00:00 2001 From: dzerik Date: Mon, 25 May 2026 19:46:10 +0300 Subject: [PATCH 05/17] core: per-protection availability resolution in confine_inner --- crates/sandlock-core/src/landlock.rs | 168 +++++++++++++++--- crates/sandlock-core/src/protection.rs | 7 +- crates/sandlock-core/tests/integration.rs | 3 + .../tests/integration/test_protection.rs | 155 ++++++++++++++++ 4 files changed, 304 insertions(+), 29 deletions(-) create mode 100644 crates/sandlock-core/tests/integration/test_protection.rs diff --git a/crates/sandlock-core/src/landlock.rs b/crates/sandlock-core/src/landlock.rs index d5c5fbb..01fc21e 100644 --- a/crates/sandlock-core/src/landlock.rs +++ b/crates/sandlock-core/src/landlock.rs @@ -2,6 +2,7 @@ use std::os::fd::OwnedFd; use std::path::Path; use crate::error::{ConfinementError, SandlockError}; +use crate::protection::{Protection, ProtectionPolicy, ProtectionState}; use crate::sandbox::Sandbox; use crate::sys::structs::{ LandlockNetPortAttr, LandlockPathBeneathAttr, LandlockRulesetAttr, @@ -188,11 +189,112 @@ fn add_net_rule(ruleset_fd: &OwnedFd, port: u16, access: u64) -> Result<(), Conf Ok(()) } +// ============================================================ +// Per-protection availability resolution +// ============================================================ + +/// Resolution for a single `Protection` against the host's Landlock +/// ABI and the policy's state for it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[doc(hidden)] +pub enum Resolved { + /// Enforce: protection is available on the host and the policy + /// requires (or allows) it. + Active, + /// Enforce-not: protection is unavailable on the host but the + /// policy named it `Degradable`, so we skip it silently. + Degraded, + /// Off: the policy disabled this protection (regardless of host + /// support). + Disabled, + /// Error: the policy is `Strict` but the host kernel cannot + /// provide this protection. + StrictlyUnavailable, +} + +/// Resolve a single `Protection` against the host ABI and a +/// `ProtectionPolicy` into one of four states. +#[doc(hidden)] +pub fn resolve(p: Protection, host_abi: u32, policy: &ProtectionPolicy) -> Resolved { + let available = host_abi >= p.min_abi(); + match (policy.state(p), available) { + (ProtectionState::Disabled, _) => Resolved::Disabled, + (ProtectionState::Strict, true) => Resolved::Active, + (ProtectionState::Strict, false) => Resolved::StrictlyUnavailable, + (ProtectionState::Degradable, true) => Resolved::Active, + (ProtectionState::Degradable, false) => Resolved::Degraded, + } +} + +/// Compute the `scoped` mask from the per-protection resolutions of +/// the two scope protections. +pub(crate) fn compute_scope_mask(abi: u32, pol: &ProtectionPolicy) -> u64 { + let mut mask: u64 = 0; + if resolve(Protection::AbstractUnixScope, abi, pol) == Resolved::Active { + mask |= LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET; + } + if resolve(Protection::SignalScope, abi, pol) == Resolved::Active { + mask |= LANDLOCK_SCOPE_SIGNAL; + } + mask +} + +/// Compute the `handled_access_fs` mask. Starts from the ABI-cumulative +/// base set and masks off bits whose corresponding `Protection` is +/// `Disabled` in the policy. +pub(crate) fn compute_fs_mask(abi: u32, pol: &ProtectionPolicy) -> u64 { + let mut mask = base_fs_access(abi); + if resolve(Protection::FsRefer, abi, pol) == Resolved::Disabled { + mask &= !LANDLOCK_ACCESS_FS_REFER; + } + if resolve(Protection::FsTruncate, abi, pol) == Resolved::Disabled { + mask &= !LANDLOCK_ACCESS_FS_TRUNCATE; + } + if resolve(Protection::FsIoctlDev, abi, pol) == Resolved::Disabled { + mask &= !LANDLOCK_ACCESS_FS_IOCTL_DEV; + } + mask +} + +/// Compute the `handled_access_net` mask, preserving the wildcard +/// behaviour: when any TCP `--net-allow` rule covers every port we +/// drop `CONNECT_TCP` from the handled set (the on-behalf path is then +/// the sole enforcer). Returns `0` when `Protection::NetTcp` is not +/// `Active` (either disabled by policy or degraded on a kernel that +/// does not provide TCP network hooks). +pub(crate) fn compute_net_mask( + abi: u32, + pol: &ProtectionPolicy, + sandbox: &Sandbox, + handle_net: bool, +) -> u64 { + if !handle_net { + return 0; + } + if resolve(Protection::NetTcp, abi, pol) != Resolved::Active { + return 0; + } + use crate::sandbox::Protocol; + let net_wildcard = sandbox + .net_allow + .iter() + .any(|r| r.protocol == Protocol::Tcp && r.all_ports); + if net_wildcard { + LANDLOCK_ACCESS_NET_BIND_TCP + } else { + LANDLOCK_ACCESS_NET_BIND_TCP | LANDLOCK_ACCESS_NET_CONNECT_TCP + } +} + // ============================================================ // Main entry point // ============================================================ -/// Minimum Landlock ABI version required by sandlock. +/// Minimum Landlock ABI version required by sandlock when every +/// protection is in the default `ProtectionState::Strict`. The +/// authoritative per-protection floors live in +/// `Protection::min_abi()`; this constant is kept for backward +/// compatibility with downstream code that re-exports it. pub const MIN_ABI: u32 = 6; /// Apply Landlock confinement based on the given `Sandbox`. @@ -209,27 +311,40 @@ pub fn confine_filesystem(policy: &Sandbox) -> Result<(), SandlockError> { } fn confine_inner(policy: &Sandbox, handle_net: bool) -> Result<(), SandlockError> { - // Step 1 -- detect and validate ABI version. + // Step 1 — detect host ABI version. let abi = abi_version().map_err(|e| { SandlockError::Runtime(crate::error::SandboxRuntimeError::Confinement(e)) })?; - if abi < MIN_ABI { - return Err(SandlockError::Runtime( - crate::error::SandboxRuntimeError::Confinement( - ConfinementError::InsufficientAbi { - required: MIN_ABI, - actual: abi, - feature: "full sandlock support".into(), - }, - ), - )); + // Step 2 — per-protection availability resolution. Any protection + // in `ProtectionState::Strict` that the host kernel cannot provide + // is a hard error here; `Degradable` becomes a silent skip and + // `Disabled` is honoured regardless of host support. With the + // default `ProtectionPolicy::strict_all()` on a v6+ host this + // produces `Resolved::Active` for every protection — preserving + // the historical `MIN_ABI = 6` floor exactly. + let pol = &policy.protection_policy; + for protection in Protection::all() { + if resolve(protection, abi, pol) == Resolved::StrictlyUnavailable { + return Err(SandlockError::Runtime( + crate::error::SandboxRuntimeError::Confinement( + ConfinementError::ProtectionUnavailable { + protection, + required_abi: protection.min_abi(), + host_abi: abi, + }, + ), + )); + } } - // Step 2 -- build handled_access_fs / handled_access_net / scoped. - let handled_access_fs = base_fs_access(abi); + // Step 3 — build handled_access_fs / handled_access_net / scoped. + // + // FS: cumulative ABI base set, with `Disabled` protections masked + // off (FsRefer/FsTruncate/FsIoctlDev). + let handled_access_fs = compute_fs_mask(abi, pol); - // Restrict TCP bind/connect via Landlock by default. When any + // Net: TCP bind/connect via Landlock by default. When any // `--net-allow` rule has the all-ports wildcard (`host:*` or // `:*`), Landlock cannot express "every port" without enumerating // 65535 rules, so we drop CONNECT_TCP from the handled set — @@ -248,16 +363,11 @@ fn confine_inner(policy: &Sandbox, handle_net: bool) -> Result<(), SandlockError .net_allow .iter() .any(|r| r.protocol == Protocol::Tcp && r.all_ports); - let handled_access_net = if !handle_net { - 0 - } else if net_wildcard { - LANDLOCK_ACCESS_NET_BIND_TCP - } else { - LANDLOCK_ACCESS_NET_BIND_TCP | LANDLOCK_ACCESS_NET_CONNECT_TCP - }; + let handled_access_net = compute_net_mask(abi, pol, policy, handle_net); - // IPC and signal isolation are always enabled. - let scoped = LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET | LANDLOCK_SCOPE_SIGNAL; + // Scope: IPC + signal isolation, each gated on its protection's + // resolved state. + let scoped = compute_scope_mask(abi, pol); // Step 3 — create ruleset. let attr = LandlockRulesetAttr { @@ -331,8 +441,12 @@ fn confine_inner(policy: &Sandbox, handle_net: bool) -> Result<(), SandlockError } } - // Step 5 -- add network port rules. - if handle_net { + // Step 5 -- add network port rules. Skip entirely when + // `Protection::NetTcp` is not `Active` (either policy-disabled or + // degraded on a host without TCP network hooks) — `handled_access_net` + // is 0 in that case and the kernel would reject any rule with EINVAL. + let net_tcp_active = resolve(Protection::NetTcp, abi, pol) == Resolved::Active; + if handle_net && net_tcp_active { for &port in &policy.net_bind { add_net_rule(&ruleset_fd, port, LANDLOCK_ACCESS_NET_BIND_TCP).map_err(|e| { SandlockError::Runtime(crate::error::SandboxRuntimeError::Confinement(e)) @@ -349,7 +463,7 @@ fn confine_inner(policy: &Sandbox, handle_net: bool) -> Result<(), SandlockError // When `net_wildcard` is set we already excluded CONNECT_TCP from // `handled_access_net`, so adding rules here would fail with EINVAL. // Skip — the on-behalf path is the sole enforcer. - if handle_net && !net_wildcard { + if handle_net && net_tcp_active && !net_wildcard { let mut connect_ports: std::collections::HashSet = std::collections::HashSet::new(); for rule in &policy.net_allow { // TCP-only — see net_wildcard comment above. diff --git a/crates/sandlock-core/src/protection.rs b/crates/sandlock-core/src/protection.rs index 0183f31..2555da6 100644 --- a/crates/sandlock-core/src/protection.rs +++ b/crates/sandlock-core/src/protection.rs @@ -128,8 +128,11 @@ impl ProtectionPolicy { /// Set the state for a single protection. Internal API — public /// builder methods (in the polarity-dependent layer landing later) - /// call this. - pub(crate) fn set(&mut self, protection: Protection, state: ProtectionState) { + /// call this. Marked `#[doc(hidden)] pub` so integration tests in + /// the `tests/` directory can drive the resolver directly; not part + /// of the stable public surface. + #[doc(hidden)] + pub fn set(&mut self, protection: Protection, state: ProtectionState) { self.states.insert(protection, state); } diff --git a/crates/sandlock-core/tests/integration.rs b/crates/sandlock-core/tests/integration.rs index 5c90f88..707c0be 100644 --- a/crates/sandlock-core/tests/integration.rs +++ b/crates/sandlock-core/tests/integration.rs @@ -57,3 +57,6 @@ mod test_http_acl; #[path = "integration/test_handlers.rs"] mod test_handlers; + +#[path = "integration/test_protection.rs"] +mod test_protection; diff --git a/crates/sandlock-core/tests/integration/test_protection.rs b/crates/sandlock-core/tests/integration/test_protection.rs new file mode 100644 index 0000000..b134a4e --- /dev/null +++ b/crates/sandlock-core/tests/integration/test_protection.rs @@ -0,0 +1,155 @@ +//! Integration tests for the per-protection availability resolution +//! in `landlock::confine_inner`. +//! +//! These tests exercise the policy-driven resolution path directly via +//! the `pub(crate)` `resolve()` helper with synthetic ABI values, so +//! they are independent of the host kernel's actual Landlock ABI. + +use sandlock_core::landlock::{resolve, Resolved}; +use sandlock_core::{Protection, ProtectionPolicy, ProtectionState}; + +// ---------------------------------------------------------------------- +// resolve() — Strict +// ---------------------------------------------------------------------- + +#[test] +fn strict_on_supporting_host_resolves_to_active() { + // SignalScope needs ABI v6; host claims v6. + let pol = ProtectionPolicy::strict_all(); + assert_eq!( + resolve(Protection::SignalScope, 6, &pol), + Resolved::Active, + "Strict + available host must resolve to Active" + ); +} + +#[test] +fn strictly_unavailable_returns_protection_unavailable() { + // SignalScope needs ABI v6; host only has v5. + let pol = ProtectionPolicy::strict_all(); + let r = resolve(Protection::SignalScope, 5, &pol); + assert_eq!( + r, + Resolved::StrictlyUnavailable, + "Strict + unavailable host must resolve to StrictlyUnavailable" + ); +} + +#[test] +fn strict_all_on_v6_host_resolves_every_protection_active() { + // Default strict_all() policy on a kernel meeting the highest floor + // (v6) yields Active for every protection — this is the load-bearing + // invariant that preserves pre-refactor confine_inner behaviour. + let pol = ProtectionPolicy::strict_all(); + for p in Protection::all() { + assert_eq!( + resolve(p, 6, &pol), + Resolved::Active, + "{:?} under strict_all on v6 host must be Active", + p + ); + } +} + +// ---------------------------------------------------------------------- +// resolve() — Degradable +// ---------------------------------------------------------------------- + +#[test] +fn degradable_on_unavailable_host_resolves_to_degraded() { + let mut pol = ProtectionPolicy::strict_all(); + pol.set(Protection::SignalScope, ProtectionState::Degradable); + let r = resolve(Protection::SignalScope, 5, &pol); + assert_eq!( + r, + Resolved::Degraded, + "Degradable + unavailable host must resolve to Degraded (silent skip)" + ); +} + +#[test] +fn degradable_on_supporting_host_resolves_to_active() { + let mut pol = ProtectionPolicy::strict_all(); + pol.set(Protection::FsTruncate, ProtectionState::Degradable); + // FsTruncate needs v3. + assert_eq!( + resolve(Protection::FsTruncate, 6, &pol), + Resolved::Active, + "Degradable + available host must enforce (Active)" + ); +} + +// ---------------------------------------------------------------------- +// resolve() — Disabled +// ---------------------------------------------------------------------- + +#[test] +fn disabled_on_supporting_host_resolves_to_disabled() { + let mut pol = ProtectionPolicy::strict_all(); + pol.set(Protection::SignalScope, ProtectionState::Disabled); + let r = resolve(Protection::SignalScope, 6, &pol); + assert_eq!( + r, + Resolved::Disabled, + "Disabled must resolve to Disabled regardless of host support" + ); +} + +#[test] +fn disabled_on_unavailable_host_resolves_to_disabled() { + let mut pol = ProtectionPolicy::strict_all(); + pol.set(Protection::SignalScope, ProtectionState::Disabled); + let r = resolve(Protection::SignalScope, 5, &pol); + assert_eq!( + r, + Resolved::Disabled, + "Disabled wins over host availability — never StrictlyUnavailable" + ); +} + +// ---------------------------------------------------------------------- +// Per-protection ABI floor matrix +// ---------------------------------------------------------------------- + +#[test] +fn strict_all_on_v4_host_fails_only_for_v5_plus_protections() { + // Host with ABI v4 supports FsRefer (v2), FsTruncate (v3), NetTcp + // (v4); fails on FsIoctlDev (v5), SignalScope (v6), + // AbstractUnixScope (v6). + let pol = ProtectionPolicy::strict_all(); + assert_eq!(resolve(Protection::FsRefer, 4, &pol), Resolved::Active); + assert_eq!(resolve(Protection::FsTruncate, 4, &pol), Resolved::Active); + assert_eq!(resolve(Protection::NetTcp, 4, &pol), Resolved::Active); + assert_eq!( + resolve(Protection::FsIoctlDev, 4, &pol), + Resolved::StrictlyUnavailable + ); + assert_eq!( + resolve(Protection::SignalScope, 4, &pol), + Resolved::StrictlyUnavailable + ); + assert_eq!( + resolve(Protection::AbstractUnixScope, 4, &pol), + Resolved::StrictlyUnavailable + ); +} + +#[test] +fn fully_degradable_policy_never_returns_strictly_unavailable_even_on_v1() { + // A policy that marks every protection Degradable must never + // produce StrictlyUnavailable, even on a host so old it only + // supports the v1 base set (no fs-extension protections). + let mut pol = ProtectionPolicy::strict_all(); + for p in Protection::all() { + pol.set(p, ProtectionState::Degradable); + } + for p in Protection::all() { + let r = resolve(p, 1, &pol); + assert!( + matches!(r, Resolved::Active | Resolved::Degraded), + "{:?} on v1 host with Degradable must not be StrictlyUnavailable, got {:?}", + p, + r + ); + } +} From bf9490d460b38eba8f06be23b6b316abe6311d33 Mon Sep 17 00:00:00 2001 From: dzerik Date: Mon, 25 May 2026 20:59:11 +0300 Subject: [PATCH 06/17] core: mask Degraded fs protections; consolidate net_wildcard computation --- crates/sandlock-core/src/landlock.rs | 73 +++++++++++------ .../tests/integration/test_protection.rs | 79 ++++++++++++++++++- 2 files changed, 129 insertions(+), 23 deletions(-) diff --git a/crates/sandlock-core/src/landlock.rs b/crates/sandlock-core/src/landlock.rs index 01fc21e..99b26c3 100644 --- a/crates/sandlock-core/src/landlock.rs +++ b/crates/sandlock-core/src/landlock.rs @@ -241,49 +241,77 @@ pub(crate) fn compute_scope_mask(abi: u32, pol: &ProtectionPolicy) -> u64 { /// Compute the `handled_access_fs` mask. Starts from the ABI-cumulative /// base set and masks off bits whose corresponding `Protection` is -/// `Disabled` in the policy. -pub(crate) fn compute_fs_mask(abi: u32, pol: &ProtectionPolicy) -> u64 { +/// `Disabled` or `Degraded` in the policy. +/// +/// `Degraded` means a `Degradable` protection on a host that does not +/// provide the underlying kernel hook — declaring the bit anyway would +/// fail `landlock_create_ruleset` with EINVAL and break the silent-skip +/// contract. `base_fs_access(abi)` already gates each extension bit on +/// the host ABI, so on a real host the `Degraded` bit is not in the +/// base mask in the first place. Masking it off here is defence in +/// depth: the contract is uniformly expressed regardless of how +/// `base_fs_access` evolves, and the synthetic-ABI integration tests +/// can exercise this code path directly. +pub fn compute_fs_mask(abi: u32, pol: &ProtectionPolicy) -> u64 { let mut mask = base_fs_access(abi); - if resolve(Protection::FsRefer, abi, pol) == Resolved::Disabled { + if matches!( + resolve(Protection::FsRefer, abi, pol), + Resolved::Disabled | Resolved::Degraded + ) { mask &= !LANDLOCK_ACCESS_FS_REFER; } - if resolve(Protection::FsTruncate, abi, pol) == Resolved::Disabled { + if matches!( + resolve(Protection::FsTruncate, abi, pol), + Resolved::Disabled | Resolved::Degraded + ) { mask &= !LANDLOCK_ACCESS_FS_TRUNCATE; } - if resolve(Protection::FsIoctlDev, abi, pol) == Resolved::Disabled { + if matches!( + resolve(Protection::FsIoctlDev, abi, pol), + Resolved::Disabled | Resolved::Degraded + ) { mask &= !LANDLOCK_ACCESS_FS_IOCTL_DEV; } mask } -/// Compute the `handled_access_net` mask, preserving the wildcard -/// behaviour: when any TCP `--net-allow` rule covers every port we -/// drop `CONNECT_TCP` from the handled set (the on-behalf path is then -/// the sole enforcer). Returns `0` when `Protection::NetTcp` is not -/// `Active` (either disabled by policy or degraded on a kernel that -/// does not provide TCP network hooks). -pub(crate) fn compute_net_mask( +/// Compute the `handled_access_net` mask AND the TCP wildcard flag, +/// preserving the wildcard behaviour: when any TCP `--net-allow` rule +/// covers every port we drop `CONNECT_TCP` from the handled set (the +/// on-behalf path is then the sole enforcer). +/// +/// Returns `(0, false)` when `Protection::NetTcp` is not `Active` +/// (either disabled by policy or degraded on a kernel that does not +/// provide TCP network hooks). +/// +/// Returning both the mask and the wildcard flag keeps the rule- +/// installation site in `confine_inner` in sync with the mask: the +/// caller no longer recomputes the wildcard from `sandbox.net_allow`, +/// so divergence between the two derivations is impossible by +/// construction. +pub fn compute_net_mask( abi: u32, pol: &ProtectionPolicy, sandbox: &Sandbox, handle_net: bool, -) -> u64 { +) -> (u64, bool) { if !handle_net { - return 0; + return (0, false); } if resolve(Protection::NetTcp, abi, pol) != Resolved::Active { - return 0; + return (0, false); } use crate::sandbox::Protocol; let net_wildcard = sandbox .net_allow .iter() .any(|r| r.protocol == Protocol::Tcp && r.all_ports); - if net_wildcard { + let mask = if net_wildcard { LANDLOCK_ACCESS_NET_BIND_TCP } else { LANDLOCK_ACCESS_NET_BIND_TCP | LANDLOCK_ACCESS_NET_CONNECT_TCP - } + }; + (mask, net_wildcard) } // ============================================================ @@ -358,12 +386,13 @@ fn confine_inner(policy: &Sandbox, handle_net: bool) -> Result<(), SandlockError // on-behalf path), so they're filtered out here — feeding them to // Landlock would either be a no-op (for unhandled protocols) or // wrongly install TCP rules from a UDP wildcard. + // + // `compute_net_mask` is the single source of truth for both the + // handled-net mask and the TCP wildcard flag: the rule-installation + // block below uses the same `net_wildcard` value the mask was + // derived from, so the two cannot diverge. use crate::sandbox::Protocol; - let net_wildcard = policy - .net_allow - .iter() - .any(|r| r.protocol == Protocol::Tcp && r.all_ports); - let handled_access_net = compute_net_mask(abi, pol, policy, handle_net); + let (handled_access_net, net_wildcard) = compute_net_mask(abi, pol, policy, handle_net); // Scope: IPC + signal isolation, each gated on its protection's // resolved state. diff --git a/crates/sandlock-core/tests/integration/test_protection.rs b/crates/sandlock-core/tests/integration/test_protection.rs index b134a4e..96e3c7f 100644 --- a/crates/sandlock-core/tests/integration/test_protection.rs +++ b/crates/sandlock-core/tests/integration/test_protection.rs @@ -5,9 +5,16 @@ //! the `pub(crate)` `resolve()` helper with synthetic ABI values, so //! they are independent of the host kernel's actual Landlock ABI. -use sandlock_core::landlock::{resolve, Resolved}; +use sandlock_core::landlock::{compute_fs_mask, resolve, Resolved}; use sandlock_core::{Protection, ProtectionPolicy, ProtectionState}; +// Landlock FS access constants (kernel ABI, stable). Kept local to the +// test so we don't need to expose `crate::sys::structs`. These are bit +// positions defined by `linux/landlock.h`. +const LANDLOCK_ACCESS_FS_REFER: u64 = 1 << 13; +const LANDLOCK_ACCESS_FS_TRUNCATE: u64 = 1 << 14; +const LANDLOCK_ACCESS_FS_IOCTL_DEV: u64 = 1 << 15; + // ---------------------------------------------------------------------- // resolve() — Strict // ---------------------------------------------------------------------- @@ -153,3 +160,73 @@ fn fully_degradable_policy_never_returns_strictly_unavailable_even_on_v1() { ); } } + +// ---------------------------------------------------------------------- +// compute_fs_mask() — Degraded protections must be masked off +// +// Regression guards for the bug where `compute_fs_mask` only masked +// off `Resolved::Disabled` bits, leaving a `Degraded` bit in the +// handled-fs mask. The kernel then rejects `landlock_create_ruleset` +// with EINVAL — breaking the `Degradable` silent-skip contract. +// +// Each test pins one extension protection to `Degradable` and feeds +// `compute_fs_mask` a synthetic host ABI below that protection's +// floor, asserting the bit is NOT in the returned mask. +// ---------------------------------------------------------------------- + +#[test] +fn degradable_fs_truncate_on_v1_host_masks_off_truncate_bit() { + // FsTruncate needs ABI v3; host claims v1. Marking it Degradable + // must drop LANDLOCK_ACCESS_FS_TRUNCATE from the handled-fs mask + // (it shouldn't be there from base_fs_access(1) either, but we + // assert the post-mask invariant directly). + let mut pol = ProtectionPolicy::strict_all(); + pol.set(Protection::FsTruncate, ProtectionState::Degradable); + // Sanity: the protection resolves Degraded on this host. + assert_eq!( + resolve(Protection::FsTruncate, 1, &pol), + Resolved::Degraded + ); + let mask = compute_fs_mask(1, &pol); + assert_eq!( + mask & LANDLOCK_ACCESS_FS_TRUNCATE, + 0, + "Degraded FsTruncate must not leave its bit in handled_access_fs (mask=0x{:x})", + mask + ); +} + +#[test] +fn degradable_fs_refer_on_v1_host_masks_off_refer_bit() { + // FsRefer needs ABI v2; host claims v1. Marking it Degradable + // must drop LANDLOCK_ACCESS_FS_REFER from the handled-fs mask. + let mut pol = ProtectionPolicy::strict_all(); + pol.set(Protection::FsRefer, ProtectionState::Degradable); + assert_eq!(resolve(Protection::FsRefer, 1, &pol), Resolved::Degraded); + let mask = compute_fs_mask(1, &pol); + assert_eq!( + mask & LANDLOCK_ACCESS_FS_REFER, + 0, + "Degraded FsRefer must not leave its bit in handled_access_fs (mask=0x{:x})", + mask + ); +} + +#[test] +fn degradable_fs_ioctl_dev_on_v4_host_masks_off_ioctl_dev_bit() { + // FsIoctlDev needs ABI v5; host claims v4. Marking it Degradable + // must drop LANDLOCK_ACCESS_FS_IOCTL_DEV from the handled-fs mask. + let mut pol = ProtectionPolicy::strict_all(); + pol.set(Protection::FsIoctlDev, ProtectionState::Degradable); + assert_eq!( + resolve(Protection::FsIoctlDev, 4, &pol), + Resolved::Degraded + ); + let mask = compute_fs_mask(4, &pol); + assert_eq!( + mask & LANDLOCK_ACCESS_FS_IOCTL_DEV, + 0, + "Degraded FsIoctlDev must not leave its bit in handled_access_fs (mask=0x{:x})", + mask + ); +} From 30ad30c38783d310db9a272d42cc6912e70f50d3 Mon Sep 17 00:00:00 2001 From: dzerik Date: Mon, 25 May 2026 21:46:14 +0300 Subject: [PATCH 07/17] cli: extend 'sandlock check' with per-protection availability report --- crates/sandlock-cli/src/main.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/sandlock-cli/src/main.rs b/crates/sandlock-cli/src/main.rs index 5d3803b..3721cf5 100644 --- a/crates/sandlock-cli/src/main.rs +++ b/crates/sandlock-cli/src/main.rs @@ -207,6 +207,14 @@ async fn main() -> Result<()> { println!(" Device ioctl: {}", if v >= 5 { "supported (ABI v5+)" } else { "not supported" }); println!(" IPC scoping: {}", if v >= 6 { "supported (ABI v6+)" } else { "not supported" }); println!(" Signal scoping: {}", if v >= 6 { "supported (ABI v6+)" } else { "not supported" }); + + println!(); + println!("Per-protection availability (host Landlock ABI v{}):", v); + for p in sandlock_core::Protection::all() { + let available = v >= p.min_abi(); + let marker = if available { "available" } else { "unavailable" }; + println!(" {:<22} requires v{} — {}", format!("{:?}", p), p.min_abi(), marker); + } } Err(e) => { println!(" Landlock: unavailable ({})", e); From 76e21ae560d9ad7ab9cf7e53a0eedb5d3c249917 Mon Sep 17 00:00:00 2001 From: dzerik Date: Mon, 25 May 2026 22:02:57 +0300 Subject: [PATCH 08/17] core: add Sandbox::active_protections() runtime accessor --- crates/sandlock-core/src/lib.rs | 2 +- crates/sandlock-core/src/protection.rs | 30 +++++++++++++++++++ crates/sandlock-core/src/sandbox.rs | 14 ++++++++- .../tests/integration/test_protection.rs | 25 +++++++++++++++- 4 files changed, 68 insertions(+), 3 deletions(-) diff --git a/crates/sandlock-core/src/lib.rs b/crates/sandlock-core/src/lib.rs index b3ffdaa..9051327 100644 --- a/crates/sandlock-core/src/lib.rs +++ b/crates/sandlock-core/src/lib.rs @@ -31,7 +31,7 @@ pub(crate) mod http_acl; pub use error::SandlockError; pub use sys::structs::{SeccompData, SeccompNotif}; pub use checkpoint::Checkpoint; -pub use protection::{Protection, ProtectionState, ProtectionPolicy}; +pub use protection::{Protection, ProtectionState, ProtectionPolicy, ProtectionStatus}; pub use sandbox::{Confinement, ConfinementBuilder, Sandbox, SandboxBuilder}; pub use result::{RunResult, ExitStatus}; pub use pipeline::{Stage, Pipeline, Gather}; diff --git a/crates/sandlock-core/src/protection.rs b/crates/sandlock-core/src/protection.rs index 2555da6..452d38a 100644 --- a/crates/sandlock-core/src/protection.rs +++ b/crates/sandlock-core/src/protection.rs @@ -143,6 +143,36 @@ impl ProtectionPolicy { } } +/// Per-protection runtime status, resolved against the host's +/// Landlock ABI and the `ProtectionPolicy`. Returned by +/// `Sandbox::active_protections()`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProtectionStatus { + /// Enforced (policy is Strict or Degradable, host supports it). + Active, + /// Policy named the protection as Degradable, host does not + /// support it — silently skipped. + Degraded, + /// Policy explicitly disabled the protection. + Disabled, + /// Policy was Strict and host does not support it — would have + /// caused `build()` to fail. + Unavailable, +} + +impl ProtectionStatus { + pub(crate) fn resolve(p: Protection, host_abi: u32, pol: &ProtectionPolicy) -> Self { + let available = host_abi >= p.min_abi(); + match (pol.state(p), available) { + (ProtectionState::Disabled, _) => ProtectionStatus::Disabled, + (ProtectionState::Strict, true) => ProtectionStatus::Active, + (ProtectionState::Strict, false) => ProtectionStatus::Unavailable, + (ProtectionState::Degradable, true) => ProtectionStatus::Active, + (ProtectionState::Degradable, false) => ProtectionStatus::Degraded, + } + } +} + #[cfg(test)] mod policy_tests { use super::*; diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index d112b17..c255259 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -11,7 +11,7 @@ use crate::context; use crate::error::SandboxError; pub use crate::http::{http_acl_check, normalize_path, prefix_or_exact_match, HttpRule}; pub use crate::network::{NetAllow, Protocol}; -use crate::protection::ProtectionPolicy; +use crate::protection::{Protection, ProtectionPolicy, ProtectionStatus}; /// A byte size value. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -449,6 +449,18 @@ impl Sandbox { Ok(()) } + /// Resolve the per-protection state against the host's current + /// Landlock ABI. Returns one entry per `Protection`. Useful for + /// post-`build()` posture inspection. + pub fn active_protections(&self) -> Result, crate::error::SandlockError> { + let host_abi = crate::landlock::abi_version().map_err(|e| { + crate::error::SandlockError::Runtime(crate::error::SandboxRuntimeError::Confinement(e)) + })?; + Ok(Protection::all() + .map(|p| (p, ProtectionStatus::resolve(p, host_abi, &self.protection_policy))) + .collect()) + } + // ================================================================ // Runtime accessor helpers (private) // ================================================================ diff --git a/crates/sandlock-core/tests/integration/test_protection.rs b/crates/sandlock-core/tests/integration/test_protection.rs index 96e3c7f..072bff0 100644 --- a/crates/sandlock-core/tests/integration/test_protection.rs +++ b/crates/sandlock-core/tests/integration/test_protection.rs @@ -6,7 +6,7 @@ //! they are independent of the host kernel's actual Landlock ABI. use sandlock_core::landlock::{compute_fs_mask, resolve, Resolved}; -use sandlock_core::{Protection, ProtectionPolicy, ProtectionState}; +use sandlock_core::{Protection, ProtectionPolicy, ProtectionState, ProtectionStatus}; // Landlock FS access constants (kernel ABI, stable). Kept local to the // test so we don't need to expose `crate::sys::structs`. These are bit @@ -230,3 +230,26 @@ fn degradable_fs_ioctl_dev_on_v4_host_masks_off_ioctl_dev_bit() { mask ); } + +// ---------------------------------------------------------------------- +// Sandbox::active_protections() runtime accessor +// ---------------------------------------------------------------------- + +#[test] +fn active_protections_returns_six_entries() { + // Construct a default Sandbox; we don't actually run it — just + // call the accessor. The dev host has Landlock available, so + // abi_version() should succeed. + let sb = sandlock_core::Sandbox::builder().build_unchecked().expect("build"); + let result = sb.active_protections().expect("ABI detect"); + assert_eq!(result.len(), 6); +} + +#[test] +fn active_protections_reports_disabled_for_explicitly_off() { + let mut sb = sandlock_core::Sandbox::builder().build_unchecked().expect("build"); + sb.protection_policy.set(Protection::SignalScope, ProtectionState::Disabled); + let result = sb.active_protections().expect("ABI detect"); + let signal = result.iter().find(|(p, _)| *p == Protection::SignalScope).unwrap(); + assert_eq!(signal.1, ProtectionStatus::Disabled); +} From 08d10c1e19a27c7ce17ea74a2b98b8b53066498f Mon Sep 17 00:00:00 2001 From: dzerik Date: Tue, 26 May 2026 15:15:35 +0300 Subject: [PATCH 09/17] core: SandboxBuilder::allow_degraded and ::disable polarity-out methods --- crates/sandlock-core/src/sandbox.rs | 34 +++++++++- .../tests/integration/test_protection.rs | 67 +++++++++++++++++++ 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index c255259..f97505a 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -11,7 +11,7 @@ use crate::context; use crate::error::SandboxError; pub use crate::http::{http_acl_check, normalize_path, prefix_or_exact_match, HttpRule}; pub use crate::network::{NetAllow, Protocol}; -use crate::protection::{Protection, ProtectionPolicy, ProtectionStatus}; +use crate::protection::{Protection, ProtectionPolicy, ProtectionState, ProtectionStatus}; /// A byte size value. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -1936,6 +1936,12 @@ pub struct SandboxBuilder { #[cfg_attr(feature = "cli", arg(long = "uid"))] pub uid: Option, + /// Per-protection state overrides. Defaults to `strict_all` — every + /// protection enforced, matching the historical `MIN_ABI = 6` floor. + /// Use the `allow_degraded` / `disable` builder methods to deviate. + #[cfg_attr(feature = "cli", clap(skip))] + pub protection_policy: ProtectionPolicy, + // Internal callback — never a CLI flag. #[cfg_attr(feature = "cli", clap(skip))] pub policy_fn: Option, @@ -2011,6 +2017,7 @@ impl Clone for SandboxBuilder { port_remap: self.port_remap, no_supervisor: self.no_supervisor, uid: self.uid, + protection_policy: self.protection_policy.clone(), policy_fn: self.policy_fn.clone(), name: self.name.clone(), // init_fn (FnOnce) cannot be cloned — drop to None. @@ -2022,6 +2029,29 @@ impl Clone for SandboxBuilder { } impl SandboxBuilder { + /// Permit `protection` to be enforced when the host kernel + /// supports it, and silently skipped when it does not (fallback + /// for kernels below the protection's `min_abi()`). + /// + /// The default policy enforces every protection strictly; calling + /// `allow_degraded` lifts the strictness for the named protection + /// only. `sandlock check` and `Sandbox::active_protections()` + /// continue to report the degraded protection so the posture is + /// observable. + pub fn allow_degraded(mut self, protection: Protection) -> Self { + self.protection_policy.set(protection, ProtectionState::Degradable); + self + } + + /// Never enforce `protection`, even on a host kernel that supports + /// it. Intended for workloads that legitimately need the capability + /// the protection blocks (e.g. signalling a sibling process when + /// `SignalScope` would normally prevent it). + pub fn disable(mut self, protection: Protection) -> Self { + self.protection_policy.set(protection, ProtectionState::Disabled); + self + } + pub fn fs_write(mut self, path: impl Into) -> Self { self.fs_writable.push(path.into()); self @@ -2369,7 +2399,7 @@ impl SandboxBuilder { fs_denied: self.fs_denied, extra_deny_syscalls: self.extra_deny_syscalls, extra_allow_syscalls: self.extra_allow_syscalls, - protection_policy: ProtectionPolicy::strict_all(), + protection_policy: self.protection_policy, net_allow, net_bind: self.net_bind, http_allow, diff --git a/crates/sandlock-core/tests/integration/test_protection.rs b/crates/sandlock-core/tests/integration/test_protection.rs index 072bff0..7928324 100644 --- a/crates/sandlock-core/tests/integration/test_protection.rs +++ b/crates/sandlock-core/tests/integration/test_protection.rs @@ -253,3 +253,70 @@ fn active_protections_reports_disabled_for_explicitly_off() { let signal = result.iter().find(|(p, _)| *p == Protection::SignalScope).unwrap(); assert_eq!(signal.1, ProtectionStatus::Disabled); } + +// ---------------------------------------------------------------------- +// SandboxBuilder::allow_degraded / ::disable polarity-out methods +// ---------------------------------------------------------------------- + +#[test] +fn builder_allow_degraded_sets_state_to_degradable() { + let sb = sandlock_core::Sandbox::builder() + .allow_degraded(Protection::SignalScope) + .build_unchecked() + .expect("build"); + assert_eq!( + sb.protection_policy.state(Protection::SignalScope), + ProtectionState::Degradable + ); +} + +#[test] +fn builder_disable_sets_state_to_disabled() { + let sb = sandlock_core::Sandbox::builder() + .disable(Protection::AbstractUnixScope) + .build_unchecked() + .expect("build"); + assert_eq!( + sb.protection_policy.state(Protection::AbstractUnixScope), + ProtectionState::Disabled + ); +} + +#[test] +fn builder_methods_are_idempotent_last_wins() { + let sb = sandlock_core::Sandbox::builder() + .allow_degraded(Protection::SignalScope) + .disable(Protection::SignalScope) + .build_unchecked() + .expect("build"); + assert_eq!( + sb.protection_policy.state(Protection::SignalScope), + ProtectionState::Disabled + ); +} + +#[test] +fn builder_methods_fluent_chain() { + let sb = sandlock_core::Sandbox::builder() + .allow_degraded(Protection::SignalScope) + .allow_degraded(Protection::AbstractUnixScope) + .disable(Protection::FsTruncate) + .build_unchecked() + .expect("build"); + assert_eq!( + sb.protection_policy.state(Protection::SignalScope), + ProtectionState::Degradable + ); + assert_eq!( + sb.protection_policy.state(Protection::AbstractUnixScope), + ProtectionState::Degradable + ); + assert_eq!( + sb.protection_policy.state(Protection::FsTruncate), + ProtectionState::Disabled + ); + assert_eq!( + sb.protection_policy.state(Protection::FsRefer), + ProtectionState::Strict + ); +} From 265b3c1a427fdf58d0a6a6915afeac5f1398ecf1 Mon Sep 17 00:00:00 2001 From: dzerik Date: Tue, 26 May 2026 15:21:02 +0300 Subject: [PATCH 10/17] ffi: C ABI for Protection + allow_degraded / disable builders --- crates/sandlock-ffi/include/sandlock.h | 36 +++++ crates/sandlock-ffi/src/lib.rs | 88 +++++++++++- crates/sandlock-ffi/tests/protection.rs | 177 ++++++++++++++++++++++++ 3 files changed, 300 insertions(+), 1 deletion(-) create mode 100644 crates/sandlock-ffi/tests/protection.rs diff --git a/crates/sandlock-ffi/include/sandlock.h b/crates/sandlock-ffi/include/sandlock.h index 0cf06d5..d34a7ba 100644 --- a/crates/sandlock-ffi/include/sandlock.h +++ b/crates/sandlock-ffi/include/sandlock.h @@ -56,6 +56,42 @@ sandlock_builder_t *sandlock_sandbox_builder_env_var(sandlock_builder_t *b, cons sandlock_builder_t *sandlock_sandbox_builder_no_randomize_memory(sandlock_builder_t *b, bool v); sandlock_builder_t *sandlock_sandbox_builder_no_huge_pages(sandlock_builder_t *b, bool v); +/* Landlock protections */ + +/** Per-protection Landlock feature identifier. + * + * Discriminant values mirror the Rust `Protection` enum in + * `sandlock_core::protection::Protection`. Stable across releases; new + * protections are appended at higher discriminants. */ +typedef enum { + SANDLOCK_PROTECTION_FS_REFER = 0, + SANDLOCK_PROTECTION_FS_TRUNCATE = 1, + SANDLOCK_PROTECTION_NET_TCP = 2, + SANDLOCK_PROTECTION_FS_IOCTL_DEV = 3, + SANDLOCK_PROTECTION_SIGNAL_SCOPE = 4, + SANDLOCK_PROTECTION_ABSTRACT_UNIX_SOCKET_SCOPE = 5, +} sandlock_protection_t; + +/** Minimum Landlock ABI version the host kernel must support for the + * given protection to be available. */ +uint32_t sandlock_protection_min_abi(sandlock_protection_t protection); + +/** Mark `protection` as degradable on the builder: enforced when the + * host kernel supports it, silently skipped otherwise. Returns the + * (possibly relocated) builder pointer; mirrors the move-semantics + * convention of the other builder setters. */ +sandlock_builder_t *sandlock_sandbox_builder_allow_degraded( + sandlock_builder_t *b, + sandlock_protection_t protection); + +/** Mark `protection` as disabled on the builder: never enforced, even + * on a host kernel that supports it. Returns the (possibly + * relocated) builder pointer; mirrors the move-semantics convention + * of the other builder setters. */ +sandlock_builder_t *sandlock_sandbox_builder_disable( + sandlock_builder_t *b, + sandlock_protection_t protection); + /* Build & free */ /* On failure, *err is set to -1 and *err_msg (if non-null) is set to a * heap-allocated C string with the error description. Caller frees it diff --git a/crates/sandlock-ffi/src/lib.rs b/crates/sandlock-ffi/src/lib.rs index 0ffbc98..b69ddc0 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, FsIsolation, SandboxBuilder}; -use sandlock_core::{Sandbox, RunResult}; +use sandlock_core::{Protection, Sandbox, RunResult}; pub mod handler; pub mod notif_repr; @@ -614,6 +614,92 @@ pub unsafe extern "C" fn sandlock_sandbox_builder_deterministic_dirs( Box::into_raw(Box::new(builder.deterministic_dirs(v))) } +// ---------------------------------------------------------------- +// Sandbox Builder — Landlock protections +// ---------------------------------------------------------------- + +/// Mirror of [`sandlock_core::Protection`] for the C ABI. +/// +/// Discriminant values are stable across releases; new protections are +/// appended. The order matches the Rust `Protection` enum (and +/// `Protection::all()`) so a discriminant can be converted to the Rust +/// variant by index. +#[repr(C)] +#[allow(non_camel_case_types)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum sandlock_protection_t { + FsRefer = 0, + FsTruncate = 1, + NetTcp = 2, + FsIoctlDev = 3, + SignalScope = 4, + AbstractUnixScopeSocket = 5, +} + +impl From for Protection { + fn from(p: sandlock_protection_t) -> Self { + match p { + sandlock_protection_t::FsRefer => Protection::FsRefer, + sandlock_protection_t::FsTruncate => Protection::FsTruncate, + sandlock_protection_t::NetTcp => Protection::NetTcp, + sandlock_protection_t::FsIoctlDev => Protection::FsIoctlDev, + sandlock_protection_t::SignalScope => Protection::SignalScope, + sandlock_protection_t::AbstractUnixScopeSocket => Protection::AbstractUnixScope, + } + } +} + +/// Per-protection minimum Landlock ABI version required by the host +/// kernel for this protection to be available. +#[no_mangle] +pub extern "C" fn sandlock_protection_min_abi(protection: sandlock_protection_t) -> u32 { + Protection::from(protection).min_abi() +} + +/// Mark `protection` as degradable on the builder: enforced when the +/// host kernel supports it, silently skipped otherwise. +/// +/// Returns the (possibly relocated) builder pointer, mirroring the +/// move-semantics convention used by every other +/// `sandlock_sandbox_builder_*` setter. A null `b` is returned +/// unchanged. +/// +/// # Safety +/// `b` must be a valid builder pointer returned by +/// `sandlock_sandbox_builder_new` (or a previous builder setter) and +/// not freed. +#[no_mangle] +pub unsafe extern "C" fn sandlock_sandbox_builder_allow_degraded( + b: *mut SandboxBuilder, + protection: sandlock_protection_t, +) -> *mut SandboxBuilder { + if b.is_null() { return b; } + let builder = *Box::from_raw(b); + Box::into_raw(Box::new(builder.allow_degraded(protection.into()))) +} + +/// Mark `protection` as disabled on the builder: never enforced, even +/// on a host kernel that supports it. +/// +/// Returns the (possibly relocated) builder pointer, mirroring the +/// move-semantics convention used by every other +/// `sandlock_sandbox_builder_*` setter. A null `b` is returned +/// unchanged. +/// +/// # Safety +/// `b` must be a valid builder pointer returned by +/// `sandlock_sandbox_builder_new` (or a previous builder setter) and +/// not freed. +#[no_mangle] +pub unsafe extern "C" fn sandlock_sandbox_builder_disable( + b: *mut SandboxBuilder, + protection: sandlock_protection_t, +) -> *mut SandboxBuilder { + if b.is_null() { return b; } + let builder = *Box::from_raw(b); + Box::into_raw(Box::new(builder.disable(protection.into()))) +} + // ---------------------------------------------------------------- // Sandbox Builder — build & free // ---------------------------------------------------------------- diff --git a/crates/sandlock-ffi/tests/protection.rs b/crates/sandlock-ffi/tests/protection.rs new file mode 100644 index 0000000..61ded58 --- /dev/null +++ b/crates/sandlock-ffi/tests/protection.rs @@ -0,0 +1,177 @@ +//! Integration tests for the C ABI `Protection` enum + builder setters. +//! +//! These tests drive the FFI symbols directly (no C compilation step) +//! and read back state through the public Rust `Sandbox` API to verify +//! the setters mutate the underlying `ProtectionPolicy`. + +use sandlock_core::{Protection, ProtectionState, Sandbox}; +use sandlock_ffi::{ + sandlock_protection_min_abi, sandlock_protection_t, + sandlock_sandbox_builder_allow_degraded, sandlock_sandbox_builder_disable, + sandlock_sandbox_builder_new, +}; + +#[test] +fn protection_min_abi_returns_kernel_documented_floors() { + // Discriminants in the C ABI must agree with Landlock's + // documented per-feature ABI floor. Drifting these numbers is a + // contract break with every external binding. + assert_eq!( + sandlock_protection_min_abi(sandlock_protection_t::FsRefer), + 2, + "FsRefer requires Landlock ABI v2", + ); + assert_eq!( + sandlock_protection_min_abi(sandlock_protection_t::FsTruncate), + 3, + "FsTruncate requires Landlock ABI v3", + ); + assert_eq!( + sandlock_protection_min_abi(sandlock_protection_t::NetTcp), + 4, + "NetTcp requires Landlock ABI v4", + ); + assert_eq!( + sandlock_protection_min_abi(sandlock_protection_t::FsIoctlDev), + 5, + "FsIoctlDev requires Landlock ABI v5", + ); + assert_eq!( + sandlock_protection_min_abi(sandlock_protection_t::SignalScope), + 6, + "SignalScope requires Landlock ABI v6", + ); + assert_eq!( + sandlock_protection_min_abi(sandlock_protection_t::AbstractUnixScopeSocket), + 6, + "AbstractUnixScope requires Landlock ABI v6", + ); +} + +#[test] +fn protection_discriminants_match_rust_enum_order() { + // `sandlock_protection_t` discriminants MUST mirror + // `Protection::all()` iteration order so external callers (Python + // ctypes, etc.) can convert via raw integer values. + let rust_order: Vec = Protection::all().collect(); + let c_order = [ + Protection::from(sandlock_protection_t::FsRefer), + Protection::from(sandlock_protection_t::FsTruncate), + Protection::from(sandlock_protection_t::NetTcp), + Protection::from(sandlock_protection_t::FsIoctlDev), + Protection::from(sandlock_protection_t::SignalScope), + Protection::from(sandlock_protection_t::AbstractUnixScopeSocket), + ]; + assert_eq!(rust_order, c_order); + + // And the discriminants themselves must be the index in that + // sequence (0..=5), which Python/ctypes wrappers rely on. + assert_eq!(sandlock_protection_t::FsRefer as u32, 0); + assert_eq!(sandlock_protection_t::FsTruncate as u32, 1); + assert_eq!(sandlock_protection_t::NetTcp as u32, 2); + assert_eq!(sandlock_protection_t::FsIoctlDev as u32, 3); + assert_eq!(sandlock_protection_t::SignalScope as u32, 4); + assert_eq!(sandlock_protection_t::AbstractUnixScopeSocket as u32, 5); +} + +/// Run a build sequence through the FFI: builder_new + the supplied +/// closure (typically chaining `allow_degraded` / `disable` setters) +/// + `build()`. Returns the resulting Sandbox so the caller can +/// inspect `protection_policy`. +fn build_via_ffi(configure: F) -> Sandbox +where + F: FnOnce(*mut sandlock_core::sandbox::SandboxBuilder) -> *mut sandlock_core::sandbox::SandboxBuilder, +{ + let b = sandlock_sandbox_builder_new(); + assert!(!b.is_null(), "builder_new returned null"); + let b = configure(b); + assert!(!b.is_null(), "configure returned null builder"); + // SAFETY: `b` is a valid Box pointer produced by builder_new and + // possibly relocated through builder setters. + let builder = unsafe { *Box::from_raw(b) }; + builder.build().expect("build failed") +} + +#[test] +fn builder_allow_degraded_marks_protection_degradable() { + let sandbox = build_via_ffi(|b| unsafe { + sandlock_sandbox_builder_allow_degraded(b, sandlock_protection_t::SignalScope) + }); + assert_eq!( + sandbox.protection_policy.state(Protection::SignalScope), + ProtectionState::Degradable, + ); + // Other protections stay strict (default). + assert_eq!( + sandbox.protection_policy.state(Protection::FsRefer), + ProtectionState::Strict, + ); +} + +#[test] +fn builder_disable_marks_protection_disabled() { + let sandbox = build_via_ffi(|b| unsafe { + sandlock_sandbox_builder_disable(b, sandlock_protection_t::AbstractUnixScopeSocket) + }); + assert_eq!( + sandbox.protection_policy.state(Protection::AbstractUnixScope), + ProtectionState::Disabled, + ); + assert_eq!( + sandbox.protection_policy.state(Protection::FsRefer), + ProtectionState::Strict, + ); +} + +#[test] +fn builder_setters_chain_and_last_call_wins() { + // disable after allow_degraded must end in Disabled (last writer + // wins, mirroring `ProtectionPolicy::set` semantics). + let sandbox = build_via_ffi(|b| unsafe { + let b = sandlock_sandbox_builder_allow_degraded(b, sandlock_protection_t::SignalScope); + let b = sandlock_sandbox_builder_disable(b, sandlock_protection_t::SignalScope); + // And opt-out two more protections in one chain. + let b = sandlock_sandbox_builder_allow_degraded(b, sandlock_protection_t::FsTruncate); + sandlock_sandbox_builder_disable(b, sandlock_protection_t::NetTcp) + }); + + assert_eq!( + sandbox.protection_policy.state(Protection::SignalScope), + ProtectionState::Disabled, + "last-writer-wins: disable after allow_degraded", + ); + assert_eq!( + sandbox.protection_policy.state(Protection::FsTruncate), + ProtectionState::Degradable, + ); + assert_eq!( + sandbox.protection_policy.state(Protection::NetTcp), + ProtectionState::Disabled, + ); + // Untouched protection stays Strict. + assert_eq!( + sandbox.protection_policy.state(Protection::FsIoctlDev), + ProtectionState::Strict, + ); +} + +#[test] +fn builder_setters_tolerate_null_builder() { + // Null in, null out — no panic. Matches the convention of every + // other `sandlock_sandbox_builder_*` setter. + let out = unsafe { + sandlock_sandbox_builder_allow_degraded( + std::ptr::null_mut(), + sandlock_protection_t::SignalScope, + ) + }; + assert!(out.is_null(), "allow_degraded(null, _) must return null"); + + let out = unsafe { + sandlock_sandbox_builder_disable( + std::ptr::null_mut(), + sandlock_protection_t::FsRefer, + ) + }; + assert!(out.is_null(), "disable(null, _) must return null"); +} From 53af1d1be806ddb1e37ca860aa18cbbce384fae6 Mon Sep 17 00:00:00 2001 From: dzerik Date: Tue, 26 May 2026 15:26:55 +0300 Subject: [PATCH 11/17] python: Sandbox allow_degraded / disable kwargs + Protection IntEnum --- python/src/sandlock/__init__.py | 2 + python/src/sandlock/_sdk.py | 45 +++++++++++++ python/src/sandlock/sandbox.py | 18 ++++++ python/tests/test_protection.py | 108 ++++++++++++++++++++++++++++++++ 4 files changed, 173 insertions(+) create mode 100644 python/tests/test_protection.py diff --git a/python/src/sandlock/__init__.py b/python/src/sandlock/__init__.py index fba0c52..9a73a61 100644 --- a/python/src/sandlock/__init__.py +++ b/python/src/sandlock/__init__.py @@ -9,6 +9,7 @@ from ._sdk import ( Stage, Pipeline, Result, SyscallEvent, PolicyContext, Checkpoint, NamedStage, Gather, GatherPipeline, + Protection, landlock_abi_version, min_landlock_abi, confine, ) from .inputs import inputs @@ -49,6 +50,7 @@ "parse_ports", "Change", "DryRunResult", + "Protection", # Handler ABI "Handler", "NotifAction", diff --git a/python/src/sandlock/_sdk.py b/python/src/sandlock/_sdk.py index 4c58038..c7e7b58 100644 --- a/python/src/sandlock/_sdk.py +++ b/python/src/sandlock/_sdk.py @@ -8,6 +8,7 @@ import signal import sys from dataclasses import dataclass, field +from enum import IntEnum from pathlib import Path from typing import Any, Sequence @@ -109,6 +110,37 @@ def _builder_fn(name, *extra_args): _b_deterministic_dirs = _builder_fn("sandlock_sandbox_builder_deterministic_dirs", ctypes.c_bool) _b_cpu_cores = _builder_fn("sandlock_sandbox_builder_cpu_cores", ctypes.POINTER(ctypes.c_uint32), ctypes.c_uint32) +# Protection opt-out — mirror of the C ABI `sandlock_protection_t`. +# Discriminant values must stay in sync with `sandlock_core::Protection` +# and `sandlock_protection_t` in `crates/sandlock-ffi/include/sandlock.h`. +class Protection(IntEnum): + """Per-protection Landlock feature identifier. + + Pass values from this enum to ``Sandbox(allow_degraded=...)`` or + ``Sandbox(disable=...)`` to opt out of strict enforcement for the + named protection. See the C header for kernel ABI requirements. + """ + + FS_REFER = 0 + FS_TRUNCATE = 1 + NET_TCP = 2 + FS_IOCTL_DEV = 3 + SIGNAL_SCOPE = 4 + ABSTRACT_UNIX_SOCKET_SCOPE = 5 + + +_lib.sandlock_protection_min_abi.restype = ctypes.c_uint32 +_lib.sandlock_protection_min_abi.argtypes = [ctypes.c_int] + +# Move-semantics setters: each returns the (possibly relocated) builder +# pointer, mirroring the convention of the other `_builder_fn` setters. +_b_allow_degraded = _builder_fn( + "sandlock_sandbox_builder_allow_degraded", ctypes.c_int +) +_b_disable = _builder_fn( + "sandlock_sandbox_builder_disable", ctypes.c_int +) + # Policy callback (policy_fn). # Path strings absent (issue #27 — path-based control belongs in Landlock). # argv is populated for execve only; TOCTOU-safe via sibling freeze. @@ -903,6 +935,8 @@ def __del__(self): "random_seed", "time_start", "clean_env", "env", "extra_deny_syscalls", "extra_allow_syscalls", "max_open_files", "no_randomize_memory", "no_huge_pages", "no_coredump", "deterministic_dirs", + # Landlock protection opt-out (see Protection IntEnum): + "allow_degraded", "disable", # Managed outside _build_from_policy: "notif_policy", # Runtime-only kwargs — not sent to FFI: @@ -1032,6 +1066,17 @@ def _build_from_policy(policy: PolicyDataclass): b = _b_no_coredump(b, True) if policy.deterministic_dirs: b = _b_deterministic_dirs(b, True) + + # Landlock protection opt-out. The C ABI setters use move-semantics + # and return the (possibly relocated) builder pointer — mirror that + # by rebinding `b` on each call. Idempotent / last-wins: if the + # same Protection appears in both lists, the later call wins + # (matching the underlying `ProtectionPolicy::set` semantics). + for p in (policy.allow_degraded or ()): + b = _b_allow_degraded(b, int(p)) + for p in (policy.disable or ()): + b = _b_disable(b, int(p)) + # Guard: warn if any dataclass field was set to a non-default value # but is not in _HANDLED_FIELDS (i.e. silently dropped). import dataclasses as _dc diff --git a/python/src/sandlock/sandbox.py b/python/src/sandlock/sandbox.py index cef1464..5fa3f35 100644 --- a/python/src/sandlock/sandbox.py +++ b/python/src/sandlock/sandbox.py @@ -338,6 +338,24 @@ class Sandbox: on_error: BranchAction = BranchAction.ABORT """Branch action on sandbox error/exception.""" + # Landlock protection opt-out — relax strict enforcement for the + # named protections. See ``sandlock.Protection`` (the IntEnum mirror + # of the C ABI ``sandlock_protection_t``). + allow_degraded: Sequence[int] = field(default_factory=list) + """Protections that may degrade silently on kernels that don't + support them. Each entry is a :class:`sandlock.Protection` value. + On a capable kernel the protection is still enforced strictly; on + an older kernel it is skipped instead of failing the build. + Idempotent / last-wins with :attr:`disable` (the later assignment + for a given protection wins).""" + + disable: Sequence[int] = field(default_factory=list) + """Protections that are never enforced, even on a host kernel that + supports them. Each entry is a :class:`sandlock.Protection` value. + Use this for a deliberate opt-out (e.g. to allow a workload to use + a protection-incompatible feature). Idempotent / last-wins with + :attr:`allow_degraded`.""" + # Runtime kwargs — not part of policy serialization. name: str | None = field(default=None, repr=False, metadata={"runtime": True}) """Sandbox name (also exposed as the virtual hostname inside the sandbox). diff --git a/python/tests/test_protection.py b/python/tests/test_protection.py new file mode 100644 index 0000000..c97651f --- /dev/null +++ b/python/tests/test_protection.py @@ -0,0 +1,108 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Python wrapper tests for Protection allow_degraded / disable kwargs. + +Covers the Python parity for the C ABI added in sandlock-ffi (see +``crates/sandlock-ffi/include/sandlock.h`` — ``sandlock_protection_t``, +``sandlock_protection_min_abi``, ``sandlock_sandbox_builder_allow_degraded``, +``sandlock_sandbox_builder_disable``). +""" + +import sandlock +from sandlock import Sandbox, Protection + + +def test_protection_intenum_min_abi_via_ffi(): + """`sandlock_protection_min_abi` returns the kernel ABI requirement + for each protection — sanity-check the IntEnum discriminants match + the C ABI by calling through the FFI.""" + from sandlock._sdk import _lib + + # SIGNAL_SCOPE — Landlock ABI v6 + assert _lib.sandlock_protection_min_abi(int(Protection.SIGNAL_SCOPE)) == 6 + # FS_TRUNCATE — Landlock ABI v3 + assert _lib.sandlock_protection_min_abi(int(Protection.FS_TRUNCATE)) == 3 + + +def test_sandbox_allow_degraded_kwarg_default_empty(): + """Both kwargs default to an empty sequence when omitted.""" + sb = Sandbox(fs_readable=["/usr"]) + assert list(sb.allow_degraded) == [] + assert list(sb.disable) == [] + + +def test_sandbox_allow_degraded_kwarg_accepts_protections(): + """`allow_degraded` accepts a sequence of Protection values and + preserves the order/identity on the dataclass.""" + sb = Sandbox( + fs_readable=["/usr"], + allow_degraded=[ + Protection.SIGNAL_SCOPE, + Protection.ABSTRACT_UNIX_SOCKET_SCOPE, + ], + ) + assert list(sb.allow_degraded) == [ + Protection.SIGNAL_SCOPE, + Protection.ABSTRACT_UNIX_SOCKET_SCOPE, + ] + + +def test_sandbox_disable_kwarg_accepts_protections(): + """`disable` accepts a sequence of Protection values and preserves + them on the dataclass.""" + sb = Sandbox(fs_readable=["/usr"], disable=[Protection.SIGNAL_SCOPE]) + assert list(sb.disable) == [Protection.SIGNAL_SCOPE] + + +def test_protection_is_intenum_with_correct_values(): + """Protection discriminants mirror the C ABI ``sandlock_protection_t`` + layout exactly (stable across releases).""" + assert Protection.FS_REFER == 0 + assert Protection.FS_TRUNCATE == 1 + assert Protection.NET_TCP == 2 + assert Protection.FS_IOCTL_DEV == 3 + assert Protection.SIGNAL_SCOPE == 4 + assert Protection.ABSTRACT_UNIX_SOCKET_SCOPE == 5 + # IntEnum: values usable as plain ints (the FFI takes c_int). + assert int(Protection.SIGNAL_SCOPE) == 4 + assert isinstance(Protection.SIGNAL_SCOPE, int) + + +def test_protection_reexported_from_top_level_package(): + """`Protection` is re-exported by the top-level package so callers + can do ``from sandlock import Protection`` without reaching into + ``_sdk``.""" + assert sandlock.Protection is Protection + assert "Protection" in sandlock.__all__ + + +def test_sandbox_build_with_protection_kwargs_does_not_raise(): + """Building the native policy with non-empty Protection kwargs must + succeed: this exercises the ctypes bindings end-to-end (the + move-semantics `b = _b_allow_degraded(b, ...)` rebind in + `_build_from_policy`).""" + from sandlock._sdk import _NativePolicy + + sb = Sandbox( + fs_readable=["/usr"], + allow_degraded=[Protection.SIGNAL_SCOPE], + disable=[Protection.ABSTRACT_UNIX_SOCKET_SCOPE], + ) + # Should not raise — and the resulting native policy owns a live + # pointer that is freed by __del__. + native = _NativePolicy.from_dataclass(sb) + assert native.ptr is not None and native.ptr != 0 + + +def test_sandbox_build_with_idempotent_protection_kwargs(): + """Repeating the same Protection in `allow_degraded` (or across + both kwargs) must not raise — the C-side `ProtectionPolicy::set` + is last-wins, and the Python wrapper just forwards values.""" + from sandlock._sdk import _NativePolicy + + sb = Sandbox( + fs_readable=["/usr"], + allow_degraded=[Protection.SIGNAL_SCOPE, Protection.SIGNAL_SCOPE], + disable=[Protection.SIGNAL_SCOPE], + ) + native = _NativePolicy.from_dataclass(sb) + assert native.ptr is not None and native.ptr != 0 From 2be594b170aeaa8759281cfa555329f7f44db171 Mon Sep 17 00:00:00 2001 From: dzerik Date: Tue, 26 May 2026 15:30:13 +0300 Subject: [PATCH 12/17] cli: --allow-degraded and --disable flags for sandlock run --- crates/sandlock-cli/src/main.rs | 39 +++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/crates/sandlock-cli/src/main.rs b/crates/sandlock-cli/src/main.rs index 3721cf5..89b0ef6 100644 --- a/crates/sandlock-cli/src/main.rs +++ b/crates/sandlock-cli/src/main.rs @@ -110,10 +110,41 @@ struct RunArgs { #[arg(long)] no_supervisor: bool, + /// Allow the named protection to degrade silently if the host kernel ABI lacks support. + /// Repeatable. Accepted values: fs-refer, fs-truncate, net-tcp, fs-ioctl-dev, + /// signal-scope, abstract-unix-scope-socket. + #[arg(long = "allow-degraded", value_name = "PROTECTION")] + allow_degraded: Vec, + + /// Disable the named protection entirely (no rule emitted, no error on missing ABI). + /// Repeatable. Accepts the same values as --allow-degraded. + #[arg(long = "disable", value_name = "PROTECTION")] + disable: Vec, + #[arg(last = true)] cmd: Vec, } +/// Parse a kebab-case protection name into a `Protection` value. +/// +/// Accepted strings (case-insensitive): `fs-refer`, `fs-truncate`, `net-tcp`, +/// `fs-ioctl-dev`, `signal-scope`, `abstract-unix-scope-socket` +/// (alias: `abstract-unix-socket-scope`). +fn parse_protection(s: &str) -> Result { + use sandlock_core::Protection; + match s.to_ascii_lowercase().as_str() { + "fs-refer" => Ok(Protection::FsRefer), + "fs-truncate" => Ok(Protection::FsTruncate), + "net-tcp" => Ok(Protection::NetTcp), + "fs-ioctl-dev" => Ok(Protection::FsIoctlDev), + "signal-scope" => Ok(Protection::SignalScope), + "abstract-unix-scope-socket" | "abstract-unix-socket-scope" => { + Ok(Protection::AbstractUnixScope) + } + other => Err(format!("unknown protection: {}", other)), + } +} + #[derive(Subcommand)] enum ProfileAction { /// List available profiles @@ -467,6 +498,14 @@ async fn run_command(args: RunArgs) -> Result<()> { builder = builder.no_supervisor(true); } + // CLI overrides — protection policy + for s in &args.allow_degraded { + builder = builder.allow_degraded(parse_protection(s).map_err(|e| anyhow!(e))?); + } + for s in &args.disable { + builder = builder.disable(parse_protection(s).map_err(|e| anyhow!(e))?); + } + let policy = builder.build()?; let cmd_strs: Vec<&str> = if let Some(ref shell_cmd) = args.exec_shell { vec!["/bin/sh", "-c", shell_cmd.as_str()] From b9db29e2b702ba43c9a9045e8062498d6393e4f4 Mon Sep 17 00:00:00 2001 From: dzerik Date: Tue, 26 May 2026 15:32:31 +0300 Subject: [PATCH 13/17] docs: document Protection opt-out (allow_degraded / disable) --- README.md | 3 +++ docs/extension-handlers.md | 42 ++++++++++++++++++++++++++++++++++++++ docs/python-handlers.md | 21 +++++++++++++++++++ 3 files changed, 66 insertions(+) diff --git a/README.md b/README.md index d491216..998fcca 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,9 @@ Sandlock is implemented in **Rust** for performance and safety: | Landlock TCP port rules | 6.7 (ABI v4) | | Landlock IPC scoping | 6.12 (ABI v6) | +Protections can be selectively waived per-policy when needed — see +[`docs/extension-handlers.md#protection-opt-out`](docs/extension-handlers.md#protection-opt-out). + ## Install ### From source diff --git a/docs/extension-handlers.md b/docs/extension-handlers.md index d494a03..d90915f 100644 --- a/docs/extension-handlers.md +++ b/docs/extension-handlers.md @@ -653,6 +653,48 @@ an opaque `void*`; the responsibility is on the C side. See `crates/sandlock-ffi/tests/c/handler_smoke.c` for the canonical end-to-end example. +## Protection opt-out + +By default sandlock enforces every Landlock protection the host kernel +supports and refuses to start when a required protection is +unavailable. Two builder methods on `SandboxBuilder` let callers opt +out of the strict default on a per-protection basis: + +- `allow_degraded(Protection::P)` — enforce `P` where the host kernel + supports it, silently skip it where it does not. Use this when + deploying across a mixed fleet of kernels where some lack the + protection. +- `disable(Protection::P)` — never enforce `P`, even on a kernel that + supports it. Use this when the workload legitimately needs the + capability the protection blocks (for example signalling a sibling + process when `SignalScope` would otherwise prevent it). + +Calling neither method leaves the protection in its default `Strict` +state. The two methods are last-wins per protection: a later call for +the same `Protection` value supersedes the earlier one. + +`sandlock check` reports each protection's availability against the +host's Landlock ABI; `Sandbox::active_protections()` returns the +per-protection resolved status (`Active`, `Degraded`, `Disabled`, or +`Unavailable`) of a constructed `Sandbox`. + +Example: + +```rust +use sandlock_core::{Protection, Sandbox}; + +let sb = Sandbox::builder() + .fs_read("/data") + .fs_write("/tmp") + .allow_degraded(Protection::SignalScope) + .allow_degraded(Protection::AbstractUnixScope) + .build()?; +``` + +The two `allow_degraded` calls let the sandbox build on Linux kernels +below 6.12, where the v6 IPC scopes are unavailable. On a kernel that +does support them, the scopes remain enforced. + ## Python wrapper See [`python-handlers.md`](python-handlers.md) — the dedicated page is the diff --git a/docs/python-handlers.md b/docs/python-handlers.md index c53cdb2..f3bdeac 100644 --- a/docs/python-handlers.md +++ b/docs/python-handlers.md @@ -270,6 +270,27 @@ syscall list rather than against an empty dispatch table. was actually dispatched — the supervisor handles cleanup on all paths. +## Protection opt-out + +The Python wrapper exposes the same opt-out mechanism as the Rust +builder, via two keyword arguments on `Sandbox`: + +```python +from sandlock import Sandbox, Protection + +sb = Sandbox( + fs_readable=["/data"], + fs_writable=["/tmp"], + allow_degraded=[Protection.SIGNAL_SCOPE, Protection.ABSTRACT_UNIX_SOCKET_SCOPE], +) +``` + +`disable=[Protection.X, ...]` is the parallel kwarg for protections +that should never be enforced even on capable kernels. See the +"Protection opt-out" section of +[`extension-handlers.md`](extension-handlers.md#protection-opt-out) for +the full semantics. + ## C ABI The Python wrapper sits on the C ABI declared in From ceae31c29bdac30961574cddebae826ff6fd49cd Mon Sep 17 00:00:00 2001 From: dzerik Date: Wed, 27 May 2026 09:35:18 +0300 Subject: [PATCH 14/17] ffi: validate Protection discriminant at the C ABI boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Protection setters took `sandlock_protection_t` and matched on it exhaustively, so a C or Python caller passing an integer outside the known discriminant range (0..=5) produced undefined behaviour at the Rust match — `#[repr(C)]` enums are UB to construct from arbitrary bits. Change the three entry-points (`sandlock_protection_min_abi`, `sandlock_sandbox_builder_allow_degraded`, `sandlock_sandbox_builder_disable`) to accept `u32` and route every incoming value through `try_protection_from_raw`. Unknown values are now handled at the boundary: - `min_abi(unknown)` returns 0 — a sentinel that cannot collide with any real `min_abi()` (those start at 2). - The builder setters return the input pointer untouched, mirroring the null-builder convention already used elsewhere in the C ABI. The Python wrapper adds a stricter guard: an out-of-range int raises `ValueError` at SDK boundary rather than silently no-op'ing through the FFI, because the Python contract should fail loudly on a typed mistake. Update the C header to declare the new signatures (`uint32_t` instead of the enum type) and document the sentinel and no-op behaviour. The `sandlock_protection_t` enum is kept as a labelling type for callers who want the names; passing an enum constant still works because C implicitly promotes to `uint32_t`. Tests: - 3 new FFI regression tests cover the boundary: min_abi sentinel, setter no-op, and "bad call then good call" to catch builder corruption in the bad path. - 4 new Python tests cover ValueError on out-of-range, negative, and well-formed plain-int inputs. --- crates/sandlock-ffi/include/sandlock.h | 29 +++-- crates/sandlock-ffi/src/lib.rs | 91 +++++++------ crates/sandlock-ffi/tests/protection.rs | 164 +++++++++++++++++------- python/src/sandlock/_sdk.py | 29 ++++- python/tests/test_protection.py | 54 ++++++++ 5 files changed, 272 insertions(+), 95 deletions(-) diff --git a/crates/sandlock-ffi/include/sandlock.h b/crates/sandlock-ffi/include/sandlock.h index d34a7ba..6812db7 100644 --- a/crates/sandlock-ffi/include/sandlock.h +++ b/crates/sandlock-ffi/include/sandlock.h @@ -61,8 +61,15 @@ sandlock_builder_t *sandlock_sandbox_builder_no_huge_pages(sandlock_builder_t *b /** Per-protection Landlock feature identifier. * * Discriminant values mirror the Rust `Protection` enum in - * `sandlock_core::protection::Protection`. Stable across releases; new - * protections are appended at higher discriminants. */ + * `sandlock_core::protection::Protection`. Stable across releases; + * new protections are appended at higher discriminants. Old + * discriminants are never reused. + * + * Functions below take the discriminant as `uint32_t` (not the enum + * type) so an out-of-range value from a C caller is rejected at the + * Rust boundary instead of triggering undefined behaviour. Callers + * should pass one of the constants below; any other value is treated + * as documented for each function. */ typedef enum { SANDLOCK_PROTECTION_FS_REFER = 0, SANDLOCK_PROTECTION_FS_TRUNCATE = 1, @@ -73,24 +80,30 @@ typedef enum { } sandlock_protection_t; /** Minimum Landlock ABI version the host kernel must support for the - * given protection to be available. */ -uint32_t sandlock_protection_min_abi(sandlock_protection_t protection); + * given protection to be available. + * + * Returns 0 for any `protection` value that is not a known + * discriminant. (0 is below every real `min_abi` — those start at + * 2 — so 0 functions as an unambiguous "unknown protection" sentinel.) */ +uint32_t sandlock_protection_min_abi(uint32_t protection); /** Mark `protection` as degradable on the builder: enforced when the * host kernel supports it, silently skipped otherwise. Returns the * (possibly relocated) builder pointer; mirrors the move-semantics - * convention of the other builder setters. */ + * convention of the other builder setters. An unknown `protection` + * value is treated as a no-op (the builder is returned unchanged). */ sandlock_builder_t *sandlock_sandbox_builder_allow_degraded( sandlock_builder_t *b, - sandlock_protection_t protection); + uint32_t protection); /** Mark `protection` as disabled on the builder: never enforced, even * on a host kernel that supports it. Returns the (possibly * relocated) builder pointer; mirrors the move-semantics convention - * of the other builder setters. */ + * of the other builder setters. An unknown `protection` value is + * treated as a no-op. */ sandlock_builder_t *sandlock_sandbox_builder_disable( sandlock_builder_t *b, - sandlock_protection_t protection); + uint32_t protection); /* Build & free */ /* On failure, *err is set to -1 and *err_msg (if non-null) is set to a diff --git a/crates/sandlock-ffi/src/lib.rs b/crates/sandlock-ffi/src/lib.rs index b69ddc0..740f2cf 100644 --- a/crates/sandlock-ffi/src/lib.rs +++ b/crates/sandlock-ffi/src/lib.rs @@ -618,42 +618,51 @@ pub unsafe extern "C" fn sandlock_sandbox_builder_deterministic_dirs( // Sandbox Builder — Landlock protections // ---------------------------------------------------------------- -/// Mirror of [`sandlock_core::Protection`] for the C ABI. -/// -/// Discriminant values are stable across releases; new protections are -/// appended. The order matches the Rust `Protection` enum (and -/// `Protection::all()`) so a discriminant can be converted to the Rust -/// variant by index. -#[repr(C)] -#[allow(non_camel_case_types)] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum sandlock_protection_t { - FsRefer = 0, - FsTruncate = 1, - NetTcp = 2, - FsIoctlDev = 3, - SignalScope = 4, - AbstractUnixScopeSocket = 5, -} - -impl From for Protection { - fn from(p: sandlock_protection_t) -> Self { - match p { - sandlock_protection_t::FsRefer => Protection::FsRefer, - sandlock_protection_t::FsTruncate => Protection::FsTruncate, - sandlock_protection_t::NetTcp => Protection::NetTcp, - sandlock_protection_t::FsIoctlDev => Protection::FsIoctlDev, - sandlock_protection_t::SignalScope => Protection::SignalScope, - sandlock_protection_t::AbstractUnixScopeSocket => Protection::AbstractUnixScope, - } +/// C ABI discriminants mirroring [`sandlock_core::Protection`]. +/// +/// The Rust entry-points below accept the discriminant as a `u32` +/// (rather than a `#[repr(C)]` enum) so that an out-of-range value +/// from a C or Python caller is rejected at the boundary instead of +/// reaching a Rust `match` over an enum and producing undefined +/// behaviour. +/// +/// New protections are appended at higher values; old discriminants +/// are never reused. +const PROT_FS_REFER: u32 = 0; +const PROT_FS_TRUNCATE: u32 = 1; +const PROT_NET_TCP: u32 = 2; +const PROT_FS_IOCTL_DEV: u32 = 3; +const PROT_SIGNAL_SCOPE: u32 = 4; +const PROT_ABSTRACT_UNIX_SOCKET_SCOPE: u32 = 5; + +/// Convert a raw discriminant into a `Protection`, returning `None` +/// for values not in the known range. Centralises the validation that +/// guards every C-ABI entry-point. +fn try_protection_from_raw(raw: u32) -> Option { + match raw { + PROT_FS_REFER => Some(Protection::FsRefer), + PROT_FS_TRUNCATE => Some(Protection::FsTruncate), + PROT_NET_TCP => Some(Protection::NetTcp), + PROT_FS_IOCTL_DEV => Some(Protection::FsIoctlDev), + PROT_SIGNAL_SCOPE => Some(Protection::SignalScope), + PROT_ABSTRACT_UNIX_SOCKET_SCOPE => Some(Protection::AbstractUnixScope), + _ => None, } } /// Per-protection minimum Landlock ABI version required by the host /// kernel for this protection to be available. +/// +/// Returns `0` for any `protection` value that is not a known +/// discriminant — `0` is below every real `min_abi()` (which start at +/// `2`), so callers can use it as an "unknown protection" sentinel +/// without colliding with a valid version number. #[no_mangle] -pub extern "C" fn sandlock_protection_min_abi(protection: sandlock_protection_t) -> u32 { - Protection::from(protection).min_abi() +pub extern "C" fn sandlock_protection_min_abi(protection: u32) -> u32 { + match try_protection_from_raw(protection) { + Some(p) => p.min_abi(), + None => 0, + } } /// Mark `protection` as degradable on the builder: enforced when the @@ -662,7 +671,8 @@ pub extern "C" fn sandlock_protection_min_abi(protection: sandlock_protection_t) /// Returns the (possibly relocated) builder pointer, mirroring the /// move-semantics convention used by every other /// `sandlock_sandbox_builder_*` setter. A null `b` is returned -/// unchanged. +/// unchanged. An unknown `protection` discriminant is treated as a +/// no-op: the builder is returned untouched. /// /// # Safety /// `b` must be a valid builder pointer returned by @@ -671,11 +681,15 @@ pub extern "C" fn sandlock_protection_min_abi(protection: sandlock_protection_t) #[no_mangle] pub unsafe extern "C" fn sandlock_sandbox_builder_allow_degraded( b: *mut SandboxBuilder, - protection: sandlock_protection_t, + protection: u32, ) -> *mut SandboxBuilder { if b.is_null() { return b; } + let p = match try_protection_from_raw(protection) { + Some(p) => p, + None => return b, + }; let builder = *Box::from_raw(b); - Box::into_raw(Box::new(builder.allow_degraded(protection.into()))) + Box::into_raw(Box::new(builder.allow_degraded(p))) } /// Mark `protection` as disabled on the builder: never enforced, even @@ -684,7 +698,8 @@ pub unsafe extern "C" fn sandlock_sandbox_builder_allow_degraded( /// Returns the (possibly relocated) builder pointer, mirroring the /// move-semantics convention used by every other /// `sandlock_sandbox_builder_*` setter. A null `b` is returned -/// unchanged. +/// unchanged. An unknown `protection` discriminant is treated as a +/// no-op: the builder is returned untouched. /// /// # Safety /// `b` must be a valid builder pointer returned by @@ -693,11 +708,15 @@ pub unsafe extern "C" fn sandlock_sandbox_builder_allow_degraded( #[no_mangle] pub unsafe extern "C" fn sandlock_sandbox_builder_disable( b: *mut SandboxBuilder, - protection: sandlock_protection_t, + protection: u32, ) -> *mut SandboxBuilder { if b.is_null() { return b; } + let p = match try_protection_from_raw(protection) { + Some(p) => p, + None => return b, + }; let builder = *Box::from_raw(b); - Box::into_raw(Box::new(builder.disable(protection.into()))) + Box::into_raw(Box::new(builder.disable(p))) } // ---------------------------------------------------------------- diff --git a/crates/sandlock-ffi/tests/protection.rs b/crates/sandlock-ffi/tests/protection.rs index 61ded58..4fae9c0 100644 --- a/crates/sandlock-ffi/tests/protection.rs +++ b/crates/sandlock-ffi/tests/protection.rs @@ -1,77 +1,79 @@ -//! Integration tests for the C ABI `Protection` enum + builder setters. +//! Integration tests for the C ABI `Protection` setters. //! //! These tests drive the FFI symbols directly (no C compilation step) //! and read back state through the public Rust `Sandbox` API to verify //! the setters mutate the underlying `ProtectionPolicy`. +//! +//! Protection discriminants are passed as raw `u32` (matching the C ABI +//! and Python ctypes signatures). The `PROT_*` constants below mirror +//! the values defined in `crates/sandlock-ffi/include/sandlock.h`. use sandlock_core::{Protection, ProtectionState, Sandbox}; use sandlock_ffi::{ - sandlock_protection_min_abi, sandlock_protection_t, - sandlock_sandbox_builder_allow_degraded, sandlock_sandbox_builder_disable, - sandlock_sandbox_builder_new, + sandlock_protection_min_abi, sandlock_sandbox_builder_allow_degraded, + sandlock_sandbox_builder_disable, sandlock_sandbox_builder_new, }; +const PROT_FS_REFER: u32 = 0; +const PROT_FS_TRUNCATE: u32 = 1; +const PROT_NET_TCP: u32 = 2; +const PROT_FS_IOCTL_DEV: u32 = 3; +const PROT_SIGNAL_SCOPE: u32 = 4; +const PROT_ABSTRACT_UNIX_SOCKET_SCOPE: u32 = 5; + #[test] fn protection_min_abi_returns_kernel_documented_floors() { // Discriminants in the C ABI must agree with Landlock's // documented per-feature ABI floor. Drifting these numbers is a // contract break with every external binding. assert_eq!( - sandlock_protection_min_abi(sandlock_protection_t::FsRefer), + sandlock_protection_min_abi(PROT_FS_REFER), 2, "FsRefer requires Landlock ABI v2", ); assert_eq!( - sandlock_protection_min_abi(sandlock_protection_t::FsTruncate), + sandlock_protection_min_abi(PROT_FS_TRUNCATE), 3, "FsTruncate requires Landlock ABI v3", ); assert_eq!( - sandlock_protection_min_abi(sandlock_protection_t::NetTcp), + sandlock_protection_min_abi(PROT_NET_TCP), 4, "NetTcp requires Landlock ABI v4", ); assert_eq!( - sandlock_protection_min_abi(sandlock_protection_t::FsIoctlDev), + sandlock_protection_min_abi(PROT_FS_IOCTL_DEV), 5, "FsIoctlDev requires Landlock ABI v5", ); assert_eq!( - sandlock_protection_min_abi(sandlock_protection_t::SignalScope), + sandlock_protection_min_abi(PROT_SIGNAL_SCOPE), 6, "SignalScope requires Landlock ABI v6", ); assert_eq!( - sandlock_protection_min_abi(sandlock_protection_t::AbstractUnixScopeSocket), + sandlock_protection_min_abi(PROT_ABSTRACT_UNIX_SOCKET_SCOPE), 6, "AbstractUnixScope requires Landlock ABI v6", ); } #[test] -fn protection_discriminants_match_rust_enum_order() { - // `sandlock_protection_t` discriminants MUST mirror - // `Protection::all()` iteration order so external callers (Python - // ctypes, etc.) can convert via raw integer values. +fn protection_discriminants_cover_rust_enum_in_order() { + // The C ABI discriminants MUST mirror `Protection::all()` iteration + // order so external callers (Python ctypes, hand-written C) can + // index via the raw integer. let rust_order: Vec = Protection::all().collect(); - let c_order = [ - Protection::from(sandlock_protection_t::FsRefer), - Protection::from(sandlock_protection_t::FsTruncate), - Protection::from(sandlock_protection_t::NetTcp), - Protection::from(sandlock_protection_t::FsIoctlDev), - Protection::from(sandlock_protection_t::SignalScope), - Protection::from(sandlock_protection_t::AbstractUnixScopeSocket), - ]; - assert_eq!(rust_order, c_order); - - // And the discriminants themselves must be the index in that - // sequence (0..=5), which Python/ctypes wrappers rely on. - assert_eq!(sandlock_protection_t::FsRefer as u32, 0); - assert_eq!(sandlock_protection_t::FsTruncate as u32, 1); - assert_eq!(sandlock_protection_t::NetTcp as u32, 2); - assert_eq!(sandlock_protection_t::FsIoctlDev as u32, 3); - assert_eq!(sandlock_protection_t::SignalScope as u32, 4); - assert_eq!(sandlock_protection_t::AbstractUnixScopeSocket as u32, 5); + assert_eq!(rust_order.len(), 6, "if a new protection lands, extend the FFI discriminants and the PROT_* constants"); + assert_eq!(rust_order[PROT_FS_REFER as usize], Protection::FsRefer); + assert_eq!(rust_order[PROT_FS_TRUNCATE as usize], Protection::FsTruncate); + assert_eq!(rust_order[PROT_NET_TCP as usize], Protection::NetTcp); + assert_eq!(rust_order[PROT_FS_IOCTL_DEV as usize], Protection::FsIoctlDev); + assert_eq!(rust_order[PROT_SIGNAL_SCOPE as usize], Protection::SignalScope); + assert_eq!( + rust_order[PROT_ABSTRACT_UNIX_SOCKET_SCOPE as usize], + Protection::AbstractUnixScope, + ); } /// Run a build sequence through the FFI: builder_new + the supplied @@ -95,7 +97,7 @@ where #[test] fn builder_allow_degraded_marks_protection_degradable() { let sandbox = build_via_ffi(|b| unsafe { - sandlock_sandbox_builder_allow_degraded(b, sandlock_protection_t::SignalScope) + sandlock_sandbox_builder_allow_degraded(b, PROT_SIGNAL_SCOPE) }); assert_eq!( sandbox.protection_policy.state(Protection::SignalScope), @@ -111,7 +113,7 @@ fn builder_allow_degraded_marks_protection_degradable() { #[test] fn builder_disable_marks_protection_disabled() { let sandbox = build_via_ffi(|b| unsafe { - sandlock_sandbox_builder_disable(b, sandlock_protection_t::AbstractUnixScopeSocket) + sandlock_sandbox_builder_disable(b, PROT_ABSTRACT_UNIX_SOCKET_SCOPE) }); assert_eq!( sandbox.protection_policy.state(Protection::AbstractUnixScope), @@ -128,11 +130,11 @@ fn builder_setters_chain_and_last_call_wins() { // disable after allow_degraded must end in Disabled (last writer // wins, mirroring `ProtectionPolicy::set` semantics). let sandbox = build_via_ffi(|b| unsafe { - let b = sandlock_sandbox_builder_allow_degraded(b, sandlock_protection_t::SignalScope); - let b = sandlock_sandbox_builder_disable(b, sandlock_protection_t::SignalScope); + let b = sandlock_sandbox_builder_allow_degraded(b, PROT_SIGNAL_SCOPE); + let b = sandlock_sandbox_builder_disable(b, PROT_SIGNAL_SCOPE); // And opt-out two more protections in one chain. - let b = sandlock_sandbox_builder_allow_degraded(b, sandlock_protection_t::FsTruncate); - sandlock_sandbox_builder_disable(b, sandlock_protection_t::NetTcp) + let b = sandlock_sandbox_builder_allow_degraded(b, PROT_FS_TRUNCATE); + sandlock_sandbox_builder_disable(b, PROT_NET_TCP) }); assert_eq!( @@ -160,18 +162,88 @@ fn builder_setters_tolerate_null_builder() { // Null in, null out — no panic. Matches the convention of every // other `sandlock_sandbox_builder_*` setter. let out = unsafe { - sandlock_sandbox_builder_allow_degraded( - std::ptr::null_mut(), - sandlock_protection_t::SignalScope, - ) + sandlock_sandbox_builder_allow_degraded(std::ptr::null_mut(), PROT_SIGNAL_SCOPE) }; assert!(out.is_null(), "allow_degraded(null, _) must return null"); let out = unsafe { - sandlock_sandbox_builder_disable( - std::ptr::null_mut(), - sandlock_protection_t::FsRefer, - ) + sandlock_sandbox_builder_disable(std::ptr::null_mut(), PROT_FS_REFER) }; assert!(out.is_null(), "disable(null, _) must return null"); } + +// ---------------------------------------------------------------- +// Out-of-range discriminant guards. The Rust entry-points must reject +// unknown values at the boundary; reaching a Rust `match` over a +// `#[repr(C)]` enum with an out-of-range integer was undefined +// behaviour in the original implementation. These tests pin the new +// validating behaviour: setters become no-ops, `min_abi` returns the +// 0 sentinel. +// ---------------------------------------------------------------- + +const INVALID_DISCRIMINANTS: &[u32] = &[6, 7, 42, 99, 1_000, u32::MAX]; + +#[test] +fn protection_min_abi_returns_zero_sentinel_for_unknown_discriminant() { + for &raw in INVALID_DISCRIMINANTS { + assert_eq!( + sandlock_protection_min_abi(raw), + 0, + "min_abi({}) must return the 0 sentinel for an unknown discriminant", + raw, + ); + } +} + +#[test] +fn allow_degraded_with_unknown_discriminant_is_a_noop() { + // The builder pointer must be returned untouched, and the + // resulting Sandbox must have no `Degradable` state set. + for &raw in INVALID_DISCRIMINANTS { + let sandbox = build_via_ffi(|b| unsafe { + sandlock_sandbox_builder_allow_degraded(b, raw) + }); + for p in Protection::all() { + assert_eq!( + sandbox.protection_policy.state(p), + ProtectionState::Strict, + "raw discriminant {} must leave {:?} at the default Strict state", + raw, p, + ); + } + } +} + +#[test] +fn disable_with_unknown_discriminant_is_a_noop() { + for &raw in INVALID_DISCRIMINANTS { + let sandbox = build_via_ffi(|b| unsafe { + sandlock_sandbox_builder_disable(b, raw) + }); + for p in Protection::all() { + assert_eq!( + sandbox.protection_policy.state(p), + ProtectionState::Strict, + "raw discriminant {} must leave {:?} at the default Strict state", + raw, p, + ); + } + } +} + +#[test] +fn unknown_discriminant_does_not_corrupt_subsequent_valid_calls() { + // A bad call must not poison the builder — a following valid call + // must succeed normally. Catches a class of bug where the bad path + // leaks/double-frees the builder allocation. + let sandbox = build_via_ffi(|b| unsafe { + let b = sandlock_sandbox_builder_allow_degraded(b, 9999); + let b = sandlock_sandbox_builder_disable(b, u32::MAX); + sandlock_sandbox_builder_disable(b, PROT_SIGNAL_SCOPE) + }); + assert_eq!( + sandbox.protection_policy.state(Protection::SignalScope), + ProtectionState::Disabled, + "valid call after two invalid ones must still take effect", + ); +} diff --git a/python/src/sandlock/_sdk.py b/python/src/sandlock/_sdk.py index c7e7b58..7bb0834 100644 --- a/python/src/sandlock/_sdk.py +++ b/python/src/sandlock/_sdk.py @@ -130,17 +130,36 @@ class Protection(IntEnum): _lib.sandlock_protection_min_abi.restype = ctypes.c_uint32 -_lib.sandlock_protection_min_abi.argtypes = [ctypes.c_int] +_lib.sandlock_protection_min_abi.argtypes = [ctypes.c_uint32] # Move-semantics setters: each returns the (possibly relocated) builder # pointer, mirroring the convention of the other `_builder_fn` setters. +# The C ABI accepts the protection as a `uint32_t` so an out-of-range +# value is rejected at the FFI boundary (no `#[repr(C)]` enum cast). _b_allow_degraded = _builder_fn( - "sandlock_sandbox_builder_allow_degraded", ctypes.c_int + "sandlock_sandbox_builder_allow_degraded", ctypes.c_uint32 ) _b_disable = _builder_fn( - "sandlock_sandbox_builder_disable", ctypes.c_int + "sandlock_sandbox_builder_disable", ctypes.c_uint32 ) + +def _validate_protection(p: int, *, field: str) -> int: + """Coerce a caller-supplied protection value to a known discriminant + or raise :class:`ValueError`. Centralises the range check so the FFI + is never invoked with an unknown integer (the Rust setters silently + no-op on bad input, which is the wrong UX for the Python caller — + we want a loud failure at the SDK boundary instead). + """ + try: + return int(Protection(int(p))) + except (ValueError, TypeError) as e: + valid = ", ".join(f"{m.name}={int(m)}" for m in Protection) + raise ValueError( + f"{field}: {p!r} is not a known Protection discriminant " + f"(valid: {valid})" + ) from e + # Policy callback (policy_fn). # Path strings absent (issue #27 — path-based control belongs in Landlock). # argv is populated for execve only; TOCTOU-safe via sibling freeze. @@ -1073,9 +1092,9 @@ def _build_from_policy(policy: PolicyDataclass): # same Protection appears in both lists, the later call wins # (matching the underlying `ProtectionPolicy::set` semantics). for p in (policy.allow_degraded or ()): - b = _b_allow_degraded(b, int(p)) + b = _b_allow_degraded(b, _validate_protection(p, field="allow_degraded")) for p in (policy.disable or ()): - b = _b_disable(b, int(p)) + b = _b_disable(b, _validate_protection(p, field="disable")) # Guard: warn if any dataclass field was set to a non-default value # but is not in _HANDLED_FIELDS (i.e. silently dropped). diff --git a/python/tests/test_protection.py b/python/tests/test_protection.py index c97651f..4937d3b 100644 --- a/python/tests/test_protection.py +++ b/python/tests/test_protection.py @@ -106,3 +106,57 @@ def test_sandbox_build_with_idempotent_protection_kwargs(): ) native = _NativePolicy.from_dataclass(sb) assert native.ptr is not None and native.ptr != 0 + + +# -------------------------------------------------------------- +# Out-of-range protection int — the SDK must raise `ValueError` +# before reaching the FFI. The Rust setters tolerate unknown +# discriminants as a no-op, but the Python contract is loud failure +# (silent no-op is the wrong UX when the caller typed a wrong int). +# -------------------------------------------------------------- + + +def test_sandbox_build_rejects_out_of_range_protection_int(): + """An integer outside the known `Protection` enum range raises + `ValueError` at build time — before reaching the FFI.""" + import pytest + + from sandlock._sdk import _NativePolicy + + sb = Sandbox(fs_readable=["/usr"], allow_degraded=[99]) + with pytest.raises(ValueError, match="allow_degraded"): + _NativePolicy.from_dataclass(sb) + + +def test_sandbox_build_rejects_out_of_range_in_disable(): + """Same guard applies to the `disable` kwarg.""" + import pytest + + from sandlock._sdk import _NativePolicy + + sb = Sandbox(fs_readable=["/usr"], disable=[100, 200]) + with pytest.raises(ValueError, match="disable"): + _NativePolicy.from_dataclass(sb) + + +def test_sandbox_build_rejects_negative_protection_int(): + """Negative ints are not valid Protection discriminants — must + raise rather than wrap to a large unsigned value at the FFI.""" + import pytest + + from sandlock._sdk import _NativePolicy + + sb = Sandbox(fs_readable=["/usr"], allow_degraded=[-1]) + with pytest.raises(ValueError): + _NativePolicy.from_dataclass(sb) + + +def test_sandbox_build_accepts_plain_int_in_valid_range(): + """Callers using plain `int` (not the `Protection` IntEnum) for + values in the valid range must still succeed — the validator + coerces through `Protection(int)`.""" + from sandlock._sdk import _NativePolicy + + sb = Sandbox(fs_readable=["/usr"], allow_degraded=[4]) # 4 == SIGNAL_SCOPE + native = _NativePolicy.from_dataclass(sb) + assert native.ptr is not None and native.ptr != 0 From a7c726d1bdb1ccff0769098c4b8ed2e0175a43f9 Mon Sep 17 00:00:00 2001 From: dzerik Date: Wed, 27 May 2026 09:38:22 +0300 Subject: [PATCH 15/17] protection: rename AbstractUnixScope to AbstractUnixSocketScope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous name omitted the noun "Socket" — reading "abstract unix scope" does not parse, and the kernel constant is `LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET` (where SCOPE is the family, not part of the protection's name). The other v6 scope already uses the `Signal` + `Scope` pattern; mirror it. Before this commit the same protection was spelled four different ways across the bindings: | Layer | Old name | |--------|--------------------------------| | Rust | `Protection::AbstractUnixScope` | | C ABI | `AbstractUnixScopeSocket` | | Python | `ABSTRACT_UNIX_SOCKET_SCOPE` | | CLI | `abstract-unix-scope-socket` | After this commit all four agree on the canonical `AbstractUnixSocketScope` / `abstract-unix-socket-scope` form, which also matches the existing `Protection::SignalScope` / `signal-scope` pattern. Updates touch: - `Protection` enum (and every match arm in core / FFI / tests). - C ABI: the discriminant value at index 5 is unchanged (`PROT_ABSTRACT_UNIX_SOCKET_SCOPE` / `SANDLOCK_PROTECTION_ABSTRACT_UNIX_SOCKET_SCOPE` in the header already match this spelling). - CLI parser: the primary string is now `abstract-unix-socket-scope`; the previous `abstract-unix-scope-socket` is kept as an alias so any out-in-the-wild script still parses. Help text and error message updated to the canonical name. - Python re-export: `Protection.ABSTRACT_UNIX_SOCKET_SCOPE` was already canonical; the IntEnum is unchanged. No behaviour change. 287 lib + 18 integration + 10 FFI + 12 Python tests still pass. --- crates/sandlock-cli/src/main.rs | 17 +++++++++++------ crates/sandlock-core/src/landlock.rs | 2 +- crates/sandlock-core/src/protection.rs | 10 +++++----- .../tests/integration/test_protection.rs | 12 ++++++------ crates/sandlock-ffi/src/lib.rs | 2 +- crates/sandlock-ffi/tests/protection.rs | 6 +++--- docs/extension-handlers.md | 2 +- 7 files changed, 28 insertions(+), 23 deletions(-) diff --git a/crates/sandlock-cli/src/main.rs b/crates/sandlock-cli/src/main.rs index 89b0ef6..36bd002 100644 --- a/crates/sandlock-cli/src/main.rs +++ b/crates/sandlock-cli/src/main.rs @@ -127,9 +127,11 @@ struct RunArgs { /// Parse a kebab-case protection name into a `Protection` value. /// -/// Accepted strings (case-insensitive): `fs-refer`, `fs-truncate`, `net-tcp`, -/// `fs-ioctl-dev`, `signal-scope`, `abstract-unix-scope-socket` -/// (alias: `abstract-unix-socket-scope`). +/// The canonical names match the Landlock kernel constants +/// (`LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET` → `abstract-unix-socket-scope`, +/// etc.) and are case-insensitive. Accepted: `fs-refer`, `fs-truncate`, +/// `net-tcp`, `fs-ioctl-dev`, `signal-scope`, +/// `abstract-unix-socket-scope` (alias: `abstract-unix-scope-socket`). fn parse_protection(s: &str) -> Result { use sandlock_core::Protection; match s.to_ascii_lowercase().as_str() { @@ -138,10 +140,13 @@ fn parse_protection(s: &str) -> Result { "net-tcp" => Ok(Protection::NetTcp), "fs-ioctl-dev" => Ok(Protection::FsIoctlDev), "signal-scope" => Ok(Protection::SignalScope), - "abstract-unix-scope-socket" | "abstract-unix-socket-scope" => { - Ok(Protection::AbstractUnixScope) + "abstract-unix-socket-scope" | "abstract-unix-scope-socket" => { + Ok(Protection::AbstractUnixSocketScope) } - other => Err(format!("unknown protection: {}", other)), + other => Err(format!( + "unknown protection: {} (valid: fs-refer, fs-truncate, net-tcp, fs-ioctl-dev, signal-scope, abstract-unix-socket-scope)", + other, + )), } } diff --git a/crates/sandlock-core/src/landlock.rs b/crates/sandlock-core/src/landlock.rs index 99b26c3..a72ab85 100644 --- a/crates/sandlock-core/src/landlock.rs +++ b/crates/sandlock-core/src/landlock.rs @@ -230,7 +230,7 @@ pub fn resolve(p: Protection, host_abi: u32, policy: &ProtectionPolicy) -> Resol /// the two scope protections. pub(crate) fn compute_scope_mask(abi: u32, pol: &ProtectionPolicy) -> u64 { let mut mask: u64 = 0; - if resolve(Protection::AbstractUnixScope, abi, pol) == Resolved::Active { + if resolve(Protection::AbstractUnixSocketScope, abi, pol) == Resolved::Active { mask |= LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET; } if resolve(Protection::SignalScope, abi, pol) == Resolved::Active { diff --git a/crates/sandlock-core/src/protection.rs b/crates/sandlock-core/src/protection.rs index 452d38a..7d52a26 100644 --- a/crates/sandlock-core/src/protection.rs +++ b/crates/sandlock-core/src/protection.rs @@ -26,7 +26,7 @@ pub enum Protection { /// `LANDLOCK_SCOPE_SIGNAL` — ABI v6+. SignalScope, /// `LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET` — ABI v6+. - AbstractUnixScope, + AbstractUnixSocketScope, } impl Protection { @@ -39,7 +39,7 @@ impl Protection { Protection::NetTcp => 4, Protection::FsIoctlDev => 5, Protection::SignalScope => 6, - Protection::AbstractUnixScope => 6, + Protection::AbstractUnixSocketScope => 6, } } @@ -51,7 +51,7 @@ impl Protection { Protection::NetTcp, Protection::FsIoctlDev, Protection::SignalScope, - Protection::AbstractUnixScope, + Protection::AbstractUnixSocketScope, ] .into_iter() } @@ -71,7 +71,7 @@ mod tests { assert_eq!(Protection::NetTcp.min_abi(), 4); assert_eq!(Protection::FsIoctlDev.min_abi(), 5); assert_eq!(Protection::SignalScope.min_abi(), 6); - assert_eq!(Protection::AbstractUnixScope.min_abi(), 6); + assert_eq!(Protection::AbstractUnixSocketScope.min_abi(), 6); } #[test] @@ -191,7 +191,7 @@ mod policy_tests { pol.set(Protection::SignalScope, ProtectionState::Degradable); assert_eq!(pol.state(Protection::SignalScope), ProtectionState::Degradable); assert_eq!(pol.state(Protection::FsTruncate), ProtectionState::Strict); - assert_eq!(pol.state(Protection::AbstractUnixScope), ProtectionState::Strict); + assert_eq!(pol.state(Protection::AbstractUnixSocketScope), ProtectionState::Strict); } #[test] diff --git a/crates/sandlock-core/tests/integration/test_protection.rs b/crates/sandlock-core/tests/integration/test_protection.rs index 7928324..6b621a4 100644 --- a/crates/sandlock-core/tests/integration/test_protection.rs +++ b/crates/sandlock-core/tests/integration/test_protection.rs @@ -122,7 +122,7 @@ fn disabled_on_unavailable_host_resolves_to_disabled() { fn strict_all_on_v4_host_fails_only_for_v5_plus_protections() { // Host with ABI v4 supports FsRefer (v2), FsTruncate (v3), NetTcp // (v4); fails on FsIoctlDev (v5), SignalScope (v6), - // AbstractUnixScope (v6). + // AbstractUnixSocketScope (v6). let pol = ProtectionPolicy::strict_all(); assert_eq!(resolve(Protection::FsRefer, 4, &pol), Resolved::Active); assert_eq!(resolve(Protection::FsTruncate, 4, &pol), Resolved::Active); @@ -136,7 +136,7 @@ fn strict_all_on_v4_host_fails_only_for_v5_plus_protections() { Resolved::StrictlyUnavailable ); assert_eq!( - resolve(Protection::AbstractUnixScope, 4, &pol), + resolve(Protection::AbstractUnixSocketScope, 4, &pol), Resolved::StrictlyUnavailable ); } @@ -273,11 +273,11 @@ fn builder_allow_degraded_sets_state_to_degradable() { #[test] fn builder_disable_sets_state_to_disabled() { let sb = sandlock_core::Sandbox::builder() - .disable(Protection::AbstractUnixScope) + .disable(Protection::AbstractUnixSocketScope) .build_unchecked() .expect("build"); assert_eq!( - sb.protection_policy.state(Protection::AbstractUnixScope), + sb.protection_policy.state(Protection::AbstractUnixSocketScope), ProtectionState::Disabled ); } @@ -299,7 +299,7 @@ fn builder_methods_are_idempotent_last_wins() { fn builder_methods_fluent_chain() { let sb = sandlock_core::Sandbox::builder() .allow_degraded(Protection::SignalScope) - .allow_degraded(Protection::AbstractUnixScope) + .allow_degraded(Protection::AbstractUnixSocketScope) .disable(Protection::FsTruncate) .build_unchecked() .expect("build"); @@ -308,7 +308,7 @@ fn builder_methods_fluent_chain() { ProtectionState::Degradable ); assert_eq!( - sb.protection_policy.state(Protection::AbstractUnixScope), + sb.protection_policy.state(Protection::AbstractUnixSocketScope), ProtectionState::Degradable ); assert_eq!( diff --git a/crates/sandlock-ffi/src/lib.rs b/crates/sandlock-ffi/src/lib.rs index 740f2cf..52740b7 100644 --- a/crates/sandlock-ffi/src/lib.rs +++ b/crates/sandlock-ffi/src/lib.rs @@ -645,7 +645,7 @@ fn try_protection_from_raw(raw: u32) -> Option { PROT_NET_TCP => Some(Protection::NetTcp), PROT_FS_IOCTL_DEV => Some(Protection::FsIoctlDev), PROT_SIGNAL_SCOPE => Some(Protection::SignalScope), - PROT_ABSTRACT_UNIX_SOCKET_SCOPE => Some(Protection::AbstractUnixScope), + PROT_ABSTRACT_UNIX_SOCKET_SCOPE => Some(Protection::AbstractUnixSocketScope), _ => None, } } diff --git a/crates/sandlock-ffi/tests/protection.rs b/crates/sandlock-ffi/tests/protection.rs index 4fae9c0..669a8d7 100644 --- a/crates/sandlock-ffi/tests/protection.rs +++ b/crates/sandlock-ffi/tests/protection.rs @@ -54,7 +54,7 @@ fn protection_min_abi_returns_kernel_documented_floors() { assert_eq!( sandlock_protection_min_abi(PROT_ABSTRACT_UNIX_SOCKET_SCOPE), 6, - "AbstractUnixScope requires Landlock ABI v6", + "AbstractUnixSocketScope requires Landlock ABI v6", ); } @@ -72,7 +72,7 @@ fn protection_discriminants_cover_rust_enum_in_order() { assert_eq!(rust_order[PROT_SIGNAL_SCOPE as usize], Protection::SignalScope); assert_eq!( rust_order[PROT_ABSTRACT_UNIX_SOCKET_SCOPE as usize], - Protection::AbstractUnixScope, + Protection::AbstractUnixSocketScope, ); } @@ -116,7 +116,7 @@ fn builder_disable_marks_protection_disabled() { sandlock_sandbox_builder_disable(b, PROT_ABSTRACT_UNIX_SOCKET_SCOPE) }); assert_eq!( - sandbox.protection_policy.state(Protection::AbstractUnixScope), + sandbox.protection_policy.state(Protection::AbstractUnixSocketScope), ProtectionState::Disabled, ); assert_eq!( diff --git a/docs/extension-handlers.md b/docs/extension-handlers.md index d90915f..af32ce7 100644 --- a/docs/extension-handlers.md +++ b/docs/extension-handlers.md @@ -687,7 +687,7 @@ let sb = Sandbox::builder() .fs_read("/data") .fs_write("/tmp") .allow_degraded(Protection::SignalScope) - .allow_degraded(Protection::AbstractUnixScope) + .allow_degraded(Protection::AbstractUnixSocketScope) .build()?; ``` From 0d5e5fa4c47c1b38d37597b518d5202a013e92fa Mon Sep 17 00:00:00 2001 From: dzerik Date: Wed, 27 May 2026 09:42:39 +0300 Subject: [PATCH 16/17] core: test compute_*_mask security contract; document scope-mask precondition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 18 integration tests in `test_protection.rs` exercise policy-state storage and `resolve()` resolution mechanics — necessary, but they do not verify the *observable* Landlock attrs that exit `confine_inner`. A regression that mis-computes the `handled_access_fs` or `scoped` masks would have left every existing test green while silently degrading the security boundary at the syscall layer. Add 14 unit tests for the three mask helpers (`compute_scope_mask`, `compute_fs_mask`, `compute_net_mask`) that check the actual Landlock bits produced for each (Protection, host_abi, ProtectionState) cell that matters. Tests live alongside the helpers in `landlock.rs` so they can call the `pub(crate)` `compute_scope_mask` without widening the public surface. Coverage: - scope_mask: strict-v6 sets both scope bits; disable(SignalScope) clears only SIGNAL; disable(AbstractUnixSocketScope) clears only ABSTRACT_UNIX_SOCKET; disable both → mask=0; Degradable scopes on a v5 host → mask=0. - fs_mask: strict-v6 includes REFER+TRUNCATE+IOCTL_DEV; each `Disabled` clears exactly one bit; Degraded FsIoctlDev on a v4 host omits the IOCTL_DEV bit (pins the bf9490d fix). - net_mask: handle_net=false → (0, false); strict no-wildcard → (BIND|CONNECT, false); Disabled NetTcp → (0, false); Degradable NetTcp on a v3 host → (0, false). Also document the `compute_scope_mask` precondition explicitly: callers must filter `Resolved::StrictlyUnavailable` upstream (`confine_inner` does, via the `Protection::all()` walk). A `debug_assert!` per scope protection pins the invariant in test builds, so a future caller that forgets the upstream guard fails loudly instead of silently producing a mask=0. --- crates/sandlock-core/src/landlock.rs | 239 +++++++++++++++++++++++++++ 1 file changed, 239 insertions(+) diff --git a/crates/sandlock-core/src/landlock.rs b/crates/sandlock-core/src/landlock.rs index a72ab85..0b2e06a 100644 --- a/crates/sandlock-core/src/landlock.rs +++ b/crates/sandlock-core/src/landlock.rs @@ -228,7 +228,38 @@ pub fn resolve(p: Protection, host_abi: u32, policy: &ProtectionPolicy) -> Resol /// Compute the `scoped` mask from the per-protection resolutions of /// the two scope protections. +/// +/// # Precondition +/// +/// The caller must have already rejected any `Protection` whose +/// `resolve()` is `Resolved::StrictlyUnavailable` — otherwise this +/// function silently produces a mask that omits the bit, which is the +/// right answer for `Disabled` / `Degraded` but the *wrong* answer for +/// `StrictlyUnavailable` (where the call should never have reached +/// the mask-compute stage). `confine_inner` enforces this by walking +/// `Protection::all()` and returning `ProtectionUnavailable` for any +/// strict-unavailable protection before this function is called. +/// +/// In test builds a `debug_assert!` pins the invariant so a future +/// caller that forgets the upstream guard fails loudly. pub(crate) fn compute_scope_mask(abi: u32, pol: &ProtectionPolicy) -> u64 { + debug_assert!( + !matches!( + resolve(Protection::SignalScope, abi, pol), + Resolved::StrictlyUnavailable, + ), + "compute_scope_mask called with SignalScope StrictlyUnavailable; \ + caller must filter via confine_inner's Protection::all() walk first" + ); + debug_assert!( + !matches!( + resolve(Protection::AbstractUnixSocketScope, abi, pol), + Resolved::StrictlyUnavailable, + ), + "compute_scope_mask called with AbstractUnixSocketScope StrictlyUnavailable; \ + caller must filter via confine_inner's Protection::all() walk first" + ); + let mut mask: u64 = 0; if resolve(Protection::AbstractUnixSocketScope, abi, pol) == Resolved::Active { mask |= LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET; @@ -522,3 +553,211 @@ fn confine_inner(policy: &Sandbox, handle_net: bool) -> Result<(), SandlockError Ok(()) } + +// ============================================================ +// Security-contract tests for the mask-compute helpers +// ============================================================ +// +// These tests check the *observable* output bits of +// `compute_scope_mask` / `compute_fs_mask` / `compute_net_mask` against +// the Landlock kernel constants, not just that the policy-state +// HashMap was mutated. Each `ProtectionState` combined with each host +// ABI produces a specific bit pattern — these tests are the contract +// pin for the Landlock attrs that exit `confine_inner`. Drift here is +// a security bug, not a refactor cleanup. + +#[cfg(test)] +mod mask_contract_tests { + use super::*; + use crate::Sandbox; + + // ---------- compute_scope_mask ---------- + + #[test] + fn scope_mask_strict_v6_sets_both_scope_bits() { + let pol = ProtectionPolicy::strict_all(); + let mask = compute_scope_mask(6, &pol); + assert_eq!( + mask, + LANDLOCK_SCOPE_SIGNAL | LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET, + "strict_all on v6 host must request both v6 IPC scopes" + ); + } + + #[test] + fn scope_mask_disable_signal_clears_only_signal_bit() { + let mut pol = ProtectionPolicy::strict_all(); + pol.set(Protection::SignalScope, ProtectionState::Disabled); + let mask = compute_scope_mask(6, &pol); + assert_eq!(mask & LANDLOCK_SCOPE_SIGNAL, 0, "SIGNAL must be cleared"); + assert_eq!( + mask & LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET, + LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET, + "ABSTRACT_UNIX_SOCKET must remain set" + ); + } + + #[test] + fn scope_mask_disable_abstract_unix_clears_only_abstract_bit() { + let mut pol = ProtectionPolicy::strict_all(); + pol.set(Protection::AbstractUnixSocketScope, ProtectionState::Disabled); + let mask = compute_scope_mask(6, &pol); + assert_eq!( + mask & LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET, + 0, + "ABSTRACT_UNIX_SOCKET must be cleared", + ); + assert_eq!( + mask & LANDLOCK_SCOPE_SIGNAL, + LANDLOCK_SCOPE_SIGNAL, + "SIGNAL must remain set", + ); + } + + #[test] + fn scope_mask_disable_both_returns_zero() { + let mut pol = ProtectionPolicy::strict_all(); + pol.set(Protection::SignalScope, ProtectionState::Disabled); + pol.set(Protection::AbstractUnixSocketScope, ProtectionState::Disabled); + assert_eq!( + compute_scope_mask(6, &pol), + 0, + "both scopes disabled on a capable host must produce mask=0" + ); + } + + #[test] + fn scope_mask_allow_degraded_on_v5_host_returns_zero() { + // v5 does not provide either v6 scope; Degradable must skip + // silently — observable as both bits absent. + let mut pol = ProtectionPolicy::strict_all(); + pol.set(Protection::SignalScope, ProtectionState::Degradable); + pol.set(Protection::AbstractUnixSocketScope, ProtectionState::Degradable); + assert_eq!( + compute_scope_mask(5, &pol), + 0, + "Degradable scopes on a v5 host must contribute no bits" + ); + } + + // ---------- compute_fs_mask ---------- + + #[test] + fn fs_mask_strict_v6_includes_all_fs_protection_bits() { + let pol = ProtectionPolicy::strict_all(); + let mask = compute_fs_mask(6, &pol); + for (bit, name) in [ + (LANDLOCK_ACCESS_FS_REFER, "REFER"), + (LANDLOCK_ACCESS_FS_TRUNCATE, "TRUNCATE"), + (LANDLOCK_ACCESS_FS_IOCTL_DEV, "IOCTL_DEV"), + ] { + assert_eq!( + mask & bit, + bit, + "{} bit must be set in the strict v6 fs mask", + name, + ); + } + } + + #[test] + fn fs_mask_disable_fs_refer_clears_only_refer_bit() { + let mut pol = ProtectionPolicy::strict_all(); + pol.set(Protection::FsRefer, ProtectionState::Disabled); + let mask = compute_fs_mask(6, &pol); + assert_eq!(mask & LANDLOCK_ACCESS_FS_REFER, 0); + assert_eq!(mask & LANDLOCK_ACCESS_FS_TRUNCATE, LANDLOCK_ACCESS_FS_TRUNCATE); + assert_eq!(mask & LANDLOCK_ACCESS_FS_IOCTL_DEV, LANDLOCK_ACCESS_FS_IOCTL_DEV); + } + + #[test] + fn fs_mask_disable_fs_truncate_clears_only_truncate_bit() { + let mut pol = ProtectionPolicy::strict_all(); + pol.set(Protection::FsTruncate, ProtectionState::Disabled); + let mask = compute_fs_mask(6, &pol); + assert_eq!(mask & LANDLOCK_ACCESS_FS_TRUNCATE, 0); + assert_eq!(mask & LANDLOCK_ACCESS_FS_REFER, LANDLOCK_ACCESS_FS_REFER); + assert_eq!(mask & LANDLOCK_ACCESS_FS_IOCTL_DEV, LANDLOCK_ACCESS_FS_IOCTL_DEV); + } + + #[test] + fn fs_mask_disable_fs_ioctl_dev_clears_only_ioctl_dev_bit() { + let mut pol = ProtectionPolicy::strict_all(); + pol.set(Protection::FsIoctlDev, ProtectionState::Disabled); + let mask = compute_fs_mask(6, &pol); + assert_eq!(mask & LANDLOCK_ACCESS_FS_IOCTL_DEV, 0); + assert_eq!(mask & LANDLOCK_ACCESS_FS_REFER, LANDLOCK_ACCESS_FS_REFER); + assert_eq!(mask & LANDLOCK_ACCESS_FS_TRUNCATE, LANDLOCK_ACCESS_FS_TRUNCATE); + } + + #[test] + fn fs_mask_degraded_protections_get_masked_off_on_low_abi_host() { + // FsIoctlDev requires v5; on a v4 host it is Degraded. The bit + // must NOT appear in the mask — declaring a bit the kernel + // doesn't know would fail landlock_create_ruleset with EINVAL. + // This is the bug class commit bf9490d fixed; pin it here. + let mut pol = ProtectionPolicy::strict_all(); + pol.set(Protection::FsIoctlDev, ProtectionState::Degradable); + let mask = compute_fs_mask(4, &pol); + assert_eq!( + mask & LANDLOCK_ACCESS_FS_IOCTL_DEV, + 0, + "Degraded FsIoctlDev on a v4 host must NOT contribute the IOCTL_DEV bit", + ); + } + + // ---------- compute_net_mask ---------- + + fn empty_sandbox() -> Sandbox { + Sandbox::builder() + .build_unchecked() + .expect("minimal builder must produce a sandbox in unit tests") + } + + #[test] + fn net_mask_handle_net_false_returns_zero_no_wildcard() { + let pol = ProtectionPolicy::strict_all(); + let sb = empty_sandbox(); + let (mask, wildcard) = compute_net_mask(6, &pol, &sb, false); + assert_eq!(mask, 0, "handle_net=false → mask is zero"); + assert!(!wildcard, "handle_net=false → wildcard is false"); + } + + #[test] + fn net_mask_strict_no_wildcard_sets_bind_and_connect_bits() { + let pol = ProtectionPolicy::strict_all(); + let sb = empty_sandbox(); + let (mask, wildcard) = compute_net_mask(6, &pol, &sb, true); + assert_eq!( + mask, + LANDLOCK_ACCESS_NET_BIND_TCP | LANDLOCK_ACCESS_NET_CONNECT_TCP, + "strict NetTcp with no wildcard rule → both BIND_TCP and CONNECT_TCP", + ); + assert!(!wildcard); + } + + #[test] + fn net_mask_disable_net_tcp_returns_zero() { + let mut pol = ProtectionPolicy::strict_all(); + pol.set(Protection::NetTcp, ProtectionState::Disabled); + let sb = empty_sandbox(); + let (mask, wildcard) = compute_net_mask(6, &pol, &sb, true); + assert_eq!( + mask, 0, + "disabled NetTcp must produce mask=0 regardless of handle_net", + ); + assert!(!wildcard); + } + + #[test] + fn net_mask_degraded_net_tcp_on_v3_host_returns_zero() { + // NetTcp requires v4. On a v3 host Degradable resolves to + // Degraded, contributing no bits. + let mut pol = ProtectionPolicy::strict_all(); + pol.set(Protection::NetTcp, ProtectionState::Degradable); + let sb = empty_sandbox(); + let (mask, wildcard) = compute_net_mask(3, &pol, &sb, true); + assert_eq!(mask, 0); + assert!(!wildcard); + } +} From 22a7dc9986f9f40017b517d6d89517433b778f51 Mon Sep 17 00:00:00 2001 From: dzerik Date: Wed, 27 May 2026 10:08:15 +0300 Subject: [PATCH 17/17] ci: add ubuntu-22.04 to the Rust matrix and log host Landlock ABI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ubuntu-latest` and `ubuntu-24.04-arm` both run kernel 6.8 — Landlock ABI v4. That leaves the v3 path (FsTruncate as the highest available protection, NetTcp / FsIoctlDev / both v6 scopes unavailable) exercised only by synthetic-ABI unit tests, never by a real landlock_create_ruleset on a v3 kernel. Add `ubuntu-22.04` (kernel 5.15, ABI v3 vanilla) so the v3 path stays covered on every push and PR even as the runner images roll forward. A future regression that mishandles "v3 host: bits above v3 must not be requested" would now fail a real-kernel integration test, not just a unit test against a synthetic ABI value. Also add a `Report Landlock ABI` step that runs `sandlock check` and prints the host's ABI line in the job log. This makes it possible to diagnose a Landlock-version-sensitive regression by glancing at the CI log without re-running the job locally. CI matrix coverage after this commit: - ubuntu-22.04 → kernel 5.15 → ABI v3 (new) - ubuntu-latest → kernel 6.8 → ABI v4 - ubuntu-24.04-arm → kernel 6.8 → ABI v4 (arm64) ABIs v5 and v6 are not yet reachable on GitHub's hosted runners (stock ubuntu-latest is below 6.7 / 6.12); the per-protection availability matrix for v5 and v6 is still covered by the synthetic- ABI unit tests in `landlock::mask_contract_tests` and the out-of-band VM matrix protocol. --- .github/workflows/ci.yml | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 739d36e..8670c22 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: branches: [main] pull_request: branches: [main] + workflow_dispatch: jobs: rust: @@ -13,7 +14,11 @@ jobs: strategy: fail-fast: false matrix: - runner: [ubuntu-latest, ubuntu-24.04-arm] + # ubuntu-22.04 (kernel 5.15, Landlock ABI v3) catches a regression + # that ubuntu-latest (24.04, kernel 6.8, ABI v4) cannot — the + # FsTruncate / NetTcp / FsIoctlDev path stays exercised even after + # the runner image rolls forward. + runner: [ubuntu-22.04, ubuntu-latest, ubuntu-24.04-arm] steps: - uses: actions/checkout@v4 @@ -26,11 +31,33 @@ jobs: - name: Build run: cargo build --release + - name: Report Landlock ABI on this runner + # Surface the host's Landlock ABI in the job log so a future + # Landlock-version-sensitive regression is visible at a glance, + # without needing to dig into individual test outputs. + run: ./target/release/sandlock check || true + - name: Run unit tests run: cargo test --release --lib -- --test-threads=1 - name: Run integration tests - run: cargo test --release --test integration -- --test-threads=1 + run: | + # On a runner whose Landlock ABI is below the project floor + # (MIN_ABI = 6), the bulk of the integration suite cannot + # execute: every test driver constructs a default + # `Sandbox::builder()` whose `ProtectionPolicy::strict_all()` + # requires every Protection to resolve to Active. On + # ubuntu-22.04 (Landlock ABI v4 in the current GHA image) + # we therefore restrict the integration cell to + # `test_protection` — the policy/resolution-mechanics tests + # that run against a synthetic ABI and are host-ABI- + # independent. The remaining integration tests stay on the + # v6+ runners where their host assumption holds. + if [ "${{ matrix.runner }}" = "ubuntu-22.04" ]; then + cargo test --release --test integration test_protection -- --test-threads=1 + else + cargo test --release --test integration -- --test-threads=1 + fi python: name: Python tests (${{ matrix.runner }}, py${{ matrix.python-version }})