Skip to main content

ferroday_cage/
hardening.rs

1//! The hardening layer: opt-in Landlock filesystem and network rules, seccomp
2//! syscall filters, and capability drops applied to the sandboxed command.
3//!
4//! Everything here is configured on the [`CageBuilder`] and takes effect only
5//! when requested; the default sandbox profile is a rootless convenience, not
6//! a boundary against hostile code. The public types describe *what* to
7//! restrict; [`CageBuilder::build`] lowers them into the frozen plan the
8//! command stage applies, after the pivot and immediately before `execve`, so
9//! the restrictions bind the command and every process it starts.
10//!
11//! The restrictions are inherited across `execve` and apply only to the
12//! command, never to the sandbox init or the supervisor. A missing kernel
13//! facility is reported: a request for Landlock on a kernel without the LSM,
14//! or seccomp on an architecture the compiler does not target, is a build or
15//! setup error rather than a silent no-op.
16//!
17//! With the `serde` feature, a hardening request is part of the profile
18//! format: it serializes and deserializes as the `[hardening]` table on the
19//! [`CageBuilder`] profile, so a Landlock, seccomp, and capability posture is
20//! a versionable, reviewable artifact like the rest of the specification.
21//!
22//! [`CageBuilder`]: crate::CageBuilder
23//! [`CageBuilder::build`]: crate::CageBuilder::build
24
25use std::collections::{BTreeMap, BTreeSet};
26use std::ffi::CString;
27use std::fmt;
28use std::ops::{BitAnd, BitAndAssign, BitOr, BitOrAssign, Not};
29use std::path::{Path, PathBuf};
30use std::str::FromStr;
31
32use rustix::thread::CapabilitySet;
33
34use crate::error::ConfigError;
35use crate::mechanism::{HardeningPlan, LandlockNetRule, LandlockRule, SockFilter};
36use crate::roster::roster_enum;
37
38/// Declares the text side of a letter-set access type: its
39/// [`Display`](fmt::Display), its [`FromStr`], the parse error it produces, and
40/// the serde adapters that go through both.
41///
42/// [`FsAccess`] and [`NetAccess`] are the same type twice — a bitflag newtype
43/// whose textual form is an unordered set of single letters — differing only in
44/// the letters, the flags, and their names. Everything that follows from the
45/// letter table is generated from the table.
46///
47/// Letters are declared in the order [`Display`](fmt::Display) writes them, and
48/// parsing accepts them in any order and any case. A letter outside the table,
49/// or a letter given twice, is a parse error; the empty string is the empty
50/// set.
51macro_rules! letter_set_text {
52    (
53        $set:ident, $expected:literal,
54        $( $letter:literal => $flag:ident, )+
55        $(#[$error_meta:meta])*
56        $error:ident
57    ) => {
58        impl fmt::Display for $set {
59            #[doc = concat!("Writes the rights as the letters ", $expected, ", in that order; the empty set writes nothing.")]
60            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61                $(
62                    if self.contains($set::$flag) {
63                        write!(f, "{}", $letter)?;
64                    }
65                )+
66                Ok(())
67            }
68        }
69
70        impl FromStr for $set {
71            type Err = $error;
72
73            #[doc = concat!("Parses an access set from the letters ", $expected, ", in any order and any case. A letter outside that set, or a letter given twice, is an error; the empty string is the empty set.")]
74            fn from_str(s: &str) -> Result<$set, $error> {
75                let mut access = $set::empty();
76                for ch in s.chars() {
77                    let right = match ch.to_ascii_lowercase() {
78                        $( $letter => $set::$flag, )+
79                        _ => return Err($error { text: s.to_string() }),
80                    };
81                    if access.contains(right) {
82                        return Err($error { text: s.to_string() });
83                    }
84                    access |= right;
85                }
86                Ok(access)
87            }
88        }
89
90        $(#[$error_meta])*
91        #[derive(Debug, Clone, PartialEq, Eq)]
92        pub struct $error {
93            text: String,
94        }
95
96        impl $error {
97            /// The access string that failed to parse, as it was given.
98            ///
99            /// Named `text` on every parse error in this crate, so a caller
100            /// moving between them does not have to check.
101            pub fn text(&self) -> &str {
102                &self.text
103            }
104        }
105
106        impl fmt::Display for $error {
107            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108                write!(
109                    f,
110                    concat!("invalid access set {:?}: expected some combination of the letters ", $expected),
111                    self.text,
112                )
113            }
114        }
115
116        impl std::error::Error for $error {}
117
118        #[cfg(feature = "serde")]
119        impl serde::Serialize for $set {
120            /// Serializes as the letter string, through [`Display`](fmt::Display).
121            fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
122                serializer.collect_str(self)
123            }
124        }
125
126        #[cfg(feature = "serde")]
127        impl<'de> serde::Deserialize<'de> for $set {
128            /// Deserializes from the letter string, through [`FromStr`].
129            fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<$set, D::Error> {
130                let text = <String as serde::Deserialize>::deserialize(deserializer)?;
131                text.parse().map_err(serde::de::Error::custom)
132            }
133        }
134    };
135}
136
137/// Declares the bitwise operators of a bitflag newtype over an integer.
138///
139/// `!` complements within `MASK`, so a bit outside the modeled set is never
140/// produced.
141macro_rules! bitflag_ops {
142    ($set:ident) => {
143        impl BitOr for $set {
144            type Output = $set;
145
146            fn bitor(self, rhs: $set) -> $set {
147                $set(self.0 | rhs.0)
148            }
149        }
150
151        impl BitOrAssign for $set {
152            fn bitor_assign(&mut self, rhs: $set) {
153                self.0 |= rhs.0;
154            }
155        }
156
157        impl BitAnd for $set {
158            type Output = $set;
159
160            fn bitand(self, rhs: $set) -> $set {
161                $set(self.0 & rhs.0)
162            }
163        }
164
165        impl BitAndAssign for $set {
166            fn bitand_assign(&mut self, rhs: $set) {
167                self.0 &= rhs.0;
168            }
169        }
170
171        impl Not for $set {
172            type Output = $set;
173
174            /// The modeled rights this set does not hold; bits outside the
175            /// modeled set are never produced.
176            fn not(self) -> $set {
177                $set(!self.0 & $set::MASK)
178            }
179        }
180    };
181}
182
183/// Kernel `LANDLOCK_ACCESS_FS_*` rights. Named here rather than pulled from a
184/// binding so the ABI contract is visible in one place; re-verify against the
185/// kernel uapi when the Landlock ABI grows.
186pub(crate) mod fs_access {
187    pub(crate) const EXECUTE: u64 = 1 << 0;
188    pub(crate) const WRITE_FILE: u64 = 1 << 1;
189    pub(crate) const READ_FILE: u64 = 1 << 2;
190    pub(crate) const READ_DIR: u64 = 1 << 3;
191    pub(crate) const REMOVE_DIR: u64 = 1 << 4;
192    pub(crate) const REMOVE_FILE: u64 = 1 << 5;
193    pub(crate) const MAKE_CHAR: u64 = 1 << 6;
194    pub(crate) const MAKE_DIR: u64 = 1 << 7;
195    pub(crate) const MAKE_REG: u64 = 1 << 8;
196    pub(crate) const MAKE_SOCK: u64 = 1 << 9;
197    pub(crate) const MAKE_FIFO: u64 = 1 << 10;
198    pub(crate) const MAKE_BLOCK: u64 = 1 << 11;
199    pub(crate) const MAKE_SYM: u64 = 1 << 12;
200    pub(crate) const REFER: u64 = 1 << 13;
201    pub(crate) const TRUNCATE: u64 = 1 << 14;
202    pub(crate) const IOCTL_DEV: u64 = 1 << 15;
203
204    /// Every filesystem right this crate models — the ABI 5 set. The command
205    /// stage masks this down to the running kernel's supported ABI.
206    pub(crate) const ALL: u64 = EXECUTE
207        | WRITE_FILE
208        | READ_FILE
209        | READ_DIR
210        | REMOVE_DIR
211        | REMOVE_FILE
212        | MAKE_CHAR
213        | MAKE_DIR
214        | MAKE_REG
215        | MAKE_SOCK
216        | MAKE_FIFO
217        | MAKE_BLOCK
218        | MAKE_SYM
219        | REFER
220        | TRUNCATE
221        | IOCTL_DEV;
222}
223
224/// Kernel `LANDLOCK_ACCESS_NET_*` rights, keyed by TCP port rather than by
225/// path. Named here alongside the filesystem rights so the ABI contract lives
226/// in one place; re-verify against the kernel uapi when the Landlock ABI grows.
227pub(crate) mod net_access {
228    pub(crate) const BIND_TCP: u64 = 1 << 0;
229    pub(crate) const CONNECT_TCP: u64 = 1 << 1;
230
231    /// Every network right this crate models — the ABI 4 set. The command
232    /// stage masks this down to the running kernel's supported ABI.
233    pub(crate) const ALL: u64 = BIND_TCP | CONNECT_TCP;
234}
235
236/// Masks a filesystem access set down to the rights a Landlock ABI version
237/// defines, so a ruleset built for a newer ABI still enforces on an older
238/// kernel — narrowing what is enforced, never widening it.
239///
240/// A right absent from the running ABI cannot be part of a ruleset that
241/// ABI's kernel will accept; dropping it leaves the access unrestricted for
242/// that right, the same best-effort downgrade the reference library performs.
243pub(crate) fn fs_access_for_abi(abi: i32) -> u64 {
244    use fs_access::*;
245    // v1: EXECUTE through MAKE_SYM. Each later version adds the rights below.
246    let mut mask = EXECUTE
247        | WRITE_FILE
248        | READ_FILE
249        | READ_DIR
250        | REMOVE_DIR
251        | REMOVE_FILE
252        | MAKE_CHAR
253        | MAKE_DIR
254        | MAKE_REG
255        | MAKE_SOCK
256        | MAKE_FIFO
257        | MAKE_BLOCK
258        | MAKE_SYM;
259    if abi >= 2 {
260        mask |= REFER;
261    }
262    if abi >= 3 {
263        mask |= TRUNCATE;
264    }
265    // ABI 4 adds only network rights, masked separately by net_access_for_abi.
266    if abi >= 5 {
267        mask |= IOCTL_DEV;
268    }
269    mask
270}
271
272/// Masks a network access set down to the rights a Landlock ABI version
273/// defines, so a ruleset built for a newer ABI still enforces on an older
274/// kernel — narrowing what is enforced, never widening it.
275///
276/// The TCP bind and connect rights are the whole modeled set and arrive
277/// together in ABI 4; a kernel older than that defines no network rights, so
278/// the mask is empty and network access is left unrestricted, the same
279/// best-effort downgrade [`fs_access_for_abi`] performs for filesystem rights.
280pub(crate) fn net_access_for_abi(abi: i32) -> u64 {
281    // ABI 4 introduced BIND_TCP and CONNECT_TCP together. Later network rights
282    // (the UDP rights of ABI 10) become additional gated additions here.
283    if abi >= 4 { net_access::ALL } else { 0 }
284}
285
286/// Whether a requested filesystem grant carrying this handled mask falls
287/// entirely outside `abi`, so a ruleset built at `abi` would govern none of it.
288/// A zero mask means no filesystem grant was requested, so nothing is dropped.
289///
290/// A filesystem grant keeps its ABI-1 base rights on every Landlock kernel, so
291/// in practice this is only ever true when the LSM reports an ABI of zero,
292/// which cannot occur; it exists for symmetry with the network predicate and to
293/// state the invariant explicitly.
294pub(crate) fn fs_grant_masked_away(fs_handled: u64, abi: i32) -> bool {
295    fs_handled != 0 && fs_handled & fs_access_for_abi(abi) == 0
296}
297
298/// Whether a requested network grant carrying this handled mask falls entirely
299/// outside `abi`, so a ruleset built at `abi` would govern none of it. A zero
300/// mask means no network grant was requested, so nothing is dropped.
301///
302/// Network rights first appear in Landlock ABI 4 (Linux 6.7), so a network
303/// grant is masked away on every older kernel. The grant must be judged per
304/// access kind rather than against the whole ruleset: a filesystem grant that
305/// survives the same ABI must not mask this refusal, or a configured network
306/// boundary would silently vanish while the filesystem side enforced.
307///
308/// Callers that treat a masked-away grant as a build error rather than run
309/// unconfined: `HardeningPlan::ensure_landlock_enforceable` (the restriction
310/// fallback) and `HardeningPlan::ensure_host_net_enforceable` (a host-network
311/// cage, whose only network boundary is Landlock).
312pub(crate) fn net_grant_masked_away(net_handled: u64, abi: i32) -> bool {
313    net_handled != 0 && net_handled & net_access_for_abi(abi) == 0
314}
315
316/// A set of filesystem access rights, granted on a path by
317/// [`CageBuilder::landlock_fs`].
318///
319/// The three primitives — [`READ`](Self::READ), [`WRITE`](Self::WRITE), and
320/// [`EXECUTE`](Self::EXECUTE) — combine with `|`, mask with `&`, and
321/// complement with `!` (within the modeled rights):
322///
323/// ```
324/// use ferroday_cage::FsAccess;
325///
326/// let read_execute = FsAccess::READ | FsAccess::EXECUTE;
327/// assert!(read_execute.contains(FsAccess::READ));
328/// assert_eq!(read_execute & FsAccess::WRITE, FsAccess::empty());
329/// assert_eq!(!FsAccess::WRITE, FsAccess::READ | FsAccess::EXECUTE);
330/// ```
331///
332/// [`CageBuilder::landlock_fs`]: crate::CageBuilder::landlock_fs
333#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
334pub struct FsAccess(u8);
335
336impl FsAccess {
337    /// Read files and list directories beneath the path.
338    pub const READ: FsAccess = FsAccess(0b001);
339    /// Write files, and create, remove, and rename entries beneath the path.
340    pub const WRITE: FsAccess = FsAccess(0b010);
341    /// Execute files beneath the path.
342    pub const EXECUTE: FsAccess = FsAccess(0b100);
343
344    /// The bits every modeled right occupies, for masking a complement.
345    const MASK: u8 = 0b111;
346
347    /// The empty access set — no rights. Equivalent to [`FsAccess::default`].
348    pub const fn empty() -> FsAccess {
349        FsAccess(0)
350    }
351
352    /// Every modeled right: [`READ`](Self::READ), [`WRITE`](Self::WRITE), and
353    /// [`EXECUTE`](Self::EXECUTE).
354    pub const fn all() -> FsAccess {
355        FsAccess(FsAccess::MASK)
356    }
357
358    /// Returns `true` if no right is present.
359    pub const fn is_empty(self) -> bool {
360        self.0 == 0
361    }
362
363    /// Returns `true` if every right in `other` is present.
364    pub const fn contains(self, other: FsAccess) -> bool {
365        self.0 & other.0 == other.0
366    }
367
368    /// The kernel `LANDLOCK_ACCESS_FS_*` rights this access set grants.
369    ///
370    /// `WRITE` maps to the full set of content and directory-mutation rights
371    /// so a read-write grant behaves as a caller expects; rights the mount
372    /// makes impossible anyway (device-node creation) are harmless to grant.
373    ///
374    /// `IOCTL_DEV` (device-specific `ioctl`, an ABI-5 right) is granted with
375    /// either `READ` or `WRITE`: a caller who can read or write a device file
376    /// expects its ioctls to work, and the right has no effect on a regular
377    /// file or directory. Because it is in the handled set, a grant that omits
378    /// both — an `EXECUTE`-only grant — still denies device ioctls. Were it not
379    /// granted here at all, every filesystem grant would deny device ioctls on
380    /// an ABI-5 kernel, since a handled right absent from a path's allowed set
381    /// is denied there.
382    pub(crate) fn to_landlock(self) -> u64 {
383        use fs_access::*;
384        let mut bits = 0;
385        if self.contains(FsAccess::READ) {
386            bits |= READ_FILE | READ_DIR;
387        }
388        if self.contains(FsAccess::EXECUTE) {
389            bits |= EXECUTE;
390        }
391        if self.contains(FsAccess::WRITE) {
392            bits |= WRITE_FILE
393                | REMOVE_DIR
394                | REMOVE_FILE
395                | MAKE_CHAR
396                | MAKE_DIR
397                | MAKE_REG
398                | MAKE_SOCK
399                | MAKE_FIFO
400                | MAKE_BLOCK
401                | MAKE_SYM
402                | REFER
403                | TRUNCATE;
404        }
405        if self.contains(FsAccess::READ) || self.contains(FsAccess::WRITE) {
406            bits |= IOCTL_DEV;
407        }
408        bits
409    }
410}
411
412bitflag_ops!(FsAccess);
413
414letter_set_text! {
415    FsAccess, "r, w, and x",
416    'r' => READ,
417    'w' => WRITE,
418    'x' => EXECUTE,
419    /// The error from parsing an [`FsAccess`] from an invalid access string.
420    ///
421    /// Produced when the string holds a character other than `r`, `w`, or `x`,
422    /// or repeats one of them.
423    ParseFsAccessError
424}
425
426/// A set of Landlock network access rights, granted on a TCP port by
427/// [`CageBuilder::landlock_net`].
428///
429/// Landlock's network control covers TCP alone: these rights govern TCP bind
430/// and connect and nothing else. UDP, raw sockets, and other socket families
431/// — `AF_UNIX` among them, including abstract sockets to host IPC — are
432/// outside a network grant's reach, and a restriction that must deny them
433/// needs a seccomp filter or a cage's network namespace instead.
434///
435/// The kernel keys network rights by port, so these are the network
436/// counterpart of [`FsAccess`]: the two primitives — [`BIND`](Self::BIND) and
437/// [`CONNECT`](Self::CONNECT) — combine with `|`, mask with `&`, and complement
438/// with `!` (within the modeled rights):
439///
440/// ```
441/// use ferroday_cage::NetAccess;
442///
443/// let bind_connect = NetAccess::BIND | NetAccess::CONNECT;
444/// assert!(bind_connect.contains(NetAccess::BIND));
445/// assert_eq!(bind_connect & NetAccess::CONNECT, NetAccess::CONNECT);
446/// assert_eq!(!NetAccess::BIND, NetAccess::CONNECT);
447/// ```
448///
449/// [`CageBuilder::landlock_net`]: crate::CageBuilder::landlock_net
450#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
451pub struct NetAccess(u8);
452
453impl NetAccess {
454    /// Bind a TCP socket to the port (`LANDLOCK_ACCESS_NET_BIND_TCP`).
455    pub const BIND: NetAccess = NetAccess(0b01);
456    /// Connect a TCP socket to the port (`LANDLOCK_ACCESS_NET_CONNECT_TCP`).
457    pub const CONNECT: NetAccess = NetAccess(0b10);
458
459    /// The bits every modeled right occupies, for masking a complement.
460    const MASK: u8 = 0b11;
461
462    /// The empty access set — no rights. Equivalent to [`NetAccess::default`].
463    pub const fn empty() -> NetAccess {
464        NetAccess(0)
465    }
466
467    /// Every modeled right: [`BIND`](Self::BIND) and [`CONNECT`](Self::CONNECT).
468    pub const fn all() -> NetAccess {
469        NetAccess(NetAccess::MASK)
470    }
471
472    /// Returns `true` if no right is present.
473    pub const fn is_empty(self) -> bool {
474        self.0 == 0
475    }
476
477    /// Returns `true` if every right in `other` is present.
478    pub const fn contains(self, other: NetAccess) -> bool {
479        self.0 & other.0 == other.0
480    }
481
482    /// The kernel `LANDLOCK_ACCESS_NET_*` rights this access set grants.
483    pub(crate) fn to_landlock(self) -> u64 {
484        use net_access::*;
485        let mut bits = 0;
486        if self.contains(NetAccess::BIND) {
487            bits |= BIND_TCP;
488        }
489        if self.contains(NetAccess::CONNECT) {
490            bits |= CONNECT_TCP;
491        }
492        bits
493    }
494}
495
496bitflag_ops!(NetAccess);
497
498letter_set_text! {
499    NetAccess, "b and c",
500    'b' => BIND,
501    'c' => CONNECT,
502    /// The error from parsing a [`NetAccess`] from an invalid access string.
503    ///
504    /// Produced when the string holds a character other than `b` or `c`, or
505    /// repeats one of them.
506    ParseNetAccessError
507}
508
509/// One Landlock filesystem grant, as configured on the builder.
510#[derive(Debug, Clone)]
511struct Grant {
512    access: FsAccess,
513    path: PathBuf,
514}
515
516/// One Landlock network grant, as configured on the builder: an access set
517/// bound to a single TCP port.
518#[derive(Debug, Clone)]
519struct NetGrant {
520    access: NetAccess,
521    port: u16,
522}
523
524/// The action a seccomp filter takes for a syscall.
525///
526/// Mirrors the kernel's seccomp return actions. `Errno` is the gentlest —
527/// the syscall fails with the given error number and the command continues;
528/// `KillProcess` is the strictest.
529#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
530#[non_exhaustive]
531pub enum SeccompAction {
532    /// Allow the syscall.
533    Allow,
534    /// Fail the syscall with this error number, without running it.
535    Errno(u16),
536    /// Log the syscall (audit), then allow it.
537    Log,
538    /// Kill the calling thread.
539    KillThread,
540    /// Kill the whole process.
541    KillProcess,
542}
543
544impl SeccompAction {
545    fn to_seccompiler(self) -> seccompiler::SeccompAction {
546        match self {
547            SeccompAction::Allow => seccompiler::SeccompAction::Allow,
548            SeccompAction::Errno(errno) => seccompiler::SeccompAction::Errno(u32::from(errno)),
549            SeccompAction::Log => seccompiler::SeccompAction::Log,
550            SeccompAction::KillThread => seccompiler::SeccompAction::KillThread,
551            SeccompAction::KillProcess => seccompiler::SeccompAction::KillProcess,
552        }
553    }
554
555    /// Whether the action lets the syscall run. `Allow` runs it and `Log` runs
556    /// it after auditing; the rest deny it. Used to decide whether a policy
557    /// restricts anything at all.
558    fn is_permissive(self) -> bool {
559        matches!(self, SeccompAction::Allow | SeccompAction::Log)
560    }
561}
562
563/// `EPERM`, the error a denied syscall reports by default.
564const EPERM: u16 = 1;
565
566/// `BPF_MAXINSNS`, the kernel's ceiling on a classic BPF filter's length. A
567/// compiled seccomp program is checked against it at build time so an
568/// over-long program is rejected rather than silently truncated when its
569/// length is later narrowed to a `u16` for the kernel's `sock_fprog`.
570const BPF_MAXINSNS: usize = 4096;
571
572/// The width of a syscall argument to compare: the low 32 bits (`Dword`) or
573/// all 64 (`Qword`).
574///
575/// The kernel presents every syscall argument to a seccomp filter as a 64-bit
576/// value, whatever the argument's declared type. The width is a deliberate
577/// choice with a security edge: comparing only the low 32 bits of an argument
578/// that is really 64 bits lets a value hide in the high bits, and comparing
579/// all 64 bits of an argument the kernel treats as a 32-bit `int` lets a value
580/// hide there instead. Match the width to the argument's kernel type — `Dword`
581/// for an `int`, `unsigned int`, or a 32-bit flag set; `Qword` for a pointer,
582/// an `unsigned long`, or any 64-bit value.
583#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
584#[non_exhaustive]
585pub enum SeccompArgLen {
586    /// The low 32 bits of the argument.
587    Dword,
588    /// All 64 bits of the argument.
589    Qword,
590}
591
592/// The comparison a [`SeccompArg`] applies between a syscall argument and a
593/// reference value.
594#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
595#[non_exhaustive]
596pub enum SeccompCompare {
597    /// The argument equals the value.
598    Eq,
599    /// The argument does not equal the value.
600    Ne,
601    /// The argument is greater than or equal to the value.
602    Ge,
603    /// The argument is greater than the value.
604    Gt,
605    /// The argument is less than or equal to the value.
606    Le,
607    /// The argument is less than the value.
608    Lt,
609    /// The argument, masked with the carried bits, equals the value masked
610    /// with the same bits: `(arg & mask) == (value & mask)`. Tests individual
611    /// flag bits — for example, that `CLONE_NEWUSER` is clear in a `clone`
612    /// flags argument, with `MaskedEq(CLONE_NEWUSER)` against a value of `0`.
613    MaskedEq(u64),
614}
615
616/// A condition on one argument of a syscall.
617///
618/// A condition names an argument by index (0 through 5, the six syscall
619/// registers), a [width](SeccompArgLen) to compare, a [comparison](SeccompCompare),
620/// and a reference value. It narrows which invocations of a listed syscall a
621/// [`SeccompRules`] rule matches — "`ioctl` only when its request equals
622/// `TIOCGWINSZ`," "`socket` only when its domain equals `AF_INET`."
623///
624/// The width-named constructors ([`eq_dword`](Self::eq_dword),
625/// [`eq_qword`](Self::eq_qword), and the rest) are the usual way to build one;
626/// they make the [`Dword`](SeccompArgLen::Dword)/[`Qword`](SeccompArgLen::Qword)
627/// choice explicit at the call site, since it has no safe default. [`new`](Self::new)
628/// takes the parts directly.
629#[derive(Debug, Clone, Copy, PartialEq, Eq)]
630pub struct SeccompArg {
631    index: u8,
632    len: SeccompArgLen,
633    compare: SeccompCompare,
634    value: u64,
635}
636
637impl SeccompArg {
638    /// A condition from its parts: the argument `index` (0 through 5), the
639    /// `len` to compare, the `compare` operation, and the reference `value`.
640    pub fn new(index: u8, len: SeccompArgLen, compare: SeccompCompare, value: u64) -> SeccompArg {
641        SeccompArg {
642            index,
643            len,
644            compare,
645            value,
646        }
647    }
648
649    /// The low 32 bits of argument `index` equal `value`.
650    pub fn eq_dword(index: u8, value: u64) -> SeccompArg {
651        SeccompArg::new(index, SeccompArgLen::Dword, SeccompCompare::Eq, value)
652    }
653
654    /// All 64 bits of argument `index` equal `value`.
655    pub fn eq_qword(index: u8, value: u64) -> SeccompArg {
656        SeccompArg::new(index, SeccompArgLen::Qword, SeccompCompare::Eq, value)
657    }
658
659    /// The low 32 bits of argument `index` do not equal `value`.
660    pub fn ne_dword(index: u8, value: u64) -> SeccompArg {
661        SeccompArg::new(index, SeccompArgLen::Dword, SeccompCompare::Ne, value)
662    }
663
664    /// All 64 bits of argument `index` do not equal `value`.
665    pub fn ne_qword(index: u8, value: u64) -> SeccompArg {
666        SeccompArg::new(index, SeccompArgLen::Qword, SeccompCompare::Ne, value)
667    }
668
669    /// The low 32 bits of argument `index` are greater than or equal to
670    /// `value`.
671    pub fn ge_dword(index: u8, value: u64) -> SeccompArg {
672        SeccompArg::new(index, SeccompArgLen::Dword, SeccompCompare::Ge, value)
673    }
674
675    /// All 64 bits of argument `index` are greater than or equal to `value`.
676    pub fn ge_qword(index: u8, value: u64) -> SeccompArg {
677        SeccompArg::new(index, SeccompArgLen::Qword, SeccompCompare::Ge, value)
678    }
679
680    /// The low 32 bits of argument `index` are greater than `value`.
681    pub fn gt_dword(index: u8, value: u64) -> SeccompArg {
682        SeccompArg::new(index, SeccompArgLen::Dword, SeccompCompare::Gt, value)
683    }
684
685    /// All 64 bits of argument `index` are greater than `value`.
686    pub fn gt_qword(index: u8, value: u64) -> SeccompArg {
687        SeccompArg::new(index, SeccompArgLen::Qword, SeccompCompare::Gt, value)
688    }
689
690    /// The low 32 bits of argument `index` are less than or equal to `value`.
691    pub fn le_dword(index: u8, value: u64) -> SeccompArg {
692        SeccompArg::new(index, SeccompArgLen::Dword, SeccompCompare::Le, value)
693    }
694
695    /// All 64 bits of argument `index` are less than or equal to `value`.
696    pub fn le_qword(index: u8, value: u64) -> SeccompArg {
697        SeccompArg::new(index, SeccompArgLen::Qword, SeccompCompare::Le, value)
698    }
699
700    /// The low 32 bits of argument `index` are less than `value`.
701    pub fn lt_dword(index: u8, value: u64) -> SeccompArg {
702        SeccompArg::new(index, SeccompArgLen::Dword, SeccompCompare::Lt, value)
703    }
704
705    /// All 64 bits of argument `index` are less than `value`.
706    pub fn lt_qword(index: u8, value: u64) -> SeccompArg {
707        SeccompArg::new(index, SeccompArgLen::Qword, SeccompCompare::Lt, value)
708    }
709
710    /// The low 32 bits of argument `index`, masked with `mask`, equal `value`
711    /// masked with `mask`.
712    pub fn masked_eq_dword(index: u8, mask: u64, value: u64) -> SeccompArg {
713        SeccompArg::new(
714            index,
715            SeccompArgLen::Dword,
716            SeccompCompare::MaskedEq(mask),
717            value,
718        )
719    }
720
721    /// All 64 bits of argument `index`, masked with `mask`, equal `value`
722    /// masked with `mask`.
723    pub fn masked_eq_qword(index: u8, mask: u64, value: u64) -> SeccompArg {
724        SeccompArg::new(
725            index,
726            SeccompArgLen::Qword,
727            SeccompCompare::MaskedEq(mask),
728            value,
729        )
730    }
731
732    /// Lowers the condition to the compiler backend's representation, mapping a
733    /// rejected argument index to a [`ConfigError`].
734    fn to_seccompiler(self) -> Result<seccompiler::SeccompCondition, ConfigError> {
735        let len = match self.len {
736            SeccompArgLen::Dword => seccompiler::SeccompCmpArgLen::Dword,
737            SeccompArgLen::Qword => seccompiler::SeccompCmpArgLen::Qword,
738        };
739        let op = match self.compare {
740            SeccompCompare::Eq => seccompiler::SeccompCmpOp::Eq,
741            SeccompCompare::Ne => seccompiler::SeccompCmpOp::Ne,
742            SeccompCompare::Ge => seccompiler::SeccompCmpOp::Ge,
743            SeccompCompare::Gt => seccompiler::SeccompCmpOp::Gt,
744            SeccompCompare::Le => seccompiler::SeccompCmpOp::Le,
745            SeccompCompare::Lt => seccompiler::SeccompCmpOp::Lt,
746            SeccompCompare::MaskedEq(mask) => seccompiler::SeccompCmpOp::MaskedEq(mask),
747        };
748        seccompiler::SeccompCondition::new(self.index, len, op, self.value).map_err(|err| {
749            ConfigError::SeccompInvalid {
750                reason: err.to_string(),
751            }
752        })
753    }
754}
755
756/// A listed syscall and the argument conditions, if any, that narrow which of
757/// its invocations the rule matches. Empty conditions match the syscall
758/// regardless of its arguments.
759#[derive(Debug, Clone)]
760struct SyscallRule {
761    syscall: i64,
762    /// Conditions that all must hold (an AND) for the rule to match.
763    conditions: Vec<SeccompArg>,
764}
765
766impl SyscallRule {
767    /// A rule that matches `syscall` regardless of its arguments.
768    fn unconditional(syscall: i64) -> SyscallRule {
769        SyscallRule {
770            syscall,
771            conditions: Vec::new(),
772        }
773    }
774}
775
776/// A caller-authored seccomp policy: a default action for unlisted syscalls,
777/// and the opposite action for a named list.
778///
779/// The two shapes cover the common cases. An allowlist names the syscalls the
780/// command may make and denies the rest — the strict posture, which must
781/// include every syscall the command needs (`execve` among them, since it is
782/// the first syscall the filter governs). A denylist names the syscalls to
783/// refuse and allows the rest.
784///
785/// Syscalls are named by their kernel number for the host architecture; the
786/// `syscalls` crate or `libc` provides them.
787///
788/// A listed syscall may carry [argument conditions](SeccompArg) through
789/// [`rule`](Self::rule), so the listed action applies only to invocations
790/// whose arguments match — an allowlist that permits `ioctl` only for a
791/// specific request, a denylist that refuses `socket` only for `AF_INET`. A
792/// syscall listed both plainly and with conditions matches unconditionally:
793/// the broader listing wins.
794///
795/// A seccomp filter governs syscalls, not the operations an asynchronous
796/// submission interface performs on their behalf. An allowlist that permits
797/// `io_uring_enter` therefore permits the file opens, connects, and reads
798/// submitted through the ring, even where it denies `openat`, `connect`, or
799/// `read` directly. Do not allowlist the io_uring syscalls unless the command
800/// genuinely needs them; the [curated policy](SeccompPolicy::Curated) denies
801/// them for this reason.
802#[derive(Debug, Clone)]
803pub struct SeccompRules {
804    default_action: SeccompAction,
805    listed_action: SeccompAction,
806    rules: Vec<SyscallRule>,
807}
808
809impl SeccompRules {
810    /// A denylist: `syscalls` fail with `EPERM`, everything else is allowed.
811    pub fn denying<I: IntoIterator<Item = i64>>(syscalls: I) -> SeccompRules {
812        SeccompRules {
813            default_action: SeccompAction::Allow,
814            listed_action: SeccompAction::Errno(EPERM),
815            rules: syscalls
816                .into_iter()
817                .map(SyscallRule::unconditional)
818                .collect(),
819        }
820    }
821
822    /// An allowlist: `syscalls` are allowed, everything else fails with
823    /// `EPERM`.
824    pub fn allowing<I: IntoIterator<Item = i64>>(syscalls: I) -> SeccompRules {
825        SeccompRules {
826            default_action: SeccompAction::Errno(EPERM),
827            listed_action: SeccompAction::Allow,
828            rules: syscalls
829                .into_iter()
830                .map(SyscallRule::unconditional)
831                .collect(),
832        }
833    }
834
835    /// Lists `syscall` with argument conditions: the listed action applies
836    /// only when every condition in `conditions` holds (an AND). Call this
837    /// more than once for the same syscall to accept any of several condition
838    /// sets (an OR).
839    ///
840    /// Passing no conditions lists the syscall unconditionally, the same as
841    /// naming it in [`denying`](Self::denying) or [`allowing`](Self::allowing).
842    /// If a syscall is listed both unconditionally and with conditions, the
843    /// unconditional listing wins.
844    pub fn rule<I: IntoIterator<Item = SeccompArg>>(
845        mut self,
846        syscall: i64,
847        conditions: I,
848    ) -> SeccompRules {
849        self.rules.push(SyscallRule {
850            syscall,
851            conditions: conditions.into_iter().collect(),
852        });
853        self
854    }
855
856    /// Sets the action for unlisted syscalls (the default is `Allow` for a
857    /// denylist, `Errno(EPERM)` for an allowlist).
858    pub fn default_action(mut self, action: SeccompAction) -> SeccompRules {
859        self.default_action = action;
860        self
861    }
862
863    /// Sets the action for the listed syscalls.
864    pub fn listed_action(mut self, action: SeccompAction) -> SeccompRules {
865        self.listed_action = action;
866        self
867    }
868
869    /// Whether the policy denies any syscall. A default-deny (allowlist)
870    /// posture always restricts, since it denies every unlisted syscall; a
871    /// default-allow (denylist) posture restricts only when it lists at least
872    /// one syscall under a denying action. An empty `denying([])` restricts
873    /// nothing.
874    fn restricts(&self) -> bool {
875        !self.default_action.is_permissive()
876            || (!self.rules.is_empty() && !self.listed_action.is_permissive())
877    }
878}
879
880/// A seccomp syscall-filtering policy for the sandboxed command.
881#[derive(Clone)]
882#[non_exhaustive]
883pub enum SeccompPolicy {
884    /// A curated default-deny of dangerous and rarely-legitimate syscalls,
885    /// allowing the rest. A convenience posture, not a boundary claim; its
886    /// exact roster is documented and grows only additively.
887    ///
888    /// Two entries are conditioned on an argument rather than naming a whole
889    /// syscall: `ioctl` is denied for the `TIOCSTI` and `TIOCLINUX` requests,
890    /// which write to a terminal's input queue, and allowed for everything
891    /// else.
892    Curated,
893    /// A caller-authored allow- or denylist.
894    Rules(SeccompRules),
895    /// An already-compiled BPF program, installed verbatim — the escape hatch
896    /// for a policy an external compiler produced.
897    Program(Vec<SockFilter>),
898}
899
900impl SeccompPolicy {
901    /// Whether the policy denies any syscall. The curated roster and any
902    /// non-empty caller policy restrict; an empty `denying([])` does not. Used
903    /// to decide whether a restriction confines anything through seccomp, so a
904    /// no-op policy does not pass the empty-restriction guard.
905    pub(crate) fn restricts(&self) -> bool {
906        match self {
907            SeccompPolicy::Curated => true,
908            SeccompPolicy::Rules(rules) => rules.restricts(),
909            // The escape hatch is opaque; a non-empty program is assumed to
910            // restrict, an empty one (which the kernel would reject anyway)
911            // does not.
912            SeccompPolicy::Program(program) => !program.is_empty(),
913        }
914    }
915}
916
917// A hand-written Debug so the escape-hatch program prints its length, not
918// every instruction. `Clone` is derived on the type, being structural.
919impl std::fmt::Debug for SeccompPolicy {
920    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
921        match self {
922            SeccompPolicy::Curated => f.write_str("Curated"),
923            SeccompPolicy::Rules(rules) => f.debug_tuple("Rules").field(rules).finish(),
924            SeccompPolicy::Program(program) => f
925                .debug_tuple("Program")
926                .field(&format_args!("{} instructions", program.len()))
927                .finish(),
928        }
929    }
930}
931
932/// The curated denylist: dangerous or rarely-legitimate syscalls, denied with
933/// `EPERM`. Named through the `syscalls` crate so the numbers are correct for
934/// the host architecture. Not exhaustive — a convenience posture.
935///
936/// The list denies `io_uring_setup`/`io_uring_enter`/`io_uring_register`
937/// because io_uring can carry filesystem and network operations
938/// (`IORING_OP_OPENAT`, `IORING_OP_CONNECT`, ...) that a syscall filter never
939/// sees. That same limitation means an *allowlist* a caller authors is
940/// bypassable if it permits the io_uring syscalls but denies the operations
941/// io_uring can perform: filter the io_uring syscalls too, or leave them
942/// unlisted so the allowlist's default action denies them. See
943/// [`SeccompRules`].
944fn curated_denylist() -> Vec<i64> {
945    use syscalls::Sysno;
946    // Filesystem topology, kernel modules, the keyring, tracing, time, and
947    // other administrative surfaces a sandboxed command has no business
948    // reaching. Every entry of the base roster exists on all three
949    // seccomp-supported architectures; anything that does not is added after it,
950    // under the architecture it belongs to.
951    let base = [
952        Sysno::mount,
953        Sysno::umount2,
954        Sysno::pivot_root,
955        Sysno::mount_setattr,
956        Sysno::move_mount,
957        Sysno::open_tree,
958        Sysno::fsopen,
959        Sysno::fsconfig,
960        Sysno::fsmount,
961        Sysno::fspick,
962        // A file handle names an inode by (mount id, opaque handle) rather than
963        // by path, so it reaches across the mount topology the way the calls
964        // above reshape it. Opening one needs CAP_DAC_READ_SEARCH, which the
965        // sandbox's own user namespace grants.
966        Sysno::name_to_handle_at,
967        Sysno::open_by_handle_at,
968        Sysno::swapon,
969        Sysno::swapoff,
970        Sysno::reboot,
971        Sysno::kexec_load,
972        Sysno::kexec_file_load,
973        Sysno::init_module,
974        Sysno::finit_module,
975        Sysno::delete_module,
976        Sysno::add_key,
977        Sysno::keyctl,
978        Sysno::request_key,
979        Sysno::bpf,
980        Sysno::ptrace,
981        // The natural complements to ptrace: cross-process memory access,
982        // reachable against another process of the same uid in a restriction
983        // (no PID namespace) where ptrace alone would not suffice.
984        Sysno::process_vm_readv,
985        Sysno::process_vm_writev,
986        Sysno::perf_event_open,
987        // Handing page-fault resolution to userspace is a standard primitive
988        // for widening a kernel race into a reliable exploit, and nothing a
989        // sandboxed build needs.
990        Sysno::userfaultfd,
991        // Changing the execution domain can weaken protections the sandbox
992        // relies on (for instance READ_IMPLIES_EXEC, or re-enabling layout
993        // randomization the caller disabled).
994        Sysno::personality,
995        // io_uring is a submission ring for asynchronous syscalls; denying it
996        // keeps operations from reaching the kernel outside the filter's view.
997        Sysno::io_uring_setup,
998        Sysno::io_uring_enter,
999        Sysno::io_uring_register,
1000        Sysno::settimeofday,
1001        Sysno::clock_settime,
1002        Sysno::clock_adjtime,
1003        Sysno::adjtimex,
1004        Sysno::setns,
1005        Sysno::acct,
1006        Sysno::quotactl,
1007    ];
1008
1009    // `modify_ldt` changes the execution domain like `personality` does, and is
1010    // denied for the same reason — but the local descriptor table is an x86
1011    // construct, so the name exists only there.
1012    #[cfg(target_arch = "x86_64")]
1013    let arch_specific = [Sysno::modify_ldt];
1014    #[cfg(not(target_arch = "x86_64"))]
1015    let arch_specific: [Sysno; 0] = [];
1016
1017    base.into_iter()
1018        .chain(arch_specific)
1019        .map(|sysno| sysno.id() as i64)
1020        .collect()
1021}
1022
1023/// `TIOCSTI`: push one character into a terminal's input queue, as though it
1024/// had been typed.
1025const TIOCSTI: u64 = 0x5412;
1026/// `TIOCLINUX`: the Linux console multiplexer, whose `TIOCL_SETSEL`/
1027/// `TIOCL_PASTESEL` subcommands paste a console selection into the input queue
1028/// and so reach the same place `TIOCSTI` does.
1029const TIOCLINUX: u64 = 0x541C;
1030
1031/// The curated denylist's argument-conditioned entries: the two `ioctl`
1032/// requests that write to a terminal's input queue.
1033///
1034/// Denied by request rather than by syscall, because `ioctl` itself is the
1035/// ordinary way to ask anything of a device and a sandboxed command needs it.
1036/// A command that holds a descriptor for the caller's terminal — an inherited
1037/// standard stream, or `/dev/tty` while it shares the caller's session — can
1038/// otherwise use these to enqueue characters that the caller's shell reads and
1039/// runs once the sandbox exits. Modern kernels gate `TIOCSTI` behind the
1040/// `dev.tty.legacy_tiocsti` sysctl, which defaults to off, and grant it only
1041/// for the process's own controlling terminal; this denies it outright, on
1042/// every kernel, whichever terminal is named. See [`Stdio`](crate::Stdio),
1043/// which decides whether the sandbox reaches the *caller's* terminal at all —
1044/// the reach this exists to close. A terminal launch
1045/// ([`Cage::spawn_terminal`](crate::Cage::spawn_terminal)) reaches none of it
1046/// whatever `Stdio` says, since the sandbox has a terminal of its own; the
1047/// denial still applies there, and denies an operation on a terminal nobody
1048/// else can see, which is the price of a denylist that does not ask whose
1049/// terminal it is.
1050///
1051/// The comparison is on the low 32 bits because `ioctl`'s request argument
1052/// reaches the kernel as an `unsigned int`: the high half is discarded before
1053/// dispatch, so comparing all 64 bits would let a request with anything set
1054/// above bit 31 pass the filter and still be honoured.
1055fn curated_ioctl_rules() -> Vec<SyscallRule> {
1056    [TIOCSTI, TIOCLINUX]
1057        .into_iter()
1058        .map(|request| SyscallRule {
1059            syscall: syscalls::Sysno::ioctl.id() as i64,
1060            conditions: vec![SeccompArg::eq_dword(1, request)],
1061        })
1062        .collect()
1063}
1064
1065roster_enum! {
1066    /// A named capability, for [`CageBuilder::keep_capabilities`].
1067    ///
1068    /// In a fresh user namespace the command is mapped to root and holds every
1069    /// capability *within that namespace* — none of which confers authority over
1070    /// the host, but which do grant power inside the sandbox.
1071    /// [`drop_all_capabilities`](crate::CageBuilder::drop_all_capabilities) sheds
1072    /// them; a keep-list retains the named few.
1073    ///
1074    /// Each capability has a canonical kebab-case name (`net-bind-service`),
1075    /// printed by [`Display`](std::fmt::Display) and parsed by
1076    /// [`FromStr`](std::str::FromStr). Parsing also accepts the snake-case form
1077    /// and an optional `cap-`/`cap_` prefix, and is case-insensitive, so
1078    /// `CAP_SYS_ADMIN`, `sys_admin`, and `sys-admin` all parse to
1079    /// [`SysAdmin`](Self::SysAdmin). [`ALL`](Self::ALL) lists every variant.
1080    ///
1081    /// [`CageBuilder::keep_capabilities`]: crate::CageBuilder::keep_capabilities
1082    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1083    #[non_exhaustive]
1084    pub enum Capability {
1085        /// The single-bit capability set naming this capability.
1086        fn to_set -> CapabilitySet;
1087        /// The canonical kebab-case name, without the `cap-` prefix.
1088        fn as_str;
1089
1090        /// `CAP_CHOWN`.
1091        Chown = CapabilitySet::CHOWN => "chown",
1092        /// `CAP_DAC_OVERRIDE`.
1093        DacOverride = CapabilitySet::DAC_OVERRIDE => "dac-override",
1094        /// `CAP_DAC_READ_SEARCH`.
1095        DacReadSearch = CapabilitySet::DAC_READ_SEARCH => "dac-read-search",
1096        /// `CAP_FOWNER`.
1097        Fowner = CapabilitySet::FOWNER => "fowner",
1098        /// `CAP_FSETID`.
1099        Fsetid = CapabilitySet::FSETID => "fsetid",
1100        /// `CAP_KILL`.
1101        Kill = CapabilitySet::KILL => "kill",
1102        /// `CAP_SETGID`.
1103        Setgid = CapabilitySet::SETGID => "setgid",
1104        /// `CAP_SETUID`.
1105        Setuid = CapabilitySet::SETUID => "setuid",
1106        /// `CAP_SETPCAP`.
1107        Setpcap = CapabilitySet::SETPCAP => "setpcap",
1108        /// `CAP_LINUX_IMMUTABLE`.
1109        LinuxImmutable = CapabilitySet::LINUX_IMMUTABLE => "linux-immutable",
1110        /// `CAP_NET_BIND_SERVICE`.
1111        NetBindService = CapabilitySet::NET_BIND_SERVICE => "net-bind-service",
1112        /// `CAP_NET_BROADCAST`.
1113        NetBroadcast = CapabilitySet::NET_BROADCAST => "net-broadcast",
1114        /// `CAP_NET_ADMIN`.
1115        NetAdmin = CapabilitySet::NET_ADMIN => "net-admin",
1116        /// `CAP_NET_RAW`.
1117        NetRaw = CapabilitySet::NET_RAW => "net-raw",
1118        /// `CAP_IPC_LOCK`.
1119        IpcLock = CapabilitySet::IPC_LOCK => "ipc-lock",
1120        /// `CAP_IPC_OWNER`.
1121        IpcOwner = CapabilitySet::IPC_OWNER => "ipc-owner",
1122        /// `CAP_SYS_MODULE`.
1123        SysModule = CapabilitySet::SYS_MODULE => "sys-module",
1124        /// `CAP_SYS_RAWIO`.
1125        SysRawio = CapabilitySet::SYS_RAWIO => "sys-rawio",
1126        /// `CAP_SYS_CHROOT`.
1127        SysChroot = CapabilitySet::SYS_CHROOT => "sys-chroot",
1128        /// `CAP_SYS_PTRACE`.
1129        SysPtrace = CapabilitySet::SYS_PTRACE => "sys-ptrace",
1130        /// `CAP_SYS_PACCT`.
1131        SysPacct = CapabilitySet::SYS_PACCT => "sys-pacct",
1132        /// `CAP_SYS_ADMIN`.
1133        SysAdmin = CapabilitySet::SYS_ADMIN => "sys-admin",
1134        /// `CAP_SYS_BOOT`.
1135        SysBoot = CapabilitySet::SYS_BOOT => "sys-boot",
1136        /// `CAP_SYS_NICE`.
1137        SysNice = CapabilitySet::SYS_NICE => "sys-nice",
1138        /// `CAP_SYS_RESOURCE`.
1139        SysResource = CapabilitySet::SYS_RESOURCE => "sys-resource",
1140        /// `CAP_SYS_TIME`.
1141        SysTime = CapabilitySet::SYS_TIME => "sys-time",
1142        /// `CAP_SYS_TTY_CONFIG`.
1143        SysTtyConfig = CapabilitySet::SYS_TTY_CONFIG => "sys-tty-config",
1144        /// `CAP_MKNOD`.
1145        Mknod = CapabilitySet::MKNOD => "mknod",
1146        /// `CAP_LEASE`.
1147        Lease = CapabilitySet::LEASE => "lease",
1148        /// `CAP_AUDIT_WRITE`.
1149        AuditWrite = CapabilitySet::AUDIT_WRITE => "audit-write",
1150        /// `CAP_AUDIT_CONTROL`.
1151        AuditControl = CapabilitySet::AUDIT_CONTROL => "audit-control",
1152        /// `CAP_SETFCAP`.
1153        Setfcap = CapabilitySet::SETFCAP => "setfcap",
1154        /// `CAP_MAC_OVERRIDE`.
1155        MacOverride = CapabilitySet::MAC_OVERRIDE => "mac-override",
1156        /// `CAP_MAC_ADMIN`.
1157        MacAdmin = CapabilitySet::MAC_ADMIN => "mac-admin",
1158        /// `CAP_SYSLOG`.
1159        Syslog = CapabilitySet::SYSLOG => "syslog",
1160        /// `CAP_WAKE_ALARM`.
1161        WakeAlarm = CapabilitySet::WAKE_ALARM => "wake-alarm",
1162        /// `CAP_BLOCK_SUSPEND`.
1163        BlockSuspend = CapabilitySet::BLOCK_SUSPEND => "block-suspend",
1164        /// `CAP_AUDIT_READ`.
1165        AuditRead = CapabilitySet::AUDIT_READ => "audit-read",
1166        /// `CAP_PERFMON`.
1167        Perfmon = CapabilitySet::PERFMON => "perfmon",
1168        /// `CAP_BPF`.
1169        Bpf = CapabilitySet::BPF => "bpf",
1170        /// `CAP_CHECKPOINT_RESTORE`.
1171        CheckpointRestore = CapabilitySet::CHECKPOINT_RESTORE => "checkpoint-restore",
1172    }
1173}
1174
1175impl fmt::Display for Capability {
1176    /// Writes the canonical kebab-case name, such as `net-bind-service`.
1177    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1178        f.write_str(self.as_str())
1179    }
1180}
1181
1182impl FromStr for Capability {
1183    type Err = ParseCapabilityError;
1184
1185    /// Parses a capability name, accepting the kebab or snake form, an
1186    /// optional `cap-`/`cap_` prefix, and any letter case.
1187    fn from_str(s: &str) -> Result<Capability, ParseCapabilityError> {
1188        let lowered = s.to_ascii_lowercase().replace('_', "-");
1189        let normalized = lowered.strip_prefix("cap-").unwrap_or(&lowered);
1190        Capability::ALL
1191            .iter()
1192            .copied()
1193            .find(|cap| cap.as_str() == normalized)
1194            .ok_or_else(|| ParseCapabilityError {
1195                text: s.to_string(),
1196            })
1197    }
1198}
1199
1200/// The error from parsing an unrecognized [`Capability`] name.
1201#[derive(Debug, Clone, PartialEq, Eq)]
1202pub struct ParseCapabilityError {
1203    text: String,
1204}
1205
1206impl ParseCapabilityError {
1207    /// The unrecognized name, as it was given.
1208    ///
1209    /// Named `text` on every parse error in this crate, so a caller moving
1210    /// between them does not have to check.
1211    pub fn text(&self) -> &str {
1212        &self.text
1213    }
1214}
1215
1216impl fmt::Display for ParseCapabilityError {
1217    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1218        write!(f, "unknown capability {:?}", self.text)
1219    }
1220}
1221
1222impl std::error::Error for ParseCapabilityError {}
1223
1224/// The capability posture: leave the namespaced set alone, or reduce it to a
1225/// kept few (empty for a full drop).
1226#[derive(Debug, Clone, Default)]
1227enum CapabilityPosture {
1228    /// Do not touch capabilities (the default).
1229    #[default]
1230    Keep,
1231    /// Reduce the bounding, ambient, permitted, effective, and inheritable
1232    /// sets to exactly these capabilities.
1233    Reduce(Vec<Capability>),
1234}
1235
1236/// The hardening request accumulated on the builder, lowered by `build` into
1237/// the frozen [`HardeningPlan`].
1238///
1239/// One field on [`CageBuilder`] rather than several, so the builder and the
1240/// lowering each touch a single place.
1241///
1242/// [`CageBuilder`]: crate::CageBuilder
1243#[derive(Debug, Clone, Default)]
1244pub(crate) struct Request {
1245    landlock: Vec<Grant>,
1246    landlock_net: Vec<NetGrant>,
1247    seccomp: Option<SeccompPolicy>,
1248    capabilities: CapabilityPosture,
1249}
1250
1251impl Request {
1252    /// Adds a Landlock grant of `access` beneath `path`.
1253    pub(crate) fn grant_fs(&mut self, access: FsAccess, path: &Path) {
1254        self.landlock.push(Grant {
1255            access,
1256            path: path.to_path_buf(),
1257        });
1258    }
1259
1260    /// Adds a Landlock grant of network `access` on the TCP `port`.
1261    pub(crate) fn grant_net(&mut self, access: NetAccess, port: u16) {
1262        self.landlock_net.push(NetGrant { access, port });
1263    }
1264
1265    /// Sets the seccomp policy.
1266    pub(crate) fn set_seccomp(&mut self, policy: SeccompPolicy) {
1267        self.seccomp = Some(policy);
1268    }
1269
1270    /// Whether the request restricts nothing: no Landlock grant — filesystem
1271    /// or network — and no seccomp policy that denies a syscall. A seccomp
1272    /// policy that denies nothing (an empty `denying([])`) does not count, so a
1273    /// restriction whose only "confinement" is a no-op filter is caught by the
1274    /// empty-restriction guard rather than run unconfined. The capability
1275    /// posture does not count — the restriction fallback, the check's consumer,
1276    /// cannot express one.
1277    pub(crate) fn is_unrestricted(&self) -> bool {
1278        self.landlock.is_empty()
1279            && self.landlock_net.is_empty()
1280            && self
1281                .seccomp
1282                .as_ref()
1283                .is_none_or(|policy| !policy.restricts())
1284    }
1285
1286    /// Whether the request carries no hardening at all: no Landlock grant, no
1287    /// seccomp policy, and the default, untouched capability posture. Unlike
1288    /// [`is_unrestricted`](Self::is_unrestricted), this also requires the
1289    /// default capability posture, so a profile that only drops capabilities
1290    /// still serializes its `[hardening]` table. Serde skips the field when
1291    /// this holds.
1292    #[cfg(feature = "serde")]
1293    pub(crate) fn has_no_hardening(&self) -> bool {
1294        self.landlock.is_empty()
1295            && self.landlock_net.is_empty()
1296            && self.seccomp.is_none()
1297            && matches!(self.capabilities, CapabilityPosture::Keep)
1298    }
1299
1300    /// Drops every capability.
1301    pub(crate) fn drop_all_capabilities(&mut self) {
1302        self.capabilities = CapabilityPosture::Reduce(Vec::new());
1303    }
1304
1305    /// Keeps only the named capabilities, dropping the rest.
1306    pub(crate) fn keep_capabilities<I: IntoIterator<Item = Capability>>(&mut self, caps: I) {
1307        self.capabilities = CapabilityPosture::Reduce(caps.into_iter().collect());
1308    }
1309
1310    /// Lowers the request into the frozen plan, compiling the seccomp policy
1311    /// and validating the Landlock grant paths. Called from the builders'
1312    /// `build`, where allocation and failure are free.
1313    pub(crate) fn lower(&self) -> Result<HardeningPlan, ConfigError> {
1314        // An empty access set is a trap rather than a no-op: enrolling any
1315        // grant denies everything ungranted, while the kernel skips a rule that
1316        // permits nothing. A grant of `FsAccess::empty()` therefore denies the
1317        // entire filesystem — including the command's own binary, which fails
1318        // the launch at `execve` with nothing pointing at the cause — and an
1319        // empty network grant denies all TCP. Neither is what naming a path or
1320        // a port means, so both are refused here.
1321        let landlock = self
1322            .landlock
1323            .iter()
1324            .map(|grant| {
1325                if grant.access.is_empty() {
1326                    return Err(ConfigError::LandlockGrantEmpty {
1327                        grant: grant.path.display().to_string(),
1328                    });
1329                }
1330                Ok(LandlockRule {
1331                    path: grant_path_cstring(&grant.path)?,
1332                    access: grant.access.to_landlock(),
1333                })
1334            })
1335            .collect::<Result<Vec<_>, ConfigError>>()?;
1336
1337        let landlock_net = self
1338            .landlock_net
1339            .iter()
1340            .map(|grant| {
1341                if grant.access.is_empty() {
1342                    return Err(ConfigError::LandlockGrantEmpty {
1343                        grant: format!("tcp:{}", grant.port),
1344                    });
1345                }
1346                Ok(LandlockNetRule {
1347                    port: grant.port,
1348                    access: grant.access.to_landlock(),
1349                })
1350            })
1351            .collect::<Result<Vec<_>, ConfigError>>()?;
1352
1353        let seccomp = match &self.seccomp {
1354            Some(policy) => Some(compile_seccomp(policy)?),
1355            None => None,
1356        };
1357
1358        let keep_caps = match &self.capabilities {
1359            CapabilityPosture::Keep => None,
1360            CapabilityPosture::Reduce(caps) => {
1361                let mut set = CapabilitySet::empty();
1362                for cap in caps {
1363                    set |= cap.to_set();
1364                }
1365                Some(set.bits())
1366            }
1367        };
1368
1369        Ok(HardeningPlan {
1370            landlock,
1371            landlock_handled: if self.landlock.is_empty() {
1372                0
1373            } else {
1374                fs_access::ALL
1375            },
1376            landlock_net,
1377            landlock_net_handled: if self.landlock_net.is_empty() {
1378                0
1379            } else {
1380                net_access::ALL
1381            },
1382            seccomp,
1383            keep_caps,
1384            // Whether the identity switch needs securebits is decided by
1385            // the builder, which alone knows the run-as identity.
1386            set_securebits: false,
1387        })
1388    }
1389}
1390
1391/// Validates a Landlock grant path — absolute, ordinary components only, the
1392/// bare root allowed so a grant can cover a whole tree — and freezes it as a
1393/// `CString`.
1394///
1395/// The bare root is the one way this differs from a mount target's check: a
1396/// grant over `/` is a coherent request, where a mount target of `/` is not.
1397/// In a cage the path resolves after the pivot; in a restriction it resolves
1398/// against the host filesystem.
1399fn grant_path_cstring(path: &Path) -> Result<CString, ConfigError> {
1400    crate::path::absolute_components(path).map_err(|_| ConfigError::LandlockPathInvalid {
1401        path: path.to_path_buf(),
1402    })?;
1403    crate::spec::cstring(path.as_os_str())
1404}
1405
1406/// Compiles a seccomp policy to a BPF program, rejecting one that exceeds the
1407/// kernel's instruction ceiling.
1408fn compile_seccomp(policy: &SeccompPolicy) -> Result<Vec<SockFilter>, ConfigError> {
1409    let program = match policy {
1410        SeccompPolicy::Curated => {
1411            let rules: Vec<SyscallRule> = curated_denylist()
1412                .into_iter()
1413                .map(SyscallRule::unconditional)
1414                .chain(curated_ioctl_rules())
1415                .collect();
1416            compile_filter(SeccompAction::Allow, SeccompAction::Errno(EPERM), &rules)?
1417        }
1418        SeccompPolicy::Rules(rules) => {
1419            compile_filter(rules.default_action, rules.listed_action, &rules.rules)?
1420        }
1421        SeccompPolicy::Program(program) => program.clone(),
1422    };
1423    // The kernel rejects a filter longer than BPF_MAXINSNS, but only if the
1424    // length reaching it is honest; the install path narrows the length to a
1425    // u16, so an over-long program (a caller's precompiled escape hatch) must
1426    // be caught here, before that cast can wrap it into a different filter.
1427    if program.len() > BPF_MAXINSNS {
1428        return Err(ConfigError::SeccompProgramTooLong {
1429            len: program.len(),
1430            max: BPF_MAXINSNS,
1431        });
1432    }
1433    Ok(program)
1434}
1435
1436/// Classic-BPF opcodes and `seccomp_data` field offsets for the hand-emitted
1437/// x32 guard. Named here rather than pulled from a binding so the one raw-BPF
1438/// fragment in the crate is self-contained and reviewable.
1439mod bpf {
1440    /// `BPF_LD | BPF_W | BPF_ABS`: load a 32-bit word from a fixed offset in
1441    /// `seccomp_data` into the accumulator.
1442    pub(super) const LD_W_ABS: u16 = 0x20;
1443    /// `BPF_JMP | BPF_JEQ | BPF_K`: branch on the accumulator equalling `k`.
1444    pub(super) const JMP_JEQ_K: u16 = 0x15;
1445    /// `BPF_JMP | BPF_JSET | BPF_K`: branch on the accumulator sharing any bit
1446    /// with `k`.
1447    pub(super) const JMP_JSET_K: u16 = 0x45;
1448    /// `BPF_RET | BPF_K`: return `k` as the seccomp action.
1449    pub(super) const RET_K: u16 = 0x06;
1450
1451    /// Byte offset of `seccomp_data.nr`, the syscall number.
1452    pub(super) const OFF_NR: u32 = 0;
1453    /// Byte offset of `seccomp_data.arch`, the AUDIT_ARCH of the syscall.
1454    pub(super) const OFF_ARCH: u32 = 4;
1455}
1456
1457/// `AUDIT_ARCH_X86_64`: the audit architecture the x32 ABI shares with 64-bit
1458/// x86_64, which is why an x32 syscall passes an x86_64 arch gate.
1459///
1460/// Only a denylist needs to care: an allowlist's default-deny already refuses
1461/// the x32 numbers, so the guard this constant serves — [`x32_deny_prologue`] —
1462/// is emitted for a permissive default alone, in [`compile_filter`].
1463const AUDIT_ARCH_X86_64: u32 = 0xC000_003E;
1464
1465/// `__X32_SYSCALL_BIT`: set on every syscall number issued through the x32 ABI.
1466///
1467/// Adding it to a syscall number gives the x32 alias of the same call, which is
1468/// what lets an x32 caller slip past a denylist that names only the 64-bit
1469/// numbers; see [`AUDIT_ARCH_X86_64`].
1470const X32_SYSCALL_BIT: u32 = 0x4000_0000;
1471
1472/// The raw `SECCOMP_RET_*` value a BPF `ret` returns for `action`.
1473///
1474/// The x32 guard emits a `ret` directly, below seccompiler's compilation, so
1475/// it needs the same encoding seccompiler produces from a
1476/// [`seccompiler::SeccompAction`]. The action words are the kernel's, from
1477/// `<linux/seccomp.h>`.
1478fn seccomp_ret_value(action: SeccompAction) -> u32 {
1479    const SECCOMP_RET_KILL_PROCESS: u32 = 0x8000_0000;
1480    const SECCOMP_RET_KILL_THREAD: u32 = 0x0000_0000;
1481    const SECCOMP_RET_ERRNO: u32 = 0x0005_0000;
1482    const SECCOMP_RET_LOG: u32 = 0x7ffc_0000;
1483    const SECCOMP_RET_ALLOW: u32 = 0x7fff_0000;
1484    const SECCOMP_RET_DATA: u32 = 0x0000_ffff;
1485    match action {
1486        SeccompAction::Allow => SECCOMP_RET_ALLOW,
1487        SeccompAction::Errno(errno) => SECCOMP_RET_ERRNO | (u32::from(errno) & SECCOMP_RET_DATA),
1488        SeccompAction::Log => SECCOMP_RET_LOG,
1489        SeccompAction::KillThread => SECCOMP_RET_KILL_THREAD,
1490        SeccompAction::KillProcess => SECCOMP_RET_KILL_PROCESS,
1491    }
1492}
1493
1494/// A five-instruction classic-BPF prologue applying `deny` to every syscall
1495/// carrying `__X32_SYSCALL_BIT`, for prepending to a denylist compiled for
1496/// x86_64.
1497///
1498/// A denylist gates on AUDIT_ARCH_X86_64 and then matches raw syscall numbers.
1499/// The x32 ABI shares that arch but sets `__X32_SYSCALL_BIT` on every number,
1500/// so a denied syscall (`ptrace`, `mount`, ...) issued through x32 matches no
1501/// rule and falls through to the allowing default. Denying the whole x32 range
1502/// closes that path; the crate does not otherwise support x32. The fragment
1503/// falls through to seccompiler's program (which reloads the arch word) when
1504/// the arch is not x86_64, or when the bit is clear:
1505///
1506/// ```text
1507///   [0] A = seccomp_data.arch
1508///   [1] if A != AUDIT_ARCH_X86_64: jump past the guard
1509///   [2] A = seccomp_data.nr
1510///   [3] if A & __X32_SYSCALL_BIT == 0: jump past the guard
1511///   [4] return `deny`
1512/// ```
1513fn x32_deny_prologue(deny: SeccompAction) -> [SockFilter; 5] {
1514    let ret = seccomp_ret_value(deny);
1515    [
1516        SockFilter {
1517            code: bpf::LD_W_ABS,
1518            jt: 0,
1519            jf: 0,
1520            k: bpf::OFF_ARCH,
1521        },
1522        SockFilter {
1523            code: bpf::JMP_JEQ_K,
1524            jt: 0,
1525            jf: 3,
1526            k: AUDIT_ARCH_X86_64,
1527        },
1528        SockFilter {
1529            code: bpf::LD_W_ABS,
1530            jt: 0,
1531            jf: 0,
1532            k: bpf::OFF_NR,
1533        },
1534        SockFilter {
1535            code: bpf::JMP_JSET_K,
1536            jt: 0,
1537            jf: 1,
1538            k: X32_SYSCALL_BIT,
1539        },
1540        SockFilter {
1541            code: bpf::RET_K,
1542            jt: 0,
1543            jf: 0,
1544            k: ret,
1545        },
1546    ]
1547}
1548
1549/// Compiles a single-match-action filter: `listed_action` for the listed
1550/// syscalls (subject to any argument conditions), `default_action` for the
1551/// rest.
1552///
1553/// A syscall's argument conditions decide whether an invocation counts as
1554/// matched; a matched invocation takes the `listed_action`, an unmatched one
1555/// the `default_action`. So conditions refine which invocations of a listed
1556/// syscall the listed action governs, without any per-syscall action.
1557fn compile_filter(
1558    default_action: SeccompAction,
1559    listed_action: SeccompAction,
1560    rules: &[SyscallRule],
1561) -> Result<Vec<SockFilter>, ConfigError> {
1562    let arch = host_target_arch()?;
1563    // An unconditional listing matches the syscall regardless of its
1564    // arguments and dominates any conditioned listing of the same syscall, so
1565    // the two are gathered separately before the map is built: unconditional
1566    // syscalls map to an empty rule vector (the backend's match-anything
1567    // form), conditioned ones to a rule per condition set (an OR).
1568    let mut unconditional: BTreeSet<i64> = BTreeSet::new();
1569    let mut conditioned: BTreeMap<i64, Vec<seccompiler::SeccompRule>> = BTreeMap::new();
1570    for rule in rules {
1571        if rule.conditions.is_empty() {
1572            unconditional.insert(rule.syscall);
1573            continue;
1574        }
1575        let conditions = rule
1576            .conditions
1577            .iter()
1578            .map(|condition| condition.to_seccompiler())
1579            .collect::<Result<Vec<_>, _>>()?;
1580        let seccomp_rule = seccompiler::SeccompRule::new(conditions).map_err(|err| {
1581            ConfigError::SeccompInvalid {
1582                reason: err.to_string(),
1583            }
1584        })?;
1585        conditioned
1586            .entry(rule.syscall)
1587            .or_default()
1588            .push(seccomp_rule);
1589    }
1590    let mut map: BTreeMap<i64, Vec<seccompiler::SeccompRule>> = BTreeMap::new();
1591    for &syscall in &unconditional {
1592        map.insert(syscall, Vec::new());
1593    }
1594    for (syscall, seccomp_rules) in conditioned {
1595        if !unconditional.contains(&syscall) {
1596            map.insert(syscall, seccomp_rules);
1597        }
1598    }
1599    let filter = seccompiler::SeccompFilter::new(
1600        map,
1601        default_action.to_seccompiler(),
1602        listed_action.to_seccompiler(),
1603        arch,
1604    )
1605    .map_err(|err| ConfigError::SeccompInvalid {
1606        reason: err.to_string(),
1607    })?;
1608    let program: seccompiler::BpfProgram =
1609        filter
1610            .try_into()
1611            .map_err(
1612                |err: seccompiler::BackendError| ConfigError::SeccompInvalid {
1613                    reason: err.to_string(),
1614                },
1615            )?;
1616    let compiled = program.into_iter().map(|insn| SockFilter {
1617        code: insn.code,
1618        jt: insn.jt,
1619        jf: insn.jf,
1620        k: insn.k,
1621    });
1622
1623    // On x86_64 a denylist (a permissive default) is bypassable through the x32
1624    // ABI: it shares AUDIT_ARCH_X86_64 but tags every syscall number with
1625    // __X32_SYSCALL_BIT, so a denied number matches no rule and falls through
1626    // to the allowing default. Prepend a guard denying the whole x32 range. An
1627    // allowlist needs none — an unmatched x32 number already takes its denying
1628    // default — and only x86_64 aliases arch and ABI this way. seccompiler's
1629    // per-syscall map cannot express the guard, so it is emitted as raw BPF
1630    // ahead of seccompiler's program, which reloads the arch word itself.
1631    let needs_x32_guard =
1632        matches!(arch, seccompiler::TargetArch::x86_64) && default_action.is_permissive();
1633    let program: Vec<SockFilter> = if needs_x32_guard {
1634        x32_deny_prologue(listed_action)
1635            .into_iter()
1636            .chain(compiled)
1637            .collect()
1638    } else {
1639        compiled.collect()
1640    };
1641    Ok(program)
1642}
1643
1644/// The seccomp target architecture for the host, or an error on an
1645/// architecture the compiler does not target.
1646fn host_target_arch() -> Result<seccompiler::TargetArch, ConfigError> {
1647    match std::env::consts::ARCH {
1648        "x86_64" => Ok(seccompiler::TargetArch::x86_64),
1649        "aarch64" => Ok(seccompiler::TargetArch::aarch64),
1650        "riscv64" => Ok(seccompiler::TargetArch::riscv64),
1651        other => Err(ConfigError::SeccompUnsupportedArch {
1652            arch: other.to_string(),
1653        }),
1654    }
1655}
1656
1657/// The serde adapter bridging the in-memory [`Request`] and a profile's
1658/// hardening representation.
1659///
1660/// The programmatic API works in the request's own terms — real
1661/// [`SeccompPolicy`] values (raw syscall numbers, the precompiled escape
1662/// hatch), [`FsAccess`] bitsets, and [`Capability`] values. A profile is
1663/// written in a reviewable form instead: `rwx` access strings, syscalls and
1664/// capabilities by name, and no place for a precompiled program. This module
1665/// converts between the two through a mirror struct, so [`Request`] stays the
1666/// single in-memory representation and serde is only an adapter, as the
1667/// `serde_os` adapters in [`crate::spec`] are for the builder's `OsString`
1668/// fields.
1669///
1670/// The `[hardening]` table this produces is:
1671///
1672/// ```toml
1673/// [hardening]
1674/// drop-caps = true                 # or: keep-caps = ["net-bind-service"]
1675/// seccomp = "curated"              # or a [hardening.seccomp] deny/allow table
1676///
1677/// [[hardening.landlock-fs]]
1678/// access = "rx"
1679/// path = "/usr"
1680///
1681/// [[hardening.landlock-net]]
1682/// access = "c"
1683/// port = 443
1684/// ```
1685///
1686/// A seccomp allow- or denylist may narrow a syscall by its arguments through
1687/// an `allow-rule` or `deny-rule` array-of-tables, each naming a syscall and
1688/// the argument conditions its listed action requires:
1689///
1690/// ```toml
1691/// [hardening.seccomp]
1692/// allow = ["read", "write", "exit-group"]
1693///
1694/// [[hardening.seccomp.allow-rule]]
1695/// syscall = "ioctl"
1696/// [[hardening.seccomp.allow-rule.arg]]
1697/// index = 1
1698/// len = "dword"
1699/// op = "eq"
1700/// value = 21523            # TIOCGWINSZ
1701/// ```
1702///
1703/// Names resolve for the host architecture: syscall names through the
1704/// `syscalls` crate, capability names through [`Capability`]'s `FromStr`. An
1705/// unknown name is a deserialization error; a policy a profile cannot express
1706/// — a precompiled program, or rules with a custom action — is a
1707/// serialization error, never a silent omission.
1708#[cfg(feature = "serde")]
1709pub(crate) mod serde_request {
1710    use std::path::PathBuf;
1711
1712    use serde::de::Error as _;
1713    use serde::ser::Error as _;
1714    use serde::{Deserialize, Deserializer, Serialize, Serializer};
1715    use syscalls::Sysno;
1716
1717    use super::{
1718        Capability, CapabilityPosture, EPERM, FsAccess, Grant, NetAccess, NetGrant, Request,
1719        SeccompAction, SeccompArg, SeccompArgLen, SeccompCompare, SeccompPolicy, SeccompRules,
1720    };
1721
1722    /// The profile form of a hardening request: the `[hardening]` table.
1723    ///
1724    /// The two capability keys and the seccomp key precede the `landlock-fs`
1725    /// and `landlock-net` arrays so the serialized table is valid TOML in field
1726    /// order — a bare key after an array-of-tables header would bind to the
1727    /// wrong table, the same ordering constraint the top-level builder
1728    /// observes.
1729    #[derive(Serialize, Deserialize, Default)]
1730    #[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
1731    struct HardeningProfile {
1732        /// Drop every capability. Mutually exclusive with `keep-caps`.
1733        #[serde(skip_serializing_if = "std::ops::Not::not")]
1734        drop_caps: bool,
1735        /// Keep only these capabilities, by name, dropping the rest. Mutually
1736        /// exclusive with `drop-caps`. An empty list is rejected rather than
1737        /// read as a drop-all, which `drop-caps` expresses.
1738        #[serde(skip_serializing_if = "Option::is_none")]
1739        keep_caps: Option<Vec<String>>,
1740        /// The seccomp policy, when one is configured.
1741        #[serde(skip_serializing_if = "Option::is_none")]
1742        seccomp: Option<SeccompProfile>,
1743        /// The Landlock filesystem grants.
1744        #[serde(skip_serializing_if = "Vec::is_empty")]
1745        landlock_fs: Vec<GrantProfile>,
1746        /// The Landlock network (TCP port) grants.
1747        #[serde(skip_serializing_if = "Vec::is_empty")]
1748        landlock_net: Vec<NetGrantProfile>,
1749    }
1750
1751    /// One Landlock filesystem grant in a profile: an `rwx` access string and a
1752    /// path.
1753    #[derive(Serialize, Deserialize)]
1754    #[serde(rename_all = "kebab-case", deny_unknown_fields)]
1755    struct GrantProfile {
1756        access: FsAccess,
1757        path: PathBuf,
1758    }
1759
1760    /// One Landlock network grant in a profile: a `bc` access string and a TCP
1761    /// port.
1762    #[derive(Serialize, Deserialize)]
1763    #[serde(rename_all = "kebab-case", deny_unknown_fields)]
1764    struct NetGrantProfile {
1765        access: NetAccess,
1766        port: u16,
1767    }
1768
1769    /// The seccomp policy in a profile. The precompiled-program escape hatch is
1770    /// deliberately absent: a profile is declarative and reviewable.
1771    ///
1772    /// Written either as the string `"curated"` or as a table naming syscalls
1773    /// to allow or deny. The two forms are different TOML types — a string and
1774    /// a table — so the (de)serialization is written by hand rather than
1775    /// derived: the derive for a mixed string-or-table shape (an untagged enum)
1776    /// would silently drop the `deny_unknown_fields` strictness the rest of the
1777    /// profile keeps.
1778    enum SeccompProfile {
1779        /// The curated denylist. Written as the string `"curated"`.
1780        Curated,
1781        /// An allow- or denylist, with optional per-syscall argument rules.
1782        Lists(SeccompLists),
1783    }
1784
1785    impl Serialize for SeccompProfile {
1786        fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1787            match self {
1788                SeccompProfile::Curated => serializer.serialize_str("curated"),
1789                SeccompProfile::Lists(lists) => lists.serialize(serializer),
1790            }
1791        }
1792    }
1793
1794    impl<'de> Deserialize<'de> for SeccompProfile {
1795        fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1796            use serde::de::{self, MapAccess, Visitor};
1797
1798            struct SeccompProfileVisitor;
1799
1800            impl<'de> Visitor<'de> for SeccompProfileVisitor {
1801                type Value = SeccompProfile;
1802
1803                fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1804                    f.write_str("the string \"curated\" or a seccomp allow/deny table")
1805                }
1806
1807                fn visit_str<E: de::Error>(self, value: &str) -> Result<SeccompProfile, E> {
1808                    match value {
1809                        "curated" => Ok(SeccompProfile::Curated),
1810                        other => Err(E::custom(format!(
1811                            "unknown seccomp policy {other:?}, expected \"curated\" or a table"
1812                        ))),
1813                    }
1814                }
1815
1816                fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<SeccompProfile, A::Error> {
1817                    SeccompLists::deserialize(de::value::MapAccessDeserializer::new(map))
1818                        .map(SeccompProfile::Lists)
1819                }
1820            }
1821
1822            deserializer.deserialize_any(SeccompProfileVisitor)
1823        }
1824    }
1825
1826    /// The table form of a seccomp policy: bare syscall names in `allow` or
1827    /// `deny`, and argument-conditioned entries in `allow-rule` or `deny-rule`.
1828    /// The allow and deny sides are mutually exclusive; a table must set one
1829    /// side.
1830    #[derive(Serialize, Deserialize, Default)]
1831    #[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
1832    struct SeccompLists {
1833        #[serde(skip_serializing_if = "Vec::is_empty")]
1834        allow: Vec<String>,
1835        #[serde(skip_serializing_if = "Vec::is_empty")]
1836        allow_rule: Vec<RuleProfile>,
1837        #[serde(skip_serializing_if = "Vec::is_empty")]
1838        deny: Vec<String>,
1839        #[serde(skip_serializing_if = "Vec::is_empty")]
1840        deny_rule: Vec<RuleProfile>,
1841    }
1842
1843    /// One argument-conditioned syscall entry: a syscall name and the argument
1844    /// conditions its listed action requires (all of which must hold).
1845    #[derive(Serialize, Deserialize)]
1846    #[serde(rename_all = "kebab-case", deny_unknown_fields)]
1847    struct RuleProfile {
1848        syscall: String,
1849        #[serde(default, skip_serializing_if = "Vec::is_empty")]
1850        arg: Vec<ArgProfile>,
1851    }
1852
1853    /// One argument condition in a profile: an argument index, the width to
1854    /// compare, the comparison, and a value. `mask` is set only for the
1855    /// `masked-eq` comparison. The width is required rather than defaulted,
1856    /// since neither width is a safe default.
1857    #[derive(Serialize, Deserialize)]
1858    #[serde(rename_all = "kebab-case", deny_unknown_fields)]
1859    struct ArgProfile {
1860        index: u8,
1861        len: ArgLenProfile,
1862        op: ArgOp,
1863        value: u64,
1864        #[serde(default, skip_serializing_if = "Option::is_none")]
1865        mask: Option<u64>,
1866    }
1867
1868    /// The argument width in a profile.
1869    #[derive(Serialize, Deserialize, Clone, Copy)]
1870    #[serde(rename_all = "kebab-case")]
1871    enum ArgLenProfile {
1872        Dword,
1873        Qword,
1874    }
1875
1876    /// The comparison operator in a profile. `MaskedEq` takes its mask from the
1877    /// entry's `mask` field.
1878    #[derive(Serialize, Deserialize, Clone, Copy)]
1879    #[serde(rename_all = "kebab-case")]
1880    enum ArgOp {
1881        Eq,
1882        Ne,
1883        Ge,
1884        Gt,
1885        Le,
1886        Lt,
1887        MaskedEq,
1888    }
1889
1890    /// Serializes a request through its profile form.
1891    pub(crate) fn serialize<S: serde::Serializer>(
1892        request: &Request,
1893        serializer: S,
1894    ) -> Result<S::Ok, S::Error> {
1895        HardeningProfile::from_request(request)
1896            .map_err(S::Error::custom)?
1897            .serialize(serializer)
1898    }
1899
1900    /// Deserializes a request from its profile form.
1901    pub(crate) fn deserialize<'de, D: serde::Deserializer<'de>>(
1902        deserializer: D,
1903    ) -> Result<Request, D::Error> {
1904        HardeningProfile::deserialize(deserializer)?
1905            .into_request()
1906            .map_err(D::Error::custom)
1907    }
1908
1909    impl HardeningProfile {
1910        /// Lowers the profile into the in-memory request, resolving capability
1911        /// names for the host and rejecting a contradictory capability posture.
1912        fn into_request(self) -> Result<Request, String> {
1913            let capabilities = match (self.drop_caps, self.keep_caps) {
1914                (true, Some(_)) => {
1915                    return Err(
1916                        "drop-caps and keep-caps cannot both be set in a hardening profile"
1917                            .to_string(),
1918                    );
1919                }
1920                (true, None) => CapabilityPosture::Reduce(Vec::new()),
1921                (false, Some(names)) if names.is_empty() => {
1922                    return Err("keep-caps is empty; use drop-caps = true to drop every \
1923                                capability"
1924                        .to_string());
1925                }
1926                (false, Some(names)) => CapabilityPosture::Reduce(
1927                    names
1928                        .iter()
1929                        .map(|name| name.parse::<Capability>().map_err(|err| err.to_string()))
1930                        .collect::<Result<Vec<_>, _>>()?,
1931                ),
1932                (false, None) => CapabilityPosture::Keep,
1933            };
1934
1935            let seccomp = self.seccomp.map(SeccompProfile::into_policy).transpose()?;
1936
1937            let landlock = self
1938                .landlock_fs
1939                .into_iter()
1940                .map(|grant| Grant {
1941                    access: grant.access,
1942                    path: grant.path,
1943                })
1944                .collect();
1945
1946            let landlock_net = self
1947                .landlock_net
1948                .into_iter()
1949                .map(|grant| NetGrant {
1950                    access: grant.access,
1951                    port: grant.port,
1952                })
1953                .collect();
1954
1955            Ok(Request {
1956                landlock,
1957                landlock_net,
1958                seccomp,
1959                capabilities,
1960            })
1961        }
1962
1963        /// Raises the in-memory request into its profile form, rejecting the
1964        /// parts a profile cannot represent.
1965        fn from_request(request: &Request) -> Result<HardeningProfile, String> {
1966            let (drop_caps, keep_caps) = match &request.capabilities {
1967                CapabilityPosture::Keep => (false, None),
1968                CapabilityPosture::Reduce(caps) if caps.is_empty() => (true, None),
1969                CapabilityPosture::Reduce(caps) => (
1970                    false,
1971                    Some(caps.iter().map(|cap| cap.to_string()).collect()),
1972                ),
1973            };
1974
1975            let seccomp = request
1976                .seccomp
1977                .as_ref()
1978                .map(SeccompProfile::from_policy)
1979                .transpose()?;
1980
1981            let landlock_fs = request
1982                .landlock
1983                .iter()
1984                .map(|grant| GrantProfile {
1985                    access: grant.access,
1986                    path: grant.path.clone(),
1987                })
1988                .collect();
1989
1990            let landlock_net = request
1991                .landlock_net
1992                .iter()
1993                .map(|grant| NetGrantProfile {
1994                    access: grant.access,
1995                    port: grant.port,
1996                })
1997                .collect();
1998
1999            Ok(HardeningProfile {
2000                drop_caps,
2001                keep_caps,
2002                seccomp,
2003                landlock_fs,
2004                landlock_net,
2005            })
2006        }
2007    }
2008
2009    impl SeccompProfile {
2010        /// Resolves the profile's seccomp policy to a [`SeccompPolicy`],
2011        /// translating syscall names to numbers for the host architecture.
2012        fn into_policy(self) -> Result<SeccompPolicy, String> {
2013            let lists = match self {
2014                SeccompProfile::Curated => return Ok(SeccompPolicy::Curated),
2015                SeccompProfile::Lists(lists) => lists,
2016            };
2017            Ok(SeccompPolicy::Rules(lists.into_rules()?))
2018        }
2019
2020        /// Raises a [`SeccompPolicy`] into its profile form, rejecting a
2021        /// precompiled program and any rules a profile cannot express — a
2022        /// custom action, or a shape that is neither a plain allow- nor
2023        /// denylist.
2024        fn from_policy(policy: &SeccompPolicy) -> Result<SeccompProfile, String> {
2025            match policy {
2026                SeccompPolicy::Curated => Ok(SeccompProfile::Curated),
2027                SeccompPolicy::Rules(rules) => {
2028                    Ok(SeccompProfile::Lists(SeccompLists::from_rules(rules)?))
2029                }
2030                SeccompPolicy::Program(_) => Err(
2031                    "a precompiled seccomp program cannot be represented in a profile".to_string(),
2032                ),
2033            }
2034        }
2035    }
2036
2037    impl SeccompLists {
2038        /// Lowers the table into a [`SeccompRules`], choosing the allow or deny
2039        /// side and attaching each entry's argument conditions. A table that
2040        /// sets both sides, or neither, is rejected.
2041        fn into_rules(self) -> Result<SeccompRules, String> {
2042            let allow_side = !self.allow.is_empty() || !self.allow_rule.is_empty();
2043            let deny_side = !self.deny.is_empty() || !self.deny_rule.is_empty();
2044            let (mut rules, rule_profiles) = match (allow_side, deny_side) {
2045                (true, true) => {
2046                    return Err(
2047                        "a seccomp profile cannot set both an allow and a deny side".to_string()
2048                    );
2049                }
2050                (false, false) => {
2051                    return Err("a seccomp table lists no syscalls; set allow, allow-rule, \
2052                                deny, or deny-rule"
2053                        .to_string());
2054                }
2055                (true, false) => (
2056                    SeccompRules::allowing(resolve_syscalls(&self.allow)?),
2057                    self.allow_rule,
2058                ),
2059                (false, true) => (
2060                    SeccompRules::denying(resolve_syscalls(&self.deny)?),
2061                    self.deny_rule,
2062                ),
2063            };
2064            for profile in rule_profiles {
2065                let syscall = resolve_syscall(&profile.syscall)?;
2066                let conditions = profile
2067                    .arg
2068                    .iter()
2069                    .map(ArgProfile::to_arg)
2070                    .collect::<Result<Vec<_>, _>>()?;
2071                rules = rules.rule(syscall, conditions);
2072            }
2073            Ok(rules)
2074        }
2075
2076        /// Raises a [`SeccompRules`] into its table form, partitioning the
2077        /// listed syscalls into bare names and argument-conditioned entries.
2078        /// Rejects a policy with a custom action, which a profile cannot name.
2079        ///
2080        /// A rule set listing no syscall at all is rejected here for the same
2081        /// reason. The table tells its allow side from its deny side by which
2082        /// of them is non-empty, so a policy with neither has no table that
2083        /// says which it was — it renders as a bare `[hardening.seccomp]` that
2084        /// [`into_rules`](SeccompLists::into_rules) then refuses. Reporting it
2085        /// on the way out is what keeps the promise that a policy a profile
2086        /// cannot express is a serialization error rather than a document that
2087        /// will not read back.
2088        fn from_rules(rules: &SeccompRules) -> Result<SeccompLists, String> {
2089            if rules.rules.is_empty() {
2090                return Err(
2091                    "a seccomp policy listing no syscall cannot be represented in a \
2092                            profile: the table names its side by listing one"
2093                        .to_string(),
2094                );
2095            }
2096            let mut bare: Vec<String> = Vec::new();
2097            let mut rule_profiles: Vec<RuleProfile> = Vec::new();
2098            for rule in &rules.rules {
2099                let syscall = name_syscall(rule.syscall)?;
2100                if rule.conditions.is_empty() {
2101                    bare.push(syscall);
2102                } else {
2103                    rule_profiles.push(RuleProfile {
2104                        syscall,
2105                        arg: rule.conditions.iter().map(ArgProfile::from_arg).collect(),
2106                    });
2107                }
2108            }
2109            match (rules.default_action, rules.listed_action) {
2110                (SeccompAction::Allow, SeccompAction::Errno(EPERM)) => Ok(SeccompLists {
2111                    deny: bare,
2112                    deny_rule: rule_profiles,
2113                    ..SeccompLists::default()
2114                }),
2115                (SeccompAction::Errno(EPERM), SeccompAction::Allow) => Ok(SeccompLists {
2116                    allow: bare,
2117                    allow_rule: rule_profiles,
2118                    ..SeccompLists::default()
2119                }),
2120                _ => Err(
2121                    "a seccomp policy with custom actions cannot be represented in a profile"
2122                        .to_string(),
2123                ),
2124            }
2125        }
2126    }
2127
2128    impl ArgProfile {
2129        /// Lowers one profile argument condition to a [`SeccompArg`], checking
2130        /// that `mask` is present exactly when the comparison is `masked-eq`.
2131        fn to_arg(&self) -> Result<SeccompArg, String> {
2132            let len = match self.len {
2133                ArgLenProfile::Dword => SeccompArgLen::Dword,
2134                ArgLenProfile::Qword => SeccompArgLen::Qword,
2135            };
2136            let compare = match self.op {
2137                ArgOp::MaskedEq => {
2138                    let mask = self
2139                        .mask
2140                        .ok_or_else(|| "op = \"masked-eq\" requires a mask".to_string())?;
2141                    SeccompCompare::MaskedEq(mask)
2142                }
2143                other => {
2144                    if self.mask.is_some() {
2145                        return Err("mask is only valid with op = \"masked-eq\"".to_string());
2146                    }
2147                    match other {
2148                        ArgOp::Eq => SeccompCompare::Eq,
2149                        ArgOp::Ne => SeccompCompare::Ne,
2150                        ArgOp::Ge => SeccompCompare::Ge,
2151                        ArgOp::Gt => SeccompCompare::Gt,
2152                        ArgOp::Le => SeccompCompare::Le,
2153                        ArgOp::Lt => SeccompCompare::Lt,
2154                        ArgOp::MaskedEq => unreachable!("masked-eq handled above"),
2155                    }
2156                }
2157            };
2158            Ok(SeccompArg::new(self.index, len, compare, self.value))
2159        }
2160
2161        /// Raises one [`SeccompArg`] into its profile form.
2162        fn from_arg(arg: &SeccompArg) -> ArgProfile {
2163            let len = match arg.len {
2164                SeccompArgLen::Dword => ArgLenProfile::Dword,
2165                SeccompArgLen::Qword => ArgLenProfile::Qword,
2166            };
2167            let (op, mask) = match arg.compare {
2168                SeccompCompare::Eq => (ArgOp::Eq, None),
2169                SeccompCompare::Ne => (ArgOp::Ne, None),
2170                SeccompCompare::Ge => (ArgOp::Ge, None),
2171                SeccompCompare::Gt => (ArgOp::Gt, None),
2172                SeccompCompare::Le => (ArgOp::Le, None),
2173                SeccompCompare::Lt => (ArgOp::Lt, None),
2174                SeccompCompare::MaskedEq(mask) => (ArgOp::MaskedEq, Some(mask)),
2175            };
2176            ArgProfile {
2177                index: arg.index,
2178                len,
2179                op,
2180                value: arg.value,
2181                mask,
2182            }
2183        }
2184    }
2185
2186    /// Resolves syscall names to their numbers for the host architecture.
2187    fn resolve_syscalls(names: &[String]) -> Result<Vec<i64>, String> {
2188        names.iter().map(|name| resolve_syscall(name)).collect()
2189    }
2190
2191    /// Resolves one syscall name to its number for the host architecture.
2192    fn resolve_syscall(name: &str) -> Result<i64, String> {
2193        name.parse::<Sysno>()
2194            .map(|sysno| i64::from(sysno.id()))
2195            .map_err(|_| format!("unknown syscall {name:?}"))
2196    }
2197
2198    /// Names one syscall number for the host architecture, for serialization.
2199    fn name_syscall(number: i64) -> Result<String, String> {
2200        usize::try_from(number)
2201            .ok()
2202            .and_then(Sysno::new)
2203            .map(|sysno| sysno.name().to_string())
2204            .ok_or_else(|| format!("syscall number {number} has no name on this architecture"))
2205    }
2206}
2207
2208#[cfg(test)]
2209mod tests {
2210    use super::*;
2211
2212    #[test]
2213    fn fs_access_composes_and_contains() {
2214        let rx = FsAccess::READ | FsAccess::EXECUTE;
2215        assert!(rx.contains(FsAccess::READ));
2216        assert!(rx.contains(FsAccess::EXECUTE));
2217        assert!(!rx.contains(FsAccess::WRITE));
2218    }
2219
2220    #[test]
2221    fn fs_access_bit_operations() {
2222        assert!(FsAccess::empty().is_empty());
2223        assert_eq!(FsAccess::default(), FsAccess::empty());
2224        assert_eq!(
2225            FsAccess::all(),
2226            FsAccess::READ | FsAccess::WRITE | FsAccess::EXECUTE
2227        );
2228
2229        let mut set = FsAccess::READ;
2230        set |= FsAccess::WRITE;
2231        assert!(set.contains(FsAccess::WRITE));
2232
2233        // Masking keeps only the shared rights.
2234        assert_eq!(set & FsAccess::EXECUTE, FsAccess::empty());
2235        assert_eq!(set & FsAccess::READ, FsAccess::READ);
2236        set &= FsAccess::READ;
2237        assert_eq!(set, FsAccess::READ);
2238
2239        // Complement stays within the modeled rights.
2240        assert_eq!(!FsAccess::WRITE, FsAccess::READ | FsAccess::EXECUTE);
2241        assert_eq!(!FsAccess::all(), FsAccess::empty());
2242        assert_eq!(!FsAccess::empty(), FsAccess::all());
2243    }
2244
2245    #[test]
2246    fn read_lowers_to_file_and_dir_reads() {
2247        let bits = FsAccess::READ.to_landlock();
2248        // Read maps to the file and directory read rights, plus device ioctls
2249        // (see `a_read_or_write_grant_allows_device_ioctls`).
2250        assert_eq!(
2251            bits,
2252            fs_access::READ_FILE | fs_access::READ_DIR | fs_access::IOCTL_DEV
2253        );
2254    }
2255
2256    #[test]
2257    fn fs_access_displays_and_parses_in_rwx_order() {
2258        assert_eq!(FsAccess::empty().to_string(), "");
2259        assert_eq!(FsAccess::READ.to_string(), "r");
2260        assert_eq!((FsAccess::READ | FsAccess::EXECUTE).to_string(), "rx");
2261        assert_eq!(FsAccess::all().to_string(), "rwx");
2262        // Display and FromStr round-trip across every subset.
2263        for access in [
2264            FsAccess::empty(),
2265            FsAccess::READ,
2266            FsAccess::WRITE,
2267            FsAccess::EXECUTE,
2268            FsAccess::READ | FsAccess::WRITE,
2269            FsAccess::READ | FsAccess::EXECUTE,
2270            FsAccess::WRITE | FsAccess::EXECUTE,
2271            FsAccess::all(),
2272        ] {
2273            assert_eq!(access.to_string().parse::<FsAccess>(), Ok(access));
2274        }
2275    }
2276
2277    #[test]
2278    fn fs_access_parse_is_order_and_case_insensitive() {
2279        let rwx = FsAccess::all();
2280        assert_eq!("xwr".parse::<FsAccess>(), Ok(rwx));
2281        assert_eq!("RWX".parse::<FsAccess>(), Ok(rwx));
2282    }
2283
2284    #[test]
2285    fn fs_access_parse_rejects_unknown_and_repeated_letters() {
2286        let err = "rq".parse::<FsAccess>().unwrap_err();
2287        assert_eq!(err.text(), "rq");
2288        assert!(err.to_string().contains("r, w, and x"));
2289        assert!("rr".parse::<FsAccess>().is_err());
2290    }
2291
2292    #[test]
2293    fn net_access_composes_and_contains() {
2294        let bc = NetAccess::BIND | NetAccess::CONNECT;
2295        assert!(bc.contains(NetAccess::BIND));
2296        assert!(bc.contains(NetAccess::CONNECT));
2297        assert_eq!(NetAccess::all(), bc);
2298
2299        let mut set = NetAccess::BIND;
2300        set |= NetAccess::CONNECT;
2301        assert!(set.contains(NetAccess::CONNECT));
2302
2303        // Masking keeps only the shared rights; complement stays in the set.
2304        assert_eq!(set & NetAccess::BIND, NetAccess::BIND);
2305        set &= NetAccess::BIND;
2306        assert_eq!(set, NetAccess::BIND);
2307        assert_eq!(!NetAccess::BIND, NetAccess::CONNECT);
2308        assert_eq!(!NetAccess::all(), NetAccess::empty());
2309        assert_eq!(!NetAccess::empty(), NetAccess::all());
2310        assert!(NetAccess::empty().is_empty());
2311        assert_eq!(NetAccess::default(), NetAccess::empty());
2312    }
2313
2314    #[test]
2315    fn net_access_lowers_to_kernel_rights() {
2316        assert_eq!(NetAccess::BIND.to_landlock(), net_access::BIND_TCP);
2317        assert_eq!(NetAccess::CONNECT.to_landlock(), net_access::CONNECT_TCP);
2318        assert_eq!(NetAccess::all().to_landlock(), net_access::ALL);
2319    }
2320
2321    #[test]
2322    fn net_access_displays_and_parses_in_bc_order() {
2323        assert_eq!(NetAccess::empty().to_string(), "");
2324        assert_eq!(NetAccess::BIND.to_string(), "b");
2325        assert_eq!(NetAccess::CONNECT.to_string(), "c");
2326        assert_eq!(NetAccess::all().to_string(), "bc");
2327        // Display and FromStr round-trip across every subset.
2328        for access in [
2329            NetAccess::empty(),
2330            NetAccess::BIND,
2331            NetAccess::CONNECT,
2332            NetAccess::all(),
2333        ] {
2334            assert_eq!(access.to_string().parse::<NetAccess>(), Ok(access));
2335        }
2336        // Order- and case-insensitive, like FsAccess.
2337        assert_eq!("cb".parse::<NetAccess>(), Ok(NetAccess::all()));
2338        assert_eq!("BC".parse::<NetAccess>(), Ok(NetAccess::all()));
2339    }
2340
2341    #[test]
2342    fn net_access_parse_rejects_unknown_and_repeated_letters() {
2343        let err = "bz".parse::<NetAccess>().unwrap_err();
2344        assert_eq!(err.text(), "bz");
2345        assert!(err.to_string().contains("b and c"));
2346        assert!("bb".parse::<NetAccess>().is_err());
2347    }
2348
2349    #[test]
2350    fn net_abi_mask_narrows_by_version() {
2351        // Network rights arrive together in ABI 4; nothing below it.
2352        assert_eq!(net_access_for_abi(1), 0);
2353        assert_eq!(net_access_for_abi(3), 0);
2354        assert_eq!(net_access_for_abi(4), net_access::ALL);
2355        assert_eq!(net_access_for_abi(5), net_access::ALL);
2356    }
2357
2358    #[test]
2359    fn net_grant_masked_away_reflects_the_abi() {
2360        // A network grant is masked away below ABI 4, but enforceable at ABI 4
2361        // and above.
2362        assert!(net_grant_masked_away(net_access::ALL, 3));
2363        assert!(!net_grant_masked_away(net_access::ALL, 4));
2364        // No network grant requested (zero mask) drops nothing.
2365        assert!(!net_grant_masked_away(0, 3));
2366        // The judgement is per access kind: a surviving filesystem grant at the
2367        // same ABI must not mask a dropped network grant, or a configured
2368        // network boundary would silently vanish (H2).
2369        assert!(net_grant_masked_away(net_access::ALL, 1));
2370    }
2371
2372    #[test]
2373    fn fs_grant_masked_away_reflects_the_abi() {
2374        // A filesystem grant keeps its ABI-1 base rights, so it is never masked
2375        // away on any Landlock kernel.
2376        assert!(!fs_grant_masked_away(fs_access::ALL, 1));
2377        // No filesystem grant requested (zero mask) drops nothing.
2378        assert!(!fs_grant_masked_away(0, 1));
2379    }
2380
2381    #[test]
2382    fn net_grants_lower_to_rules_and_the_handled_set() {
2383        let mut request = Request::default();
2384        request.grant_net(NetAccess::BIND, 8080);
2385        request.grant_net(NetAccess::CONNECT, 443);
2386        let plan = request.lower().unwrap();
2387        assert_eq!(plan.landlock_net.len(), 2);
2388        assert_eq!(plan.landlock_net[0].port, 8080);
2389        assert_eq!(plan.landlock_net[0].access, net_access::BIND_TCP);
2390        assert_eq!(plan.landlock_net[1].port, 443);
2391        assert_eq!(plan.landlock_net[1].access, net_access::CONNECT_TCP);
2392        // Any network grant enrolls the full modeled network set, so an
2393        // ungranted right is denied rather than left unrestricted.
2394        assert_eq!(plan.landlock_net_handled, net_access::ALL);
2395        // Filesystem rules and their handled set stay empty.
2396        assert!(plan.landlock.is_empty());
2397        assert_eq!(plan.landlock_handled, 0);
2398    }
2399
2400    #[test]
2401    fn a_net_only_request_is_restricting() {
2402        let mut request = Request::default();
2403        request.grant_net(NetAccess::BIND, 0);
2404        assert!(!request.is_unrestricted());
2405    }
2406
2407    #[test]
2408    fn abi_mask_narrows_by_version() {
2409        // v1 excludes REFER, TRUNCATE, and IOCTL_DEV.
2410        let v1 = fs_access_for_abi(1);
2411        assert_eq!(v1 & fs_access::REFER, 0);
2412        assert_eq!(v1 & fs_access::TRUNCATE, 0);
2413        assert_eq!(v1 & fs_access::IOCTL_DEV, 0);
2414        // v3 gains REFER and TRUNCATE but not IOCTL_DEV.
2415        let v3 = fs_access_for_abi(3);
2416        assert_ne!(v3 & fs_access::REFER, 0);
2417        assert_ne!(v3 & fs_access::TRUNCATE, 0);
2418        assert_eq!(v3 & fs_access::IOCTL_DEV, 0);
2419        // v5 is the full set.
2420        assert_eq!(fs_access_for_abi(5), fs_access::ALL);
2421    }
2422
2423    #[test]
2424    fn the_curated_denylist_covers_each_category_it_claims() {
2425        use syscalls::Sysno;
2426
2427        // The roster is not exhaustive, but it grows only additively, so one
2428        // representative of each category it does cover must stay in it.
2429        let list = curated_denylist();
2430        for sysno in [
2431            // Filesystem topology, including the new-mount API in full and the
2432            // file-handle pair that reaches an inode without a path.
2433            Sysno::mount,
2434            Sysno::pivot_root,
2435            Sysno::open_tree,
2436            Sysno::fsopen,
2437            Sysno::fsconfig,
2438            Sysno::fsmount,
2439            Sysno::fspick,
2440            Sysno::move_mount,
2441            Sysno::name_to_handle_at,
2442            Sysno::open_by_handle_at,
2443            // Kernel modules, the keyring, and the administrative surfaces.
2444            Sysno::init_module,
2445            Sysno::keyctl,
2446            Sysno::setns,
2447            // Tracing, cross-process memory, and the exploit primitives beside
2448            // them.
2449            Sysno::ptrace,
2450            Sysno::process_vm_readv,
2451            Sysno::perf_event_open,
2452            Sysno::bpf,
2453            Sysno::userfaultfd,
2454            // Execution domain, and the asynchronous submission ring a filter
2455            // cannot see through.
2456            Sysno::personality,
2457            Sysno::io_uring_setup,
2458            // The clock.
2459            Sysno::clock_settime,
2460        ] {
2461            assert!(
2462                list.contains(&(sysno.id() as i64)),
2463                "{sysno} left the curated denylist",
2464            );
2465        }
2466
2467        #[cfg(target_arch = "x86_64")]
2468        assert!(list.contains(&(Sysno::modify_ldt.id() as i64)));
2469    }
2470
2471    #[test]
2472    fn curated_profile_compiles() {
2473        let program = compile_seccomp(&SeccompPolicy::Curated).unwrap();
2474        assert!(!program.is_empty());
2475    }
2476
2477    #[test]
2478    fn the_curated_denylist_denies_the_terminal_injection_ioctls_by_request() {
2479        use syscalls::Sysno;
2480
2481        // `ioctl` is denied by request, not outright: a sandboxed command asks
2482        // devices for things the ordinary way, and only the two requests that
2483        // write to a terminal's input queue are refused.
2484        let ioctl = Sysno::ioctl.id() as i64;
2485        assert!(
2486            !curated_denylist().contains(&ioctl),
2487            "ioctl is denied by request, so it must not be listed unconditionally",
2488        );
2489        let rules = curated_ioctl_rules();
2490        assert!(rules.iter().all(|rule| rule.syscall == ioctl));
2491        let requests: Vec<u64> = rules
2492            .iter()
2493            .flat_map(|rule| rule.conditions.iter())
2494            .map(|arg| arg.value)
2495            .collect();
2496        assert_eq!(requests, [TIOCSTI, TIOCLINUX]);
2497        // The low 32 bits alone: the kernel takes the request as an
2498        // `unsigned int`, so a 64-bit comparison would miss a request carrying
2499        // anything above bit 31 that is nonetheless dispatched as this one.
2500        for arg in rules.iter().flat_map(|rule| rule.conditions.iter()) {
2501            assert_eq!(arg.index, 1);
2502            assert!(matches!(arg.len, SeccompArgLen::Dword));
2503            assert!(matches!(arg.compare, SeccompCompare::Eq));
2504        }
2505    }
2506
2507    #[test]
2508    #[cfg(target_arch = "x86_64")]
2509    fn a_denylist_is_prefixed_with_the_x32_guard_on_x86_64() {
2510        // A denylist (permissive default) is bypassable through the x32 ABI, so
2511        // the compiled program must begin with the guard denying that range.
2512        let program = compile_seccomp(&SeccompPolicy::Curated).unwrap();
2513        let guard = x32_deny_prologue(SeccompAction::Errno(EPERM));
2514        assert!(program.len() > guard.len());
2515        assert_eq!(&program[..guard.len()], &guard[..]);
2516    }
2517
2518    #[test]
2519    fn the_x32_guard_emits_the_expected_instructions() {
2520        // Five hand-emitted instructions with hand-computed jump offsets: the
2521        // one raw-BPF fragment in the crate, and the one place a wrong constant
2522        // would silently disable a guard rather than fail. Assert them exactly.
2523        //
2524        // Both false branches must land on instruction 5, the first of
2525        // seccompiler's own program: from [1] that is 1 + 1 + jf(3) = 5, and
2526        // from [3] it is 3 + 1 + jf(1) = 5.
2527        let guard = x32_deny_prologue(SeccompAction::Errno(EPERM));
2528        assert_eq!(
2529            guard,
2530            [
2531                // [0] A = seccomp_data.arch
2532                SockFilter {
2533                    code: 0x20,
2534                    jt: 0,
2535                    jf: 0,
2536                    k: 4,
2537                },
2538                // [1] if A != AUDIT_ARCH_X86_64: jump past the guard
2539                SockFilter {
2540                    code: 0x15,
2541                    jt: 0,
2542                    jf: 3,
2543                    k: 0xC000_003E,
2544                },
2545                // [2] A = seccomp_data.nr
2546                SockFilter {
2547                    code: 0x20,
2548                    jt: 0,
2549                    jf: 0,
2550                    k: 0,
2551                },
2552                // [3] if A & __X32_SYSCALL_BIT == 0: jump past the guard
2553                SockFilter {
2554                    code: 0x45,
2555                    jt: 0,
2556                    jf: 1,
2557                    k: 0x4000_0000,
2558                },
2559                // [4] return EPERM
2560                SockFilter {
2561                    code: 0x06,
2562                    jt: 0,
2563                    jf: 0,
2564                    k: 0x0005_0000 | u32::from(EPERM),
2565                },
2566            ],
2567        );
2568
2569        // The returned action is the caller's, not a constant.
2570        assert_eq!(
2571            x32_deny_prologue(SeccompAction::KillProcess)[4].k,
2572            0x8000_0000,
2573        );
2574    }
2575
2576    #[test]
2577    #[cfg(target_arch = "x86_64")]
2578    fn an_allowlist_has_no_x32_guard() {
2579        // An allowlist's denying default already refuses an unmatched x32
2580        // number, so no guard is prepended.
2581        let program = compile_seccomp(&SeccompPolicy::Rules(SeccompRules::allowing([
2582            libc::SYS_read,
2583        ])))
2584        .unwrap();
2585        assert!(
2586            !program
2587                .iter()
2588                .any(|insn| insn.code == bpf::JMP_JSET_K && insn.k == X32_SYSCALL_BIT)
2589        );
2590    }
2591
2592    #[test]
2593    fn seccomp_policies_report_whether_they_restrict() {
2594        assert!(SeccompPolicy::Curated.restricts());
2595        assert!(SeccompPolicy::Rules(SeccompRules::denying([libc::SYS_ptrace])).restricts());
2596        // An allowlist denies every unlisted syscall, so it restricts even when
2597        // it lists nothing.
2598        assert!(SeccompPolicy::Rules(SeccompRules::allowing([])).restricts());
2599        // An empty denylist denies nothing.
2600        assert!(!SeccompPolicy::Rules(SeccompRules::denying([])).restricts());
2601        // A denylist whose listed action still allows denies nothing.
2602        assert!(
2603            !SeccompPolicy::Rules(
2604                SeccompRules::denying([libc::SYS_ptrace]).listed_action(SeccompAction::Allow)
2605            )
2606            .restricts()
2607        );
2608    }
2609
2610    #[test]
2611    fn a_no_op_seccomp_policy_does_not_save_a_restriction_from_the_empty_guard() {
2612        // A filter that denies nothing leaves a restriction unconfined, so it
2613        // must not count as a restriction.
2614        let mut empty = Request::default();
2615        empty.set_seccomp(SeccompPolicy::Rules(SeccompRules::denying([])));
2616        assert!(empty.is_unrestricted());
2617        // The curated policy, which does deny, counts.
2618        let mut curated = Request::default();
2619        curated.set_seccomp(SeccompPolicy::Curated);
2620        assert!(!curated.is_unrestricted());
2621    }
2622
2623    #[test]
2624    fn a_read_or_write_grant_allows_device_ioctls() {
2625        // IOCTL_DEV is in the handled set, so it must be granted with read or
2626        // write access or a granted device path would deny all driver ioctls.
2627        assert_ne!(FsAccess::READ.to_landlock() & fs_access::IOCTL_DEV, 0);
2628        assert_ne!(FsAccess::WRITE.to_landlock() & fs_access::IOCTL_DEV, 0);
2629        // An execute-only grant does not, so device ioctls stay denied there.
2630        assert_eq!(FsAccess::EXECUTE.to_landlock() & fs_access::IOCTL_DEV, 0);
2631    }
2632
2633    #[test]
2634    fn argument_conditioned_rules_compile() {
2635        // An allowlist that permits ioctl only for a specific request, plus a
2636        // masked-eq condition, compiles to a non-empty program.
2637        let rules = SeccompRules::allowing([libc::SYS_read, libc::SYS_write])
2638            .rule(libc::SYS_ioctl, [SeccompArg::eq_dword(1, 0x5413)])
2639            .rule(
2640                libc::SYS_clone,
2641                [SeccompArg::masked_eq_qword(0, 0x1000_0000, 0)],
2642            );
2643        let program = compile_seccomp(&SeccompPolicy::Rules(rules)).unwrap();
2644        assert!(!program.is_empty());
2645    }
2646
2647    #[test]
2648    fn an_unconditional_listing_dominates_a_condition() {
2649        // A syscall listed both unconditionally and with a condition matches
2650        // unconditionally: the compiled program equals the one from the plain
2651        // listing alone, and the extra condition changes nothing.
2652        let plain = compile_seccomp(&SeccompPolicy::Rules(SeccompRules::allowing([
2653            libc::SYS_ioctl,
2654        ])))
2655        .unwrap();
2656        let with_redundant_condition = compile_seccomp(&SeccompPolicy::Rules(
2657            SeccompRules::allowing([libc::SYS_ioctl])
2658                .rule(libc::SYS_ioctl, [SeccompArg::eq_dword(1, 0x5413)]),
2659        ))
2660        .unwrap();
2661        assert_eq!(plain, with_redundant_condition);
2662    }
2663
2664    #[test]
2665    fn an_out_of_range_argument_index_is_rejected() {
2666        // A syscall has six argument registers (indices 0 through 5); an index
2667        // past them is rejected when the condition is lowered.
2668        let rules = SeccompRules::allowing([libc::SYS_read])
2669            .rule(libc::SYS_ioctl, [SeccompArg::eq_dword(6, 0)]);
2670        assert!(matches!(
2671            compile_seccomp(&SeccompPolicy::Rules(rules)),
2672            Err(ConfigError::SeccompInvalid { .. })
2673        ));
2674    }
2675
2676    #[test]
2677    fn an_over_long_program_is_rejected() {
2678        // A precompiled escape-hatch program longer than the kernel ceiling is
2679        // rejected at build time, so its length cannot wrap when narrowed to a
2680        // u16 for the kernel's sock_fprog.
2681        let instruction = SockFilter {
2682            code: 0,
2683            jt: 0,
2684            jf: 0,
2685            k: 0,
2686        };
2687        let program = vec![instruction; BPF_MAXINSNS + 1];
2688        let mut request = Request::default();
2689        request.set_seccomp(SeccompPolicy::Program(program));
2690        assert!(matches!(
2691            request.lower(),
2692            Err(ConfigError::SeccompProgramTooLong {
2693                len,
2694                max: BPF_MAXINSNS,
2695            }) if len == BPF_MAXINSNS + 1
2696        ));
2697    }
2698
2699    #[test]
2700    fn a_maximal_program_is_accepted() {
2701        // Exactly BPF_MAXINSNS instructions is at the boundary and allowed.
2702        let instruction = SockFilter {
2703            code: 0,
2704            jt: 0,
2705            jf: 0,
2706            k: 0,
2707        };
2708        let program = vec![instruction; BPF_MAXINSNS];
2709        assert_eq!(
2710            compile_seccomp(&SeccompPolicy::Program(program))
2711                .unwrap()
2712                .len(),
2713            BPF_MAXINSNS,
2714        );
2715    }
2716
2717    #[test]
2718    fn capability_names_round_trip() {
2719        for &cap in Capability::ALL {
2720            // Display produces the canonical name, which parses back.
2721            let name = cap.to_string();
2722            assert_eq!(name.parse::<Capability>(), Ok(cap));
2723            // The snake form, the cap- prefix, and upper case all parse too.
2724            let snake = name.replace('-', "_");
2725            assert_eq!(snake.parse::<Capability>(), Ok(cap));
2726            assert_eq!(format!("CAP_{snake}").parse::<Capability>(), Ok(cap));
2727            assert_eq!(name.to_uppercase().parse::<Capability>(), Ok(cap));
2728        }
2729    }
2730
2731    #[test]
2732    fn unknown_capability_name_is_rejected() {
2733        let err = "not-a-cap".parse::<Capability>().unwrap_err();
2734        assert_eq!(err.text(), "not-a-cap");
2735        assert!(err.to_string().contains("not-a-cap"));
2736    }
2737
2738    /// `SeccompPolicy` is `Clone`, and stays so.
2739    ///
2740    /// It was a hand-written impl and is now a derive, which the committed API
2741    /// snapshot omits as noise along with every other derived impl in the
2742    /// crate. Losing the trait would therefore no longer show up in that diff,
2743    /// so it is pinned here instead — where the type is, rather than in the
2744    /// snapshot the change moved it out of.
2745    #[test]
2746    fn a_seccomp_policy_clones() {
2747        let program =
2748            compile_seccomp(&SeccompPolicy::Curated).expect("the curated policy compiles");
2749        for policy in [
2750            SeccompPolicy::Curated,
2751            SeccompPolicy::Rules(SeccompRules::denying([libc::SYS_ptrace])),
2752            SeccompPolicy::Program(program),
2753        ] {
2754            let copy = policy.clone();
2755            assert_eq!(format!("{policy:?}"), format!("{copy:?}"));
2756        }
2757    }
2758
2759    #[test]
2760    fn capability_all_has_no_duplicates() {
2761        let mut names: Vec<&str> = Capability::ALL.iter().map(|cap| cap.as_str()).collect();
2762        names.sort_unstable();
2763        let count = names.len();
2764        names.dedup();
2765        assert_eq!(names.len(), count, "capability names must be unique");
2766    }
2767
2768    /// The roster names every capability bit the kernel defines.
2769    ///
2770    /// `ALL` is derived from the declaration, so a variant cannot be missing
2771    /// from it; what this catches is the other direction, which nothing else
2772    /// can. A capability the kernel gained and this enum has not is one a
2773    /// keep-list cannot name, and it shows up here as a bit no variant claims.
2774    ///
2775    /// Asked against the bits `rustix` gives a name, rather than against every
2776    /// bit of the word: the set is a bitflags type with a catch-all, so its
2777    /// `all()` holds the reserved high bits too, and `iter_names` is the set
2778    /// the kernel actually defines.
2779    #[test]
2780    fn every_capability_bit_has_a_variant() {
2781        let claimed = Capability::ALL
2782            .iter()
2783            .fold(CapabilitySet::empty(), |set, cap| set | cap.to_set());
2784        let unclaimed: Vec<&str> = CapabilitySet::all()
2785            .iter_names()
2786            .filter(|(_, bit)| !claimed.contains(*bit))
2787            .map(|(name, _)| name)
2788            .collect();
2789        assert!(
2790            unclaimed.is_empty(),
2791            "capabilities the kernel defines and this enum does not name: {unclaimed:?}",
2792        );
2793    }
2794
2795    #[test]
2796    fn drop_all_lowers_to_empty_keep_set() {
2797        let mut request = Request::default();
2798        request.drop_all_capabilities();
2799        let plan = request.lower().unwrap();
2800        assert_eq!(plan.keep_caps, Some(0));
2801    }
2802
2803    #[test]
2804    fn keep_list_lowers_to_named_bits() {
2805        let mut request = Request::default();
2806        request.keep_capabilities([Capability::NetBindService, Capability::Chown]);
2807        let plan = request.lower().unwrap();
2808        let expected = (CapabilitySet::NET_BIND_SERVICE | CapabilitySet::CHOWN).bits();
2809        assert_eq!(plan.keep_caps, Some(expected));
2810    }
2811
2812    #[test]
2813    fn no_hardening_lowers_to_an_empty_plan() {
2814        let plan = Request::default().lower().unwrap();
2815        assert!(plan.landlock.is_empty());
2816        assert_eq!(plan.landlock_handled, 0);
2817        assert!(plan.landlock_net.is_empty());
2818        assert_eq!(plan.landlock_net_handled, 0);
2819        assert!(plan.seccomp.is_none());
2820        assert!(plan.keep_caps.is_none());
2821    }
2822}