Skip to main content

ferroday_cage/
restrict.rs

1//! The restriction fallback: confine a plain spawned command with Landlock
2//! and seccomp, for hosts without unprivileged user namespaces.
3//!
4//! A [`Restriction`] is the degraded counterpart of a [`Cage`], for a host that
5//! cannot provide the namespaces and root-swap a cage is built from. It spawns
6//! the command directly on the host, confined by the same Landlock filesystem
7//! and network rules and seccomp filters the cage's hardening layer applies,
8//! always under the no-new-privileges flag. The command runs in the host PID
9//! space as the calling user, unmapped.
10//!
11//! Each control confines only what it covers, and nothing is confined by
12//! default:
13//!
14//! - a Landlock filesystem grant restricts the host filesystem to the granted
15//!   paths, but with no filesystem grant the command sees the entire host
16//!   filesystem;
17//! - a Landlock network grant restricts *TCP* bind and connect to the granted
18//!   ports, but leaves UDP, other socket families, and — with no network grant
19//!   — all networking unrestricted;
20//! - a seccomp filter restricts syscalls, but confines neither the filesystem
21//!   nor the network.
22//!
23//! A restriction confined only by seccomp therefore still exposes the whole
24//! host filesystem as the calling user; the filesystem boundary is the
25//! Landlock grant, not the filter.
26//!
27//! # Residual host surface
28//!
29//! Because a restriction runs a host process with no namespace or root-swap
30//! isolation, several host-reaching surfaces are governed only by whatever
31//! Landlock and seccomp the caller configured. A restriction always drops to
32//! the empty capability set, so no capability-gated reach remains; what is left
33//! is same-credential access that only a configured control closes:
34//!
35//! - **Abstract-socket services.** A Landlock network grant governs TCP alone,
36//!   so `AF_UNIX` sockets — including the abstract-namespace paths to D-Bus, the
37//!   X server, and the session bus — stay reachable. Denying them needs a
38//!   seccomp filter on the socket syscalls, or a cage's network namespace.
39//! - **Same-user `/proc`.** Under a seccomp-only restriction with no Landlock
40//!   filesystem grant, `/proc/<pid>/{environ,mem,maps}` of other host processes
41//!   sharing the caller's uid remain readable. A Landlock filesystem grant that
42//!   does not cover `/proc` closes this.
43//! - **Same-user `ptrace`.** Under a Landlock-only restriction (no seccomp),
44//!   `ptrace` of other same-uid host processes is possible where the host's
45//!   Yama policy permits it. The curated seccomp denylist blocks `ptrace`
46//!   outright.
47//! - **The caller's terminal.** A command holding a descriptor onto the
48//!   caller's terminal can read what is typed at it, change its settings, and —
49//!   where the terminal is the command's own controlling terminal — push input
50//!   into it with the `TIOCSTI` ioctl. Landlock does not close this, whatever
51//!   the ABI: its rights bind a descriptor when it is opened, and an inherited
52//!   terminal was opened before the ruleset existed. Neither does a namespace or
53//!   a swapped root: a controlling terminal is reached through the session, not
54//!   through the filesystem, and an inherited descriptor is reached through
55//!   neither. What closes it is not handing the terminal over — see below — the
56//!   `dev.tty.legacy_tiocsti` sysctl, which modern kernels default to off, or a
57//!   seccomp filter on `ioctl`, which the curated denylist provides.
58//!
59//! A cage closes the first three through its namespaces and swapped root; a
60//! restriction closes each of those only through the control that covers it.
61//!
62//! # The caller's terminal
63//!
64//! The fourth surface is not a namespace question, so a cage and a restriction
65//! answer it the same way: **a sandboxed command reaches the caller's terminal
66//! exactly through the standard streams the caller handed it.** Closing it
67//! therefore takes all three streams, and each closes a different part.
68//!
69//! **Standard input decides the session.** A command that inherits it
70//! ([`Stdio::Inherit`](crate::Stdio::Inherit), the default) has been given the
71//! caller's terminal along with it, so it stays in the caller's session. There
72//! the terminal is reachable through file descriptor 0 and through `/dev/tty`,
73//! which is the same terminal by a different name. A command that does not
74//! inherit it — [`Stdio::Null`](crate::Stdio::Null) or
75//! [`Stdio::Fd`](crate::Stdio::Fd) — runs in a session of its own, with no
76//! controlling terminal at all: `/dev/tty` fails with `ENXIO`, and `TIOCSTI` is
77//! refused on every terminal descriptor, because the kernel grants it only for
78//! the caller's own controlling terminal.
79//!
80//! **The output pair is not closed by the session.** Run from a shell with no
81//! redirection, file descriptors 1 and 2 are dups of one read-write open of the
82//! caller's terminal, and a session of the sandbox's own does not take them
83//! away. A command holding them can still read what is typed, leave the
84//! terminal without echo with `tcsetattr`, and resize it with `TIOCSWINSZ`.
85//! [`stdout`](RestrictionBuilder::stdout) and
86//! [`stderr`](RestrictionBuilder::stderr) are what close that, and a capturing
87//! launch closes it too by supplying pipes of its own.
88//!
89//! **What it costs.** A sandbox in a session of its own is outside the caller's
90//! job control, so a caller that wants to stop it on an interrupt uses
91//! [`Running::terminate`](crate::Running::terminate) or
92//! [`Running::kill`](crate::Running::kill). It also has no job control of its
93//! own, since the session it holds owns no terminal — and under
94//! [`Stdio::Inherit`](crate::Stdio::Inherit) it does, but only by taking the
95//! caller's terminal's foreground process group, a shell inside the sandbox
96//! competing with the caller's own shell for one terminal.
97//!
98//! A restriction is a deliberately weaker boundary with its own
99//! configuration, not an automatic downgrade of a cage: its paths are host
100//! paths, so the grants that make a command runnable (its interpreter and
101//! libraries among them) must name host locations. A launch of a [`Cage`] on
102//! a blocked host still fails with [`Error::UsernsUnavailable`]; a caller
103//! that wants to degrade catches that error and runs its separately
104//! configured restriction.
105//!
106//! Beyond the confinement, a restriction shares the cage's process model:
107//! the clean deterministic environment, the standard-stream dispositions,
108//! output streaming to an [`Observer`], a pseudoterminal of its own through
109//! [`spawn_terminal`](Restriction::spawn_terminal), and the [`Running`] handle
110//! with waiting, deadlines, termination, and kill. Because there is no PID
111//! namespace, the same caveats as [`CageBuilder::pid_namespace`]`(false)`
112//! apply: descendants of the command can outlive it, and a kill reaches only
113//! the command process itself.
114//!
115//! [`Cage`]: crate::Cage
116//! [`CageBuilder::pid_namespace`]: crate::CageBuilder::pid_namespace
117//! [`Error::UsernsUnavailable`]: crate::Error::UsernsUnavailable
118
119use std::collections::BTreeMap;
120use std::ffi::{CString, OsStr, OsString};
121use std::path::Path;
122
123use rustix::thread::UnshareFlags;
124
125use crate::error::{ConfigError, Error};
126use crate::hardening::{FsAccess, NetAccess, Request, SeccompPolicy};
127use crate::limits::{Limit, Resource};
128use crate::mechanism::{Confinement, IdentityPlan, LaunchPlan, ManagedPlaceholders, StdioPlan};
129use crate::observer::{Collect, Observer, Output};
130use crate::running::Running;
131use crate::spec::{self, Stdio};
132use crate::status::ExitStatus;
133
134/// The base environment every restricted command starts with.
135///
136/// Deterministic, like the cage's base: nothing is read from the host.
137/// Unlike the cage's, it carries no `HOME` — the command runs as the real
138/// calling user, for whom `/root` would be wrong, and reading the host's
139/// `HOME` would break determinism. A caller that wants `HOME` sets it.
140const BASE_ENV: [(&str, &str); 1] = [("PATH", spec::BASE_PATH)];
141
142/// A validated, ready-to-run restriction.
143///
144/// Constructed by [`Restriction::builder`]; all validation, the seccomp
145/// compilation, and the Landlock lowering happen in
146/// [`RestrictionBuilder::build`], which freezes the configuration into a
147/// launch plan. [`run`](Restriction::run) then executes the command under
148/// the restriction and blocks until it terminates.
149///
150/// # Example
151///
152/// Run a command that may read the system trees and a work directory, and
153/// nothing else on the filesystem:
154///
155/// ```no_run
156/// use ferroday_cage::{FsAccess, Restriction};
157///
158/// # fn main() -> ferroday_cage::Result<()> {
159/// let status = Restriction::builder()
160///     .command("/usr/bin/sort")
161///     .args(["/work/input"])
162///     .landlock_fs(FsAccess::READ | FsAccess::EXECUTE, "/usr")
163///     .landlock_fs(FsAccess::READ | FsAccess::EXECUTE, "/lib")
164///     .landlock_fs(FsAccess::READ, "/etc")
165///     .landlock_fs(FsAccess::READ | FsAccess::WRITE, "/work")
166///     .build()?
167///     .run()?;
168/// assert!(status.success());
169/// # Ok(())
170/// # }
171/// ```
172///
173/// A `Restriction` holds no live resources — it is a frozen plan — so it is
174/// `Clone` and every launch method takes `&self`, exactly as
175/// [`Cage`](crate::Cage) is. One restriction can launch the same command any
176/// number of times, concurrently or in sequence.
177#[derive(Debug, Clone)]
178#[non_exhaustive]
179pub struct Restriction {
180    /// The frozen launch plan the mechanism executes.
181    plan: LaunchPlan,
182}
183
184impl Restriction {
185    /// The environment this restriction will actually apply.
186    ///
187    /// The counterpart of [`Cage::resolved_inputs`](crate::Cage::resolved_inputs).
188    /// Its mount list is always empty: a restriction confines a process in
189    /// place rather than building a filesystem for it.
190    pub fn resolved_inputs(&self) -> crate::ResolvedInputs {
191        crate::ResolvedInputs::project(&self.plan)
192    }
193
194    /// Returns a builder for configuring a restriction.
195    pub fn builder() -> RestrictionBuilder {
196        RestrictionBuilder::default()
197    }
198
199    /// Runs the command under the restriction and blocks until it
200    /// terminates.
201    ///
202    /// Returns `Ok` with the command's [`ExitStatus`] whenever the command
203    /// was executed, regardless of its exit code. `Err` is reserved for the
204    /// library failing: spawning the process, a setup step (a Landlock rule
205    /// that cannot apply, a rejected filter), or collecting the outcome.
206    ///
207    /// The command runs with the stream dispositions the restriction carries,
208    /// which are inherited unless the builder said otherwise.
209    pub fn run(&self) -> Result<ExitStatus, Error> {
210        self.spawn()?.wait()
211    }
212
213    /// Runs the command under the restriction, streaming its output to the
214    /// observer, and blocks until it terminates.
215    ///
216    /// Like [`run`](Self::run), but the command's standard output and
217    /// standard error are captured and delivered to `observer` as they are
218    /// produced, on the calling thread.
219    pub fn run_with(&self, observer: &mut dyn Observer) -> Result<ExitStatus, Error> {
220        self.spawn_with(observer)?.wait()
221    }
222
223    /// Runs the command under the restriction and returns its exit status
224    /// together with everything it wrote.
225    ///
226    /// The restriction's counterpart of [`Cage::output`](crate::Cage::output),
227    /// with the same unbounded-capture caution: for a command whose output
228    /// volume the caller does not control, stream through an [`Observer`] that
229    /// caps what it keeps instead.
230    pub fn output(&self) -> Result<Output, Error> {
231        let mut collect = Collect::default();
232        let status = self.run_with(&mut collect)?;
233        Ok(collect.into_output(status))
234    }
235
236    /// Starts the command under the restriction and returns a handle to it.
237    ///
238    /// `spawn` blocks until the restriction is applied and the command is
239    /// executing, so a setup failure is reported here, as the same typed
240    /// error [`run`](Self::run) would return. The command runs with
241    /// the stream dispositions the restriction carries; the returned [`Running`]
242    /// waits for,
243    /// signals, or kills it.
244    pub fn spawn(&self) -> Result<Running<'static>, Error> {
245        Running::launch(&self.plan, None)
246    }
247
248    /// Starts the command under the restriction, capturing its output for
249    /// the observer, and returns a handle to it.
250    ///
251    /// Like [`spawn`](Self::spawn), but the command's standard output and
252    /// standard error are captured into pipes. The captured bytes are
253    /// delivered to `observer` while the returned handle is waited on; see
254    /// [`Observer`].
255    pub fn spawn_with<'obs>(
256        &self,
257        observer: &'obs mut dyn Observer,
258    ) -> Result<Running<'obs>, Error> {
259        Running::launch(&self.plan, Some(observer))
260    }
261
262    /// Starts the command on a pseudoterminal of its own, returning a handle to
263    /// it and the caller's end of the terminal.
264    ///
265    /// The counterpart of
266    /// [`Cage::spawn_terminal`](crate::Cage::spawn_terminal), and the fallback
267    /// supports it in full: a host-allocated pseudoterminal needs no `devpts`
268    /// instance, so a restriction gets the same interactive story a cage does.
269    /// The command process is the session leader here, since a restriction has
270    /// no PID namespace and so no init above it.
271    pub fn spawn_terminal(
272        &self,
273        terminal: &crate::Terminal,
274    ) -> Result<(Running<'static>, crate::Pty), Error> {
275        Running::launch_terminal(&self.plan, terminal)
276    }
277}
278
279/// Builder for a [`Restriction`].
280///
281/// A restriction needs a command and at least one thing to enforce — a
282/// Landlock grant or a seccomp policy; a restriction that restricts nothing
283/// is rejected by [`build`](Self::build) rather than launching a plain
284/// process that appears sandboxed.
285///
286/// The command starts in `/` with the deterministic base environment
287/// (`PATH` alone) plus the caller's variables, inherited standard streams,
288/// default signal dispositions, an empty signal mask, and a descriptor table
289/// holding only its standard streams. It also starts with no capabilities: a
290/// restriction always drops to the empty capability set, and the always-set
291/// no-new-privileges flag keeps a set-user-ID or file-capabilities binary from
292/// regaining any across the exec. This matters when the caller is privileged —
293/// a root process on a host with unprivileged user namespaces disabled is a
294/// plausible way to reach the restriction fallback — since it would otherwise
295/// pass its full capability set to the command; when the caller is already
296/// unprivileged the drop is a no-op.
297#[derive(Debug, Clone)]
298pub struct RestrictionBuilder {
299    command: Option<std::path::PathBuf>,
300    args: Vec<OsString>,
301    env: Vec<(OsString, OsString)>,
302    /// Whether the deterministic base environment is composed under the
303    /// caller's variables. Defaults to `true`, which is why this type carries
304    /// a hand-written `Default` rather than deriving one.
305    base_env: bool,
306    workdir: Option<std::path::PathBuf>,
307    stdin: Stdio,
308    stdout: Stdio,
309    stderr: Stdio,
310    stop_with_caller: bool,
311    rlimits: BTreeMap<Resource, (Limit, Limit)>,
312    hardening: Request,
313}
314
315impl Default for RestrictionBuilder {
316    fn default() -> Self {
317        RestrictionBuilder {
318            command: None,
319            args: Vec::new(),
320            env: Vec::new(),
321            base_env: true,
322            workdir: None,
323            stdin: Stdio::default(),
324            stdout: Stdio::default(),
325            stderr: Stdio::default(),
326            stop_with_caller: false,
327            rlimits: BTreeMap::new(),
328            hardening: Request::default(),
329        }
330    }
331}
332
333impl RestrictionBuilder {
334    /// Sets the command to execute under the restriction.
335    ///
336    /// The path is a host path and must be absolute. It is not resolved
337    /// against `PATH`. Executing it requires a Landlock grant with
338    /// [`FsAccess::EXECUTE`] covering the file when any grant is configured.
339    pub fn command(mut self, program: impl AsRef<Path>) -> Self {
340        self.command = Some(program.as_ref().to_path_buf());
341        self
342    }
343
344    /// Sets a resource limit on the command.
345    ///
346    /// The restriction's counterpart of
347    /// [`CageBuilder::rlimit`](crate::CageBuilder::rlimit), with the same
348    /// semantics: the limit is applied to the command process before the
349    /// hardening layer and inherited by every process it starts. It matters
350    /// more here than in a cage, because a restriction has no namespaces at
351    /// all — the only thing bounding what the command consumes is what the
352    /// caller sets.
353    pub fn rlimit(
354        mut self,
355        resource: Resource,
356        soft: impl Into<Limit>,
357        hard: impl Into<Limit>,
358    ) -> Self {
359        self.rlimits.insert(resource, (soft.into(), hard.into()));
360        self
361    }
362
363    /// Appends one argument to the command's argument list.
364    pub fn arg(mut self, arg: impl AsRef<OsStr>) -> Self {
365        self.args.push(arg.as_ref().to_os_string());
366        self
367    }
368
369    /// Appends arguments to the command's argument list.
370    pub fn args<I, S>(mut self, args: I) -> Self
371    where
372        I: IntoIterator<Item = S>,
373        S: AsRef<OsStr>,
374    {
375        self.args
376            .extend(args.into_iter().map(|arg| arg.as_ref().to_os_string()));
377        self
378    }
379
380    /// Sets an environment variable for the command.
381    ///
382    /// The command's environment is built from scratch: the deterministic
383    /// base (`PATH` alone) plus the variables set here, which override the
384    /// base on collision. Nothing is inherited from the host.
385    pub fn env(mut self, name: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> Self {
386        self.env
387            .push((name.as_ref().to_os_string(), value.as_ref().to_os_string()));
388        self
389    }
390
391    /// Sets environment variables for the command.
392    ///
393    /// Equivalent to calling [`env`](Self::env) for each pair.
394    pub fn envs<I, K, V>(mut self, vars: I) -> Self
395    where
396        I: IntoIterator<Item = (K, V)>,
397        K: AsRef<OsStr>,
398        V: AsRef<OsStr>,
399    {
400        self.env.extend(
401            vars.into_iter()
402                .map(|(k, v)| (k.as_ref().to_os_string(), v.as_ref().to_os_string())),
403        );
404        self
405    }
406
407    /// Controls whether the deterministic base environment is composed under
408    /// the caller's variables (default `true`).
409    ///
410    /// Set to `false`, the command's environment is exactly the pairs given
411    /// to [`env`](Self::env) and [`envs`](Self::envs) — `PATH` is not
412    /// supplied, and no variable the library may add to its base in a later
413    /// release is supplied either.
414    ///
415    /// The counterpart of [`CageBuilder::base_env`](crate::CageBuilder::base_env),
416    /// over the restriction's own `PATH`-only base.
417    pub fn base_env(mut self, base: bool) -> Self {
418        self.base_env = base;
419        self
420    }
421
422    /// Sets the command's working directory, a host path.
423    ///
424    /// The path must be absolute and must exist at launch; a missing
425    /// directory is a setup error, not a fallback. The default is `/`.
426    pub fn current_dir(mut self, path: impl AsRef<Path>) -> Self {
427        self.workdir = Some(path.as_ref().to_path_buf());
428        self
429    }
430
431    /// Selects the disposition of the command's standard input.
432    ///
433    /// The default is [`Stdio::Inherit`], which also keeps the command in the
434    /// caller's session; see [`Stdio`] for the whole rule. It applies here
435    /// unchanged: a restriction has no namespace to close the caller's terminal
436    /// with, so the standard streams are the only thing that can.
437    pub fn stdin(mut self, stdin: Stdio) -> Self {
438        self.stdin = stdin;
439        self
440    }
441
442    /// Selects the disposition of the command's standard output.
443    ///
444    /// The counterpart of [`CageBuilder::stdout`](crate::CageBuilder::stdout),
445    /// with the same default and the same interaction with a capturing launch.
446    /// It matters at least as much here: a restriction's command inherits the
447    /// caller's file descriptors exactly as a cage's does, and closing the
448    /// caller's terminal to it takes the output pair as well as standard input.
449    pub fn stdout(mut self, stdout: Stdio) -> Self {
450        self.stdout = stdout;
451        self
452    }
453
454    /// Selects the disposition of the command's standard error.
455    ///
456    /// The counterpart of [`stdout`](Self::stdout).
457    pub fn stderr(mut self, stderr: Stdio) -> Self {
458        self.stderr = stderr;
459        self
460    }
461
462    /// Ties the command's lifetime to the caller's (default `false`).
463    ///
464    /// As on the cage builder: when enabled, the command is stopped as soon
465    /// as the calling process exits — however it exits — or the last handle
466    /// to the launch is dropped. Only the command process itself is stopped;
467    /// without a PID namespace its descendants are beyond the library's
468    /// reach.
469    pub fn stop_with_caller(mut self, tie: bool) -> Self {
470        self.stop_with_caller = tie;
471        self
472    }
473
474    /// Grants `access` beneath the host path `path` under a Landlock
475    /// ruleset.
476    ///
477    /// Configuring any grant enrolls the command in Landlock: it may then
478    /// reach a path only where a grant allows it, and everything else on the
479    /// host filesystem is denied. A runnable command therefore needs grants
480    /// covering its own binary and libraries — commonly
481    /// `FsAccess::READ | FsAccess::EXECUTE` on `/usr`, `/bin`, and `/lib` —
482    /// alongside the data paths it works on.
483    ///
484    /// The ruleset is enforced on a kernel that offers Landlock, best-effort
485    /// down to the kernel's supported ABI. A filesystem grant keeps its ABI-1
486    /// base rights on every Landlock kernel, so it always enforces there; a
487    /// kernel without the Landlock LSM cannot enforce the grant, so the
488    /// restriction is refused at build rather than run unconfined.
489    ///
490    /// An empty `access` is refused at build with
491    /// [`ConfigError::LandlockGrantEmpty`](crate::ConfigError::LandlockGrantEmpty):
492    /// it reads as "grant nothing here" and would behave as "deny the whole
493    /// filesystem", the command's own binary included.
494    pub fn landlock_fs(mut self, access: FsAccess, path: impl AsRef<Path>) -> Self {
495        self.hardening.grant_fs(access, path.as_ref());
496        self
497    }
498
499    /// Grants network `access` on the TCP `port` under a Landlock ruleset.
500    ///
501    /// Configuring any grant — filesystem or network — enrolls the command in
502    /// Landlock. A network grant governs TCP bind and connect only: the command
503    /// may bind only ports granted [`NetAccess::BIND`] and connect only to
504    /// ports granted [`NetAccess::CONNECT`], and every other TCP bind and
505    /// connect is denied. Because a restriction runs on the host, these grants
506    /// govern the command's access to the host network.
507    ///
508    /// Landlock confines TCP alone. UDP, raw sockets, and other socket
509    /// families — including `AF_UNIX`, so abstract-socket paths to host
510    /// services such as the session bus or the X server — are not restricted
511    /// by a network grant. A restriction that must deny those needs a seccomp
512    /// filter on the relevant socket syscalls, or a cage's network namespace.
513    ///
514    /// Network rights require Landlock ABI 4. On an older kernel that offers
515    /// Landlock they narrow away best-effort, as an unsupported filesystem
516    /// right does — except that a restriction whose only Landlock grant is a
517    /// network grant is refused at build, since nothing would then be
518    /// enforced. An empty `access` is refused at build for the same reason an
519    /// empty filesystem grant is: it would deny all TCP rather than nothing.
520    ///
521    /// [`NetAccess::BIND`]: crate::NetAccess::BIND
522    /// [`NetAccess::CONNECT`]: crate::NetAccess::CONNECT
523    pub fn landlock_net(mut self, access: NetAccess, port: u16) -> Self {
524        self.hardening.grant_net(access, port);
525        self
526    }
527
528    /// Applies a seccomp syscall filter to the command.
529    ///
530    /// See [`SeccompPolicy`] for the curated profile, caller-authored rules,
531    /// and the pre-compiled escape hatch. The filter binds the command and
532    /// its descendants.
533    pub fn seccomp(mut self, policy: SeccompPolicy) -> Self {
534        self.hardening.set_seccomp(policy);
535        self
536    }
537
538    /// Validates the configuration and freezes it into a [`Restriction`].
539    ///
540    /// All fallible and allocating preparation happens here: the command
541    /// line and environment are marshaled, the Landlock grants are validated
542    /// and frozen, and the seccomp policy is compiled to its BPF program. A
543    /// restriction with no grant and no policy is rejected as
544    /// [`ConfigError::RestrictionEmpty`].
545    pub fn build(self) -> Result<Restriction, Error> {
546        let command = self.command.ok_or(ConfigError::CommandMissing)?;
547        // The absence of a command written down, reported as that rather than
548        // as a path that is not absolute; a cage reads it the same way.
549        if command.as_os_str().is_empty() {
550            return Err(ConfigError::CommandMissing.into());
551        }
552        if !command.is_absolute() {
553            return Err(ConfigError::CommandNotAbsolute { command }.into());
554        }
555        let program = spec::cstring(command.as_os_str())?;
556        let exec_label = spec::exec_label(&command, &[]);
557        let (rlimits, rlimit_labels) = spec::lower_rlimits(&self.rlimits)?;
558        let args = self
559            .args
560            .iter()
561            .map(|arg| spec::cstring(arg))
562            .collect::<Result<Vec<_>, _>>()?;
563        let base: &[(&str, &str)] = if self.base_env { &BASE_ENV } else { &[] };
564        let env = spec::compose_env(base, &self.env)?;
565        let workdir = self
566            .workdir
567            .map(|path| spec::validate_workdir(&path))
568            .transpose()?
569            .flatten();
570
571        if self.hardening.is_unrestricted() {
572            return Err(ConfigError::RestrictionEmpty.into());
573        }
574        let mut hardening = self.hardening.lower()?;
575        // A restriction is confined only by what this host enforces, so its
576        // Landlock grants must actually apply here. If they all fall outside
577        // the running kernel's Landlock ABI, no ruleset is built and the
578        // command would run unconfined; refuse rather than silently drop the
579        // requested confinement.
580        hardening.ensure_landlock_enforceable()?;
581        // Always drop to the empty capability set. A restriction runs on the
582        // host with no user namespace, so a privileged caller (root, or one
583        // holding file capabilities) would otherwise carry its full permitted
584        // and effective sets into the command — CAP_SYS_PTRACE, CAP_SYS_ADMIN,
585        // and the rest, none of which Landlock governs. The drop is applied
586        // after the (no-op) identity switch, under the no-new-privileges flag
587        // set for every non-empty plan, so nothing can be regained across the
588        // exec. Reducing the capability sets is safe and a no-op when the
589        // caller is already unprivileged, which is the common case a
590        // restriction exists to serve.
591        hardening.keep_caps = Some(0);
592
593        Ok(Restriction {
594            plan: LaunchPlan {
595                confinement: Confinement::Restriction,
596                // A restriction creates no user namespace: there is no map
597                // to establish, no identity to switch to, and nothing to nest
598                // inside — the mount flags a cage locks by nesting are the
599                // host's here, and no mount of its own is established.
600                identity: IdentityPlan::Single,
601                nested: None,
602                run_as: None,
603                program,
604                // A restriction keeps the absolute-command contract: no PATH
605                // search over the shared host filesystem.
606                program_search: Vec::new(),
607                args,
608                env,
609                // No root is swapped and no namespace is created: the
610                // container fields stay empty and the launch runs the
611                // outside-supervised process shape.
612                rootfs_path: CString::default(),
613                overlay: None,
614                unshare: UnshareFlags::empty(),
615                pid_namespace: false,
616                stdin: StdioPlan::of(&self.stdin),
617                stdout: StdioPlan::of(&self.stdout),
618                stderr: StdioPlan::of(&self.stderr),
619                // The same rule a cage follows: the command joins the caller's
620                // session only to inherit its standard input. A restriction has
621                // no namespace to close the terminal, so the session and the
622                // output dispositions are the only things that can.
623                own_session: !matches!(self.stdin, Stdio::Inherit),
624                stop_with_caller: self.stop_with_caller,
625                hostname: None,
626                configure_loopback: false,
627                // No network namespace is created, so the command runs in the
628                // caller's.
629                network: crate::Network::Host,
630                workdir,
631                ops: Vec::new(),
632                op_labels: Vec::new(),
633                // A restriction establishes no mount, so it creates no target.
634                managed_placeholders: ManagedPlaceholders::default(),
635                exec_label,
636                rlimits,
637                rlimit_labels,
638                hardening,
639            },
640        })
641    }
642}
643
644#[cfg(test)]
645mod tests {
646    use super::*;
647
648    /// Every field of a restriction's builder is classified against the cage
649    /// builder's, and stays classified because this stops compiling otherwise.
650    ///
651    /// The two types are deliberate twins: a restriction is the degraded mode a
652    /// cage's caller falls back to, and the point is that the same
653    /// configuration reads the same way. What holds them together is prose --
654    /// each setter says what its knob means *under a restriction* -- so the
655    /// pair cannot be generated from one source without losing the thing that
656    /// makes two worth having. What can be held mechanically is that neither
657    /// grows a field the other has not considered.
658    ///
659    /// The destructure below is exhaustive, with no `..`, the technique
660    /// `CageBuilder::check_restricted` uses: a field added to
661    /// `RestrictionBuilder` fails to compile here until someone classifies it.
662    /// The other direction is that same `check_restricted`, which destructures
663    /// `CageBuilder` exhaustively for its own reason -- so a field added there
664    /// is already refused until it is classified, and this comment is where a
665    /// reader is told the pair is what the two destructures are about.
666    #[test]
667    fn every_restriction_builder_field_is_classified_against_the_cage_builder() {
668        let RestrictionBuilder {
669            // The same field, meaning the same thing, on `CageBuilder`. A
670            // change to one of these is a change to both.
671            command: _,
672            args: _,
673            env: _,
674            workdir: _,
675            stdin: _,
676            stdout: _,
677            stderr: _,
678            rlimits: _,
679            hardening: _,
680            // Deliberately different in type. `CageBuilder` holds an
681            // `Option<bool>` for each, because a profile that says nothing has
682            // to be distinguishable from one that says `false` when the two are
683            // merged. A restriction has no profile, so the two-state form is
684            // the whole question here.
685            base_env: _,
686            stop_with_caller: _,
687        } = RestrictionBuilder::default();
688    }
689
690    fn restricted_true() -> RestrictionBuilder {
691        Restriction::builder()
692            .command("/bin/true")
693            .seccomp(SeccompPolicy::Curated)
694    }
695
696    #[test]
697    fn build_requires_a_command() {
698        let err = Restriction::builder()
699            .seccomp(SeccompPolicy::Curated)
700            .build()
701            .unwrap_err();
702        assert!(matches!(err, Error::Config(ConfigError::CommandMissing)));
703    }
704
705    #[test]
706    fn build_rejects_a_relative_command() {
707        let err = Restriction::builder()
708            .command("bin/true")
709            .seccomp(SeccompPolicy::Curated)
710            .build()
711            .unwrap_err();
712        assert!(matches!(
713            err,
714            Error::Config(ConfigError::CommandNotAbsolute { .. })
715        ));
716    }
717
718    #[test]
719    fn an_empty_restriction_is_rejected() {
720        let err = Restriction::builder()
721            .command("/bin/true")
722            .build()
723            .unwrap_err();
724        assert!(matches!(err, Error::Config(ConfigError::RestrictionEmpty)));
725    }
726
727    #[test]
728    fn a_net_grant_alone_is_a_valid_restriction() {
729        // A network grant is something to enforce, so it satisfies the
730        // non-empty requirement on its own. Whether it then builds depends on
731        // the host: a network grant needs Landlock ABI 4, and a net-only
732        // restriction is refused below it rather than run unconfined.
733        let builder = || {
734            Restriction::builder()
735                .command("/bin/true")
736                .landlock_net(NetAccess::CONNECT, 443)
737        };
738        match crate::mechanism::probe_landlock_abi() {
739            Some(abi) if abi >= 4 => {
740                let restriction = builder().build().expect("a network grant is a restriction");
741                let hardening = &restriction.plan.hardening;
742                assert_eq!(hardening.landlock_net.len(), 1);
743                assert_ne!(hardening.landlock_net_handled, 0);
744                assert!(hardening.landlock.is_empty());
745            }
746            _ => {
747                // Below ABI 4, or with no Landlock LSM, the grant cannot apply,
748                // so the build is refused rather than silently dropping it.
749                assert!(matches!(
750                    builder().build(),
751                    Err(Error::Config(
752                        ConfigError::RestrictionLandlockUnenforceable { .. }
753                    ))
754                ));
755            }
756        }
757    }
758
759    #[test]
760    fn a_relative_landlock_path_is_rejected() {
761        let err = Restriction::builder()
762            .command("/bin/true")
763            .landlock_fs(FsAccess::READ, "usr")
764            .build()
765            .unwrap_err();
766        assert!(matches!(
767            err,
768            Error::Config(ConfigError::LandlockPathInvalid { .. })
769        ));
770    }
771
772    #[test]
773    fn a_dot_dot_landlock_path_is_rejected() {
774        let err = Restriction::builder()
775            .command("/bin/true")
776            .landlock_fs(FsAccess::READ, "/usr/../etc")
777            .build()
778            .unwrap_err();
779        assert!(matches!(
780            err,
781            Error::Config(ConfigError::LandlockPathInvalid { .. })
782        ));
783    }
784
785    #[test]
786    fn the_root_is_a_valid_landlock_path() {
787        restricted_true()
788            .landlock_fs(FsAccess::READ | FsAccess::EXECUTE, "/")
789            .build()
790            .expect("a grant on the root covers the whole filesystem");
791    }
792
793    #[test]
794    fn build_rejects_an_empty_command() {
795        // The absence of a command written down, reported as that rather than
796        // as a path that is not absolute -- which is how a cage reads it too.
797        let err = Restriction::builder()
798            .command("")
799            .seccomp(SeccompPolicy::Curated)
800            .build()
801            .unwrap_err();
802        assert!(
803            matches!(err, Error::Config(ConfigError::CommandMissing)),
804            "{err}",
805        );
806    }
807
808    #[test]
809    fn build_rejects_a_relative_workdir() {
810        let err = restricted_true().current_dir("work").build().unwrap_err();
811        assert!(matches!(
812            err,
813            Error::Config(ConfigError::WorkdirNotAbsolute { .. })
814        ));
815    }
816
817    #[test]
818    fn the_environment_base_is_path_alone() {
819        let restriction = restricted_true().env("A", "b").build().unwrap();
820        let env: Vec<&str> = restriction
821            .plan
822            .env
823            .iter()
824            .map(|entry| entry.to_str().unwrap())
825            .collect();
826        assert_eq!(env, ["A=b", &format!("PATH={}", spec::BASE_PATH)]);
827    }
828
829    #[test]
830    fn an_exact_environment_drops_the_restriction_base_too() {
831        // The restriction's base is `PATH` alone, and opting out removes it as
832        // completely as the cage's opt-out removes `PATH` and `HOME`.
833        let restriction = restricted_true()
834            .base_env(false)
835            .env("A", "b")
836            .build()
837            .unwrap();
838        let env: Vec<&str> = restriction
839            .plan
840            .env
841            .iter()
842            .map(|entry| entry.to_str().unwrap())
843            .collect();
844        assert_eq!(env, ["A=b"]);
845    }
846
847    #[test]
848    fn the_plan_creates_no_namespaces_and_mounts_nothing() {
849        let restriction = restricted_true()
850            .current_dir("/tmp")
851            .stdin(Stdio::Null)
852            .stop_with_caller(true)
853            .build()
854            .unwrap();
855        let plan = &restriction.plan;
856        assert_eq!(plan.confinement, Confinement::Restriction);
857        assert!(plan.unshare.is_empty());
858        assert!(!plan.pid_namespace);
859        assert!(!plan.configure_loopback);
860        assert!(plan.hostname.is_none());
861        assert!(plan.ops.is_empty() && plan.op_labels.is_empty());
862        assert!(plan.rootfs_path.is_empty());
863        assert!(matches!(plan.stdin, StdioPlan::Null));
864        assert!(plan.stop_with_caller);
865        assert_eq!(plan.workdir.as_deref(), Some(c"/tmp"));
866        // The lowered hardening carries the compiled policy and always the
867        // empty capability keep set, so a privileged caller's capabilities are
868        // dropped rather than inherited by the command.
869        assert!(plan.hardening.seccomp.is_some());
870        assert_eq!(plan.hardening.keep_caps, Some(0));
871    }
872}