Skip to main content

ferroday_cage/
spec.rs

1//! The sandbox specification: builder, validation, and the launch entry point.
2
3use std::collections::BTreeMap;
4use std::ffi::{CStr, CString, OsStr, OsString};
5use std::os::fd::OwnedFd;
6use std::os::unix::ffi::OsStrExt;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9
10use rustix::fs::{AtFlags, FileType, Mode, OFlags, RawMode, ResolveFlags};
11use rustix::io::Errno;
12use rustix::mount::MountFlags;
13use rustix::thread::UnshareFlags;
14
15use crate::error::{ConfigError, Error, OverlayLayer};
16use crate::idmap::{self, IdMapper, Identity, IdentityMap};
17use crate::limits::{Limit, Resource};
18use crate::mechanism::{
19    Confinement, IdentityPlan, LaunchPlan, ManagedPlaceholders, MountAction, MountDir, MountOp,
20    NestedMap, NestedPlan, OverlayPlan, RlimitPlan, RunAsPlan, StdioPlan,
21};
22use crate::observer::{Collect, Observer, Output};
23use crate::path;
24use crate::resolved::ResolvedInputs;
25use crate::running::{Pending, Running};
26use crate::status::ExitStatus;
27use crate::terminal::{Pty, Terminal};
28
29/// The deterministic base `PATH`, shared by the cage's and the restriction's
30/// base environments.
31pub(crate) const BASE_PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
32
33/// The base environment every sandboxed command starts with.
34///
35/// Deterministic: nothing is read from the host. Caller-supplied variables
36/// are added on top and override these on collision. `HOME` is `/root`
37/// because the command runs mapped to root inside the sandbox.
38const BASE_ENV: [(&str, &str); 2] = [("PATH", BASE_PATH), ("HOME", "/root")];
39
40/// The character devices bound from the host into the sandbox's `/dev`.
41///
42/// `mknod` for character devices is denied in an unprivileged user
43/// namespace, so the minimal device set is bind-mounted from the host.
44///
45/// `tty` is device `5:0`, whose open returns the opening process's controlling
46/// terminal rather than the inode the bind carries, so binding it conveys no
47/// terminal of its own: what a sandbox reaches through it follows from its
48/// session. See [`Stdio`], which decides that.
49const DEV_DEVICES: [&str; 6] = ["null", "zero", "full", "random", "urandom", "tty"];
50
51/// The mode a mount point the sandbox creates is given, and the mode of any
52/// ancestor directory created on the way to one.
53///
54/// A mount point outlives the sandbox — it persists in the rootfs directory on
55/// the host — so the profile states the mode rather than leaving it to the
56/// process umask. This is the conventional directory mode, and what a
57/// distribution ships `/dev` and `/proc` as.
58const DIR_MODE: RawMode = 0o755;
59
60/// The sandbox path the host's resolver configuration is bound onto, and the
61/// one a caller's own mount there replaces.
62const RESOLV_CONF: &str = "/etc/resolv.conf";
63
64/// Whether one of the caller's own mounts targets the sandbox's
65/// [`RESOLV_CONF`].
66///
67/// Every kind of mount counts, not only a bind. What the managed mount yields
68/// to is the caller having claimed the target, and a [`Mount::Raw`] carries a
69/// source and the kernel's own flags -- so a bind spelled through the escape
70/// hatch is the more deliberate spelling of the claim, not a weaker one.
71///
72/// The target is compared by its components, which is what a mount target is:
73/// [`path::absolute_components`] is what every mount's target is read through,
74/// so `/etc//resolv.conf` and `/etc/./resolv.conf` are the same target here as
75/// they will be there.
76///
77/// `fcage` asks the same question of the bind a `--netstack` run composes, which
78/// is one this crate never makes and so cannot answer.
79fn mounts_resolv_conf(mounts: &[Mount]) -> bool {
80    let wanted = path::absolute_components(Path::new(RESOLV_CONF)).ok();
81    mounts
82        .iter()
83        .any(|mount| path::absolute_components(mount.get_target()).ok() == wanted)
84}
85/// The mode of a mount point standing in for a world-writable sticky
86/// directory: `/tmp` and `/dev/shm`, which the profile covers with a
87/// `mode=1777` tmpfs.
88///
89/// The mount hides the mode for as long as it is established, so this is what
90/// the directory is for every other purpose — including a rootfs provisioned
91/// through a sandbox and then deployed, where a `/tmp` no unprivileged process
92/// can write to is a broken image.
93const STICKY_DIR_MODE: RawMode = 0o1777;
94
95/// The symlinks created inside the sandbox's `/dev`, as (name, content).
96const DEV_SYMLINKS: [(&str, &str); 5] = [
97    ("stdin", "/proc/self/fd/0"),
98    ("stdout", "/proc/self/fd/1"),
99    ("stderr", "/proc/self/fd/2"),
100    ("fd", "/proc/self/fd"),
101    ("ptmx", "pts/ptmx"),
102];
103
104/// The sandbox's network posture.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
106#[non_exhaustive]
107#[cfg_attr(
108    feature = "serde",
109    derive(serde::Serialize, serde::Deserialize),
110    serde(rename_all = "lowercase")
111)]
112pub enum Network {
113    /// A new network namespace whose only interface is loopback, brought up
114    /// during setup. The sandbox has no external connectivity. This is the
115    /// default.
116    #[default]
117    Isolated,
118    /// Share the host's network namespace. The sandbox sees the host's
119    /// interfaces, and the host's `resolv.conf` is bound read-only into the
120    /// rootfs unless disabled with [`CageBuilder::resolv_conf`].
121    Host,
122    /// A new network namespace with no interface brought up, not even
123    /// loopback. The sandbox has no connectivity at all — unlike
124    /// [`Isolated`](Self::Isolated), it cannot even reach `127.0.0.1`.
125    None,
126}
127
128/// The disposition of one of the command's standard streams.
129///
130/// One enum serves all three, through [`stdin`](CageBuilder::stdin),
131/// [`stdout`](CageBuilder::stdout), and [`stderr`](CageBuilder::stderr). The
132/// two unit variants are dispositions a profile can express;
133/// [`Fd`](Self::Fd) hands the command an open descriptor and is therefore code,
134/// not configuration. See [`from_fd`](Self::from_fd) for the descriptor form.
135///
136/// The variants read per position. On standard input,
137/// [`Inherit`](Self::Inherit) hands the command the caller's own standard input
138/// and [`Null`](Self::Null) is immediate end-of-file; on the output pair,
139/// `Inherit` sends what the command writes wherever the caller's own output
140/// goes and `Null` discards it.
141///
142/// `Inherit` states no destination: it is the absence of a choice rather than a
143/// choice of the caller's descriptors, and what the command actually receives
144/// follows from the launch. A capturing launch — [`run_with`](Cage::run_with),
145/// [`output`](Cage::output), [`spawn_with`](Cage::spawn_with) — supplies the
146/// output pair itself, and `Inherit` leaves it free to. `Null` and `Fd` state a
147/// destination, so a launch that would supply its own is refused rather than
148/// silently overriding what the caller said.
149///
150/// # The caller's terminal
151///
152/// Closing the caller's terminal to the sandbox takes all three streams, not
153/// standard input alone. A controlling terminal is reached through the session
154/// rather than through the filesystem — no namespace and no swapped root
155/// affects it — and the rule is that a sandboxed command reaches the caller's
156/// terminal exactly through the standard streams the caller handed it.
157///
158/// **The session follows standard input alone.**
159/// [`Inherit`](Self::Inherit) there hands the command the caller's standard
160/// input, so the command stays in the caller's session, where the terminal is
161/// reachable through file descriptor 0 and equally through `/dev/tty`, which
162/// names the same terminal. [`Null`](Self::Null) and [`Fd`](Self::Fd) do not,
163/// so the command runs in a session of its own with no controlling terminal at
164/// all: `/dev/tty` fails with `ENXIO`, and `TIOCSTI` — the ioctl that pushes
165/// characters into a terminal's input queue, which the caller's shell would
166/// then run — is refused on every terminal descriptor, since the kernel grants
167/// it only for the caller's own controlling terminal.
168///
169/// **A session of the sandbox's own does not close the output pair.** It closes
170/// what is reached *through the session*; a descriptor the caller handed over
171/// is reached directly. Run from a shell with no redirection, file descriptors
172/// 1 and 2 are dups of one read-write open of the caller's terminal, so a
173/// command that inherits them can read what is typed at it, leave the caller's
174/// terminal without echo with `tcsetattr`, and resize it with `TIOCSWINSZ`,
175/// which delivers `SIGWINCH` to the caller's foreground process group. `Null`
176/// or `Fd` on the output pair is what closes that, and a capturing launch
177/// closes it too by supplying pipes of its own.
178///
179/// # Job control
180///
181/// Job control has two senses, and the disposition decides where each happens.
182///
183/// *Caller-session job control* is the caller's shell managing the sandbox as
184/// one of its own jobs: an interrupt typed at the caller's terminal reaches the
185/// sandbox, `^Z` suspends it, `fg` resumes it. *Sandbox-session job control* is
186/// a shell inside the sandbox running jobs of its own, which takes `tcsetpgrp`
187/// on a terminal whose session that shell shares.
188///
189/// Under `Inherit` on standard input both work, and both work by reaching into
190/// the caller's session — the second by the sandbox taking the caller's
191/// terminal's foreground process group, a shell inside the sandbox competing
192/// with the caller's own shell for one terminal. Under `Null` or `Fd` neither
193/// works: the sandbox is outside the caller's session, and the session it has
194/// instead owns no terminal to run jobs against. A caller that wants to stop
195/// such a command on an interrupt uses
196/// [`Running::terminate`](crate::Running::terminate) or
197/// [`Running::kill`](crate::Running::kill).
198///
199/// [`Cage::spawn_terminal`] is the posture where the second sense works without
200/// the first's cost: the sandbox gets a terminal of its own, so a shell inside
201/// it takes *that* terminal's foreground process group and the caller's is not
202/// involved at all.
203#[derive(Debug, Clone, Default)]
204#[non_exhaustive]
205pub enum Stdio {
206    /// The stream is left as the launch inherited it: what the caller's own
207    /// stream is wired to, unless the launch supplies its own destination.
208    /// This is the default for all three.
209    ///
210    /// On standard input it also keeps the command in the caller's session,
211    /// and with it the caller's controlling terminal.
212    #[default]
213    Inherit,
214    /// The stream is wired to `/dev/null`: immediate end-of-file on standard
215    /// input, and a discard on the output pair.
216    ///
217    /// On standard input the command also runs in a session of its own.
218    Null,
219    /// The stream is a descriptor the caller supplies.
220    ///
221    /// On standard input the command also runs in a session of its own.
222    ///
223    /// Built with [`from_fd`](Self::from_fd). The descriptor is shared rather
224    /// than owned outright because a [`Cage`] is a clonable frozen plan that
225    /// launches any number of times, so no single launch can consume it.
226    Fd(Arc<OwnedFd>),
227}
228
229impl Stdio {
230    /// The stream is wired to `fd`.
231    ///
232    /// The descriptor is duplicated onto the command's own file descriptor
233    /// after the pivot and survives `execve`; the caller's own copy is
234    /// unaffected. What serves follows from the position: anything readable on
235    /// standard input — the read end of a pipe the caller writes, an open file,
236    /// a pseudoterminal replica — and anything writable on the output pair.
237    ///
238    /// The descriptor is *shared*, not consumed: a [`Cage`] is a frozen plan
239    /// that can launch repeatedly, and every launch wires the same descriptor.
240    /// For a pipe on standard input that means the second launch reads whatever
241    /// the first left, so a cage feeding distinct input per launch is built per
242    /// launch; for a file on an output stream it means the second launch appends
243    /// where the first stopped, since the offset is a property of the open file
244    /// description both share.
245    ///
246    /// The caller owns both ends of whatever it supplies. Feeding a pipe from
247    /// the same thread that waits for the command deadlocks once the write
248    /// exceeds the pipe's capacity (64 KiB by default), because nothing is
249    /// draining the command's output meanwhile; write the input and close the
250    /// write end before waiting, or drive the write from another thread.
251    ///
252    /// # Example
253    ///
254    /// ```no_run
255    /// use std::io::Write;
256    ///
257    /// use ferroday_cage::{Cage, Stdio};
258    ///
259    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
260    /// let (reader, mut writer) = std::io::pipe()?;
261    /// let cage = Cage::builder()
262    ///     .rootfs("/srv/rootfs/alpine")
263    ///     .stdin(Stdio::from_fd(reader.into()))
264    ///     .command("/bin/sh")
265    ///     .args(["-c", "wc -l"])
266    ///     .build()?;
267    ///
268    /// // Write the input and close the write end, so the command sees
269    /// // end-of-file rather than waiting for more.
270    /// writer.write_all(b"one\ntwo\n")?;
271    /// drop(writer);
272    ///
273    /// let output = cage.output()?;
274    /// assert_eq!(output.stdout.trim_ascii(), b"2");
275    /// # Ok(())
276    /// # }
277    /// ```
278    pub fn from_fd(fd: OwnedFd) -> Stdio {
279        Stdio::Fd(Arc::new(fd))
280    }
281}
282
283// A descriptor has no profile representation, so the two unit dispositions
284// serialize as their lowercase names and `Fd` is refused outright rather than
285// serialized as something a reader would take for a disposition it is not.
286#[cfg(feature = "serde")]
287impl serde::Serialize for Stdio {
288    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
289        match self {
290            Stdio::Inherit => serializer.serialize_str("inherit"),
291            Stdio::Null => serializer.serialize_str("null"),
292            Stdio::Fd(_) => Err(serde::ser::Error::custom(
293                "a standard-stream descriptor cannot be represented in a profile",
294            )),
295        }
296    }
297}
298
299#[cfg(feature = "serde")]
300impl<'de> serde::Deserialize<'de> for Stdio {
301    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Stdio, D::Error> {
302        let name = String::deserialize(deserializer)?;
303        match name.as_str() {
304            "inherit" => Ok(Stdio::Inherit),
305            "null" => Ok(Stdio::Null),
306            other => Err(serde::de::Error::unknown_variant(
307                other,
308                &["inherit", "null"],
309            )),
310        }
311    }
312}
313
314/// A raw mount operation, for options the typed profile does not model yet.
315///
316/// A raw mount is passed to the kernel as given: the source, filesystem
317/// type, flags, and data string go into the `mount` syscall verbatim, and a
318/// refused mount surfaces as a setup error naming this mount. Only the
319/// target is validated and confined — it must be a normal absolute path,
320/// resolved inside the rootfs, and missing directories along it are
321/// created.
322///
323/// Raw mounts and bind mounts share one sequence, applied in the order they
324/// are configured.
325///
326/// # Example
327///
328/// ```
329/// use ferroday_cage::RawMount;
330///
331/// // MS_NOSUID | MS_NODEV | MS_NOEXEC
332/// let sysfs = RawMount::new("/sys").fstype("sysfs").flags(0xE);
333/// ```
334#[derive(Debug, Clone)]
335#[non_exhaustive]
336#[cfg_attr(
337    feature = "serde",
338    derive(serde::Serialize, serde::Deserialize),
339    serde(rename_all = "kebab-case", deny_unknown_fields)
340)]
341pub struct RawMount {
342    /// The mount target, an absolute path inside the sandbox.
343    target: PathBuf,
344    /// The mount source, passed to the kernel verbatim.
345    #[cfg_attr(
346        feature = "serde",
347        serde(default, skip_serializing_if = "Option::is_none")
348    )]
349    source: Option<PathBuf>,
350    /// The filesystem type.
351    #[cfg_attr(
352        feature = "serde",
353        serde(default, skip_serializing_if = "Option::is_none")
354    )]
355    fstype: Option<String>,
356    /// Mount flags: the kernel's raw `MS_*` bits.
357    #[cfg_attr(feature = "serde", serde(default))]
358    flags: u64,
359    /// The filesystem data string.
360    #[cfg_attr(
361        feature = "serde",
362        serde(default, skip_serializing_if = "Option::is_none")
363    )]
364    data: Option<String>,
365}
366
367impl RawMount {
368    /// Returns a raw mount of the given target, an absolute path inside the
369    /// sandbox.
370    pub fn new(target: impl AsRef<Path>) -> RawMount {
371        RawMount {
372            target: target.as_ref().to_path_buf(),
373            source: None,
374            fstype: None,
375            flags: 0,
376            data: None,
377        }
378    }
379
380    /// Sets the mount source, passed to the kernel verbatim.
381    pub fn source(mut self, source: impl AsRef<Path>) -> RawMount {
382        self.source = Some(source.as_ref().to_path_buf());
383        self
384    }
385
386    /// Sets the filesystem type.
387    pub fn fstype(mut self, fstype: impl Into<String>) -> RawMount {
388        self.fstype = Some(fstype.into());
389        self
390    }
391
392    /// Sets the mount flags: the kernel's raw `MS_*` bits.
393    pub fn flags(mut self, flags: u64) -> RawMount {
394        self.flags = flags;
395        self
396    }
397
398    /// Sets the filesystem data string.
399    pub fn data(mut self, data: impl Into<String>) -> RawMount {
400        self.data = Some(data.into());
401        self
402    }
403
404    /// The mount target inside the sandbox.
405    pub fn get_target(&self) -> &Path {
406        &self.target
407    }
408
409    /// The mount source, when one was set.
410    pub fn get_source(&self) -> Option<&Path> {
411        self.source.as_deref()
412    }
413
414    /// The filesystem type, when one was set.
415    pub fn get_fstype(&self) -> Option<&str> {
416        self.fstype.as_deref()
417    }
418
419    /// The raw `MS_*` mount flags.
420    pub fn get_flags(&self) -> u64 {
421        self.flags
422    }
423
424    /// The filesystem data string, when one was set.
425    pub fn get_data(&self) -> Option<&str> {
426        self.data.as_deref()
427    }
428}
429
430/// A validated, ready-to-run sandbox.
431///
432/// A `Cage` is constructed by [`Cage::builder`]; all validation and all
433/// fallible preparation happen in [`CageBuilder::build`], which freezes the
434/// configuration into a launch plan. [`Cage::run`] then executes the command
435/// inside the sandbox and blocks until it terminates.
436///
437/// The rootfs is resolved to a canonical path and validated at build time;
438/// each launch resolves that canonical path again inside the sandbox's own
439/// mount namespace.
440///
441/// A `Cage` holds no live resources — it is a frozen plan — so it is `Clone`
442/// and every launch method takes `&self`. One cage can launch the same
443/// sandbox any number of times, concurrently or in sequence, and a clone is
444/// an independent copy a caller can adjust nothing on (adjustment happens on
445/// the [`CageBuilder`], which is also `Clone`).
446#[derive(Debug, Clone)]
447#[non_exhaustive]
448pub struct Cage {
449    /// The frozen launch plan the mechanism executes.
450    plan: LaunchPlan,
451}
452
453impl Cage {
454    /// Returns a builder for configuring a sandbox.
455    pub fn builder() -> CageBuilder {
456        CageBuilder::default()
457    }
458
459    /// The environment and mounts this cage will actually apply.
460    ///
461    /// Where the builder's `get_` accessors report what the caller asked for,
462    /// this reports what that request resolved to: the caller's variables
463    /// composed over the library's base, and the caller's mounts behind the
464    /// managed profile, in the order the sandbox establishes them.
465    ///
466    /// The value a consumer stamps into an artifact's provenance to record the
467    /// inputs a build ran under. See [`ResolvedInputs`] for what its
468    /// contents do and do not promise.
469    pub fn resolved_inputs(&self) -> ResolvedInputs {
470        ResolvedInputs::project(&self.plan)
471    }
472
473    /// Runs the command inside the sandbox and blocks until it terminates.
474    ///
475    /// Returns `Ok` with the command's [`ExitStatus`] whenever the command was
476    /// executed, regardless of its exit code. `Err` is reserved for the
477    /// library failing: spawning the sandbox process, a setup step inside it,
478    /// or collecting its outcome.
479    ///
480    /// The command runs with the stream dispositions the sandbox carries,
481    /// which are inherited unless the builder said otherwise.
482    pub fn run(&self) -> Result<ExitStatus, Error> {
483        self.spawn()?.wait()
484    }
485
486    /// Runs the command inside the sandbox, streaming its output to the
487    /// observer, and blocks until it terminates.
488    ///
489    /// Like [`run`](Self::run), but the command's standard output and
490    /// standard error are captured and delivered to `observer` as they are
491    /// produced, on the calling thread.
492    pub fn run_with(&self, observer: &mut dyn Observer) -> Result<ExitStatus, Error> {
493        self.spawn_with(observer)?.wait()
494    }
495
496    /// Runs the command inside the sandbox and returns its exit status
497    /// together with everything it wrote.
498    ///
499    /// The whole-run convenience: [`run_with`](Self::run_with) against an
500    /// observer that keeps both streams entire.
501    ///
502    /// ```no_run
503    /// # fn main() -> ferroday_cage::Result<()> {
504    /// let output = ferroday_cage::Cage::builder()
505    ///     .rootfs("/srv/rootfs/alpine")
506    ///     .command("/bin/sh")
507    ///     .args(["-c", "echo out; echo err >&2"])
508    ///     .build()?
509    ///     .output()?;
510    /// assert!(output.status.success());
511    /// assert_eq!(output.stdout, b"out\n");
512    /// assert_eq!(output.stderr, b"err\n");
513    /// # Ok(())
514    /// # }
515    /// ```
516    ///
517    /// What it keeps is unbounded: a command that writes without stopping is
518    /// held entirely in memory. For a command whose output volume the caller
519    /// does not control — model-generated code, an untrusted build — use
520    /// [`run_with`](Self::run_with) with an [`Observer`] that caps what it
521    /// retains, or [`spawn_with`](Self::spawn_with) with a deadline.
522    ///
523    /// Capture reaches the command's descendants too, and with it the endpoint
524    /// of the wait; see [`Running::wait`](crate::Running::wait) for the case
525    /// where the command leaves descendants behind.
526    pub fn output(&self) -> Result<Output, Error> {
527        let mut collect = Collect::default();
528        let status = self.run_with(&mut collect)?;
529        Ok(collect.into_output(status))
530    }
531
532    /// Starts the command inside the sandbox and returns a handle to it.
533    ///
534    /// `spawn` blocks until the sandbox is set up and the command is
535    /// executing, so a setup failure is reported here, as the same typed
536    /// error [`run`](Self::run) would return. The command runs with
537    /// the stream dispositions the sandbox carries; the returned [`Running`] waits for,
538    /// signals, or kills it.
539    pub fn spawn(&self) -> Result<Running<'static>, Error> {
540        Running::launch(&self.plan, None)
541    }
542
543    /// Starts the command inside the sandbox, capturing its output for the
544    /// observer, and returns a handle to it.
545    ///
546    /// Like [`spawn`](Self::spawn), but the command's standard output and
547    /// standard error are captured into pipes. The captured bytes are
548    /// delivered to `observer` while the returned handle is waited on;
549    /// see [`Observer`].
550    pub fn spawn_with<'obs>(
551        &self,
552        observer: &'obs mut dyn Observer,
553    ) -> Result<Running<'obs>, Error> {
554        Running::launch(&self.plan, Some(observer))
555    }
556
557    /// Starts the sandbox but holds the command before it runs, returning a
558    /// [`Pending`] handle — the userspace-network seam.
559    ///
560    /// The namespaces are created and the supervisor is published, but the
561    /// command is held at a gate just before `execve`. The returned handle
562    /// exposes [`netns_pid`](Pending::netns_pid), a process in the sandbox's
563    /// network namespace, so a caller can attach a userspace network stack
564    /// (`pasta`, `slirp4netns`) to `/proc/<pid>/ns/net` before releasing the
565    /// command with [`proceed`](Pending::proceed). Attaching a stack before
566    /// proceeding is what lets the command see the network from its first
567    /// instruction, with no race against the stack's startup.
568    ///
569    /// The seam is meaningful only for a network posture with a private
570    /// namespace — [`Network::Isolated`] (the default) or [`Network::None`];
571    /// under [`Network::Host`] the sandbox shares the host's network
572    /// namespace and there is nothing to attach.
573    ///
574    /// A caller that needs no pause uses [`spawn`](Self::spawn) instead; this
575    /// entry point exists for the attach-then-run sequence.
576    pub fn spawn_pending(&self) -> Result<Pending<'static>, Error> {
577        Pending::launch(&self.plan, None)
578    }
579
580    /// Starts the sandbox held before its command runs, capturing the
581    /// command's output for the observer once it is released.
582    ///
583    /// Like [`spawn_pending`](Self::spawn_pending), but the observer bound
584    /// here receives the command's captured output while the [`Running`]
585    /// handle from [`proceed`](Pending::proceed) is waited on, exactly as
586    /// [`spawn_with`](Self::spawn_with) does for a direct launch.
587    pub fn spawn_pending_with<'obs>(
588        &self,
589        observer: &'obs mut dyn Observer,
590    ) -> Result<Pending<'obs>, Error> {
591        Pending::launch(&self.plan, Some(observer))
592    }
593
594    /// Starts the command on a pseudoterminal of its own, returning a handle to
595    /// it and the caller's end of the terminal.
596    ///
597    /// The replica is wired onto the command's file descriptors 0, 1 and 2 — a
598    /// terminal is one stream — and the launch owns its session, so the
599    /// pseudoterminal is the sandbox's controlling terminal whatever the
600    /// standard-input disposition would otherwise have decided. `isatty` is
601    /// deterministically true inside, a full-screen program behaves, and a shell
602    /// inside the sandbox runs jobs of its own. Nothing of the *caller's*
603    /// terminal is reachable.
604    ///
605    /// The two values come back together because the caller holds both at once
606    /// and drives them against each other — reading the primary while waiting on
607    /// the command. See [`Pty`] for what that takes, including the one ordering
608    /// that deadlocks.
609    ///
610    /// Nothing is captured, so no [`Observer`] is involved and no
611    /// [`Progress`](crate::Progress) milestone is reported. A terminal composes
612    /// with [`Stdio::Inherit`] on all three streams and is refused against a
613    /// disposition that names a destination of its own, since the replica is
614    /// already all three.
615    ///
616    /// ```no_run
617    /// use ferroday_cage::{Cage, Terminal};
618    ///
619    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
620    /// let cage = Cage::builder()
621    ///     .rootfs("/srv/rootfs/alpine")
622    ///     .command("/bin/sh")
623    ///     .env("TERM", "xterm-256color")
624    ///     .build()?;
625    ///
626    /// let (mut running, mut pty) = cage.spawn_terminal(&Terminal::new())?;
627    /// std::io::copy(&mut pty, &mut std::io::stdout())?;
628    /// let status = running.wait()?;
629    /// # let _ = status;
630    /// # Ok(())
631    /// # }
632    /// ```
633    pub fn spawn_terminal(&self, terminal: &Terminal) -> Result<(Running<'static>, Pty), Error> {
634        Running::launch_terminal(&self.plan, terminal)
635    }
636
637    /// [`spawn_terminal`](Self::spawn_terminal), held at the gate before the
638    /// command runs — the userspace-network seam with a terminal.
639    ///
640    /// The composition an interactive session needs: a shell with outbound
641    /// networking. The primary comes back here rather than out of
642    /// [`proceed`](Pending::proceed), because the pseudoterminal is allocated
643    /// before the fork and the gate sits after its controlling terminal is
644    /// established; nothing writes to the terminal while the launch is held, so
645    /// the caller attaches its stack and proceeds with the primary in hand.
646    pub fn spawn_pending_terminal(
647        &self,
648        terminal: &Terminal,
649    ) -> Result<(Pending<'static>, Pty), Error> {
650        Pending::launch_terminal(&self.plan, terminal)
651    }
652}
653
654/// One bind mount a sandbox carries: a host path presented at a path inside.
655///
656/// Added with [`CageBuilder::bind`] or [`CageBuilder::bind_ro`], or as a
657/// `[[mount]]` table with `kind = "bind"` in a profile, and read back through
658/// [`CageBuilder::get_binds`].
659#[derive(Debug, Clone)]
660#[non_exhaustive]
661#[cfg_attr(
662    feature = "serde",
663    derive(serde::Serialize, serde::Deserialize),
664    serde(rename_all = "kebab-case", deny_unknown_fields)
665)]
666pub struct Bind {
667    source: PathBuf,
668    target: PathBuf,
669    #[cfg_attr(feature = "serde", serde(default))]
670    read_only: bool,
671}
672
673impl Bind {
674    /// Returns a read-write bind of `source`, a host path, at `target`, an
675    /// absolute path inside the sandbox.
676    ///
677    /// [`CageBuilder::bind`] and [`CageBuilder::bind_ro`] are the ordinary way
678    /// to add one. This constructor is for a consumer assembling a
679    /// [`Mount`] sequence directly.
680    pub fn new(source: impl AsRef<Path>, target: impl AsRef<Path>) -> Bind {
681        Bind {
682            source: source.as_ref().to_path_buf(),
683            target: target.as_ref().to_path_buf(),
684            read_only: false,
685        }
686    }
687
688    /// Sets whether the bind is remounted read-only.
689    pub fn read_only(mut self, read_only: bool) -> Bind {
690        self.read_only = read_only;
691        self
692    }
693
694    /// The host path the bind presents.
695    pub fn get_source(&self) -> &Path {
696        &self.source
697    }
698
699    /// The path inside the sandbox it is presented at.
700    pub fn get_target(&self) -> &Path {
701        &self.target
702    }
703
704    /// Whether the bind is remounted read-only.
705    pub fn is_read_only(&self) -> bool {
706        self.read_only
707    }
708}
709
710/// One mount a consumer adds to a sandbox: a bind, or a raw mount.
711///
712/// The sandbox applies these in the order they are declared, after the
713/// managed mount profile. Holding both kinds in one sequence is what makes
714/// that order expressible: a raw tmpfs and the binds that populate it can be
715/// interleaved, which a bind-then-raw arrangement cannot express.
716///
717/// Added with [`CageBuilder::bind`], [`CageBuilder::bind_ro`], and
718/// [`CageBuilder::raw_mount`], or as a `[[mount]]` table in a profile, and
719/// read back through [`CageBuilder::get_mounts`].
720#[derive(Debug, Clone)]
721#[non_exhaustive]
722#[cfg_attr(
723    feature = "serde",
724    derive(serde::Serialize, serde::Deserialize),
725    serde(tag = "kind", rename_all = "kebab-case")
726)]
727pub enum Mount {
728    /// A bind mount of a host path.
729    Bind(Bind),
730    /// A raw mount, passed to the kernel verbatim.
731    Raw(RawMount),
732}
733
734impl Mount {
735    /// The path inside the sandbox the mount is established at.
736    pub fn get_target(&self) -> &Path {
737        match self {
738            Mount::Bind(bind) => bind.get_target(),
739            Mount::Raw(raw) => raw.get_target(),
740        }
741    }
742}
743
744/// An overlay root: an ordered stack of read-only lower layers, and the upper
745/// layer every write lands in.
746///
747/// Set on a builder with [`CageBuilder::overlay`]. The command sees a merged
748/// view: the lowers stay pristine, while every create, modify, and delete the
749/// sandbox performs lands in the upper. Discarding the upper afterward reverts
750/// the sandbox's changes — the "run against a base, discard the changes" model,
751/// and the basis for staging a build root of `base + increment` over a shared,
752/// unmodified base.
753///
754/// # Example
755///
756/// ```no_run
757/// use ferroday_cage::{Cage, Overlay};
758///
759/// # fn main() -> Result<(), ferroday_cage::Error> {
760/// let cage = Cage::builder()
761///     .overlay(
762///         Overlay::new()
763///             .lower("/srv/base")
764///             .lower("/srv/patches")
765///             .upper("/srv/scratch/run-1"),
766///     )
767///     .command("/bin/sh")
768///     .build()?;
769/// # let _ = cage;
770/// # Ok(())
771/// # }
772/// ```
773///
774/// # Where the upper lives
775///
776/// The upper and its work directory are the one part of an overlay root the
777/// library creates on the host, and they should live under a directory the
778/// calling user controls, not a world-writable one such as `/var/tmp` — the same
779/// requirement [`CageBuilder::rootfs`] states for a plain root.
780///
781/// An entry already at either path is adopted only when it is a directory
782/// belonging to the calling user, and is otherwise refused with
783/// [`ConfigError::OverlayDirUnowned`], so a symbolic link another user planted
784/// there is never followed. That check closes the create; it does not make a
785/// shared parent safe, since the mount options name the layers as paths and the
786/// kernel resolves them again at mount time.
787///
788/// [`ConfigError::OverlayDirUnowned`]: crate::ConfigError::OverlayDirUnowned
789///
790/// # Layer order
791///
792/// Lowers stack base-first, the way image layers do: the first added is the
793/// base, and each later one is laid over it, so a later lower shadows an earlier
794/// one where both hold the same path. The first lower is also the directory the
795/// overlay is mounted over, and the one the sandbox pivots into.
796///
797/// (The kernel's own `lowerdir=` option is highest-precedence-first, the reverse
798/// of this order; the mount options are emitted reversed to match.)
799#[derive(Debug, Clone, Default, PartialEq, Eq)]
800#[cfg_attr(
801    feature = "serde",
802    derive(serde::Serialize, serde::Deserialize),
803    serde(default, rename_all = "kebab-case", deny_unknown_fields)
804)]
805pub struct Overlay {
806    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Vec::is_empty"))]
807    lower: Vec<PathBuf>,
808    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
809    upper: Option<PathBuf>,
810    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
811    work: Option<PathBuf>,
812}
813
814impl Overlay {
815    /// Returns an empty overlay, to be given at least one lower and an upper.
816    pub fn new() -> Overlay {
817        Overlay::default()
818    }
819
820    /// Appends a read-only lower layer, over any already added.
821    ///
822    /// Repeatable and ordered: the first lower is the base the overlay is
823    /// mounted over, and each later one is laid over it, shadowing what an
824    /// earlier lower holds at the same path. Each must exist and be a directory,
825    /// exactly as [`CageBuilder::rootfs`] requires.
826    pub fn lower(mut self, path: impl AsRef<Path>) -> Overlay {
827        self.lower.push(path.as_ref().to_path_buf());
828        self
829    }
830
831    /// Sets the upper layer, where every write lands.
832    ///
833    /// Created if absent, and persists on the host after the sandbox exits: it
834    /// is the caller's to keep or discard. Its filesystem must record `user.*`
835    /// extended attributes, so an on-disk filesystem (ext4, xfs, btrfs) works on
836    /// Linux 5.11 and later, while a tmpfs upper needs Linux 6.6.
837    ///
838    /// It must live under a directory the calling user controls, not a
839    /// world-writable one — see [Where the upper lives](Overlay#where-the-upper-lives).
840    /// The upper is also not the filesystem root: it needs a parent directory
841    /// for the work directory beside it.
842    pub fn upper(mut self, path: impl AsRef<Path>) -> Overlay {
843        self.upper = Some(path.as_ref().to_path_buf());
844        self
845    }
846
847    /// Overrides the work directory the overlay requires.
848    ///
849    /// By default the library manages a hidden sibling of the upper. A caller
850    /// naming its own must place it on the same filesystem as the upper, which
851    /// the overlay requires so it can move files between them; a work directory
852    /// on another filesystem is refused at build time rather than at the mount.
853    /// It carries the same parent-directory requirement the upper does — see
854    /// [Where the upper lives](Overlay#where-the-upper-lives).
855    ///
856    /// The work area holds a directory the kernel creates mode `0`, so a plain
857    /// recursive delete cannot descend into it: discard it with a removal that
858    /// restores traversable permissions as it descends.
859    pub fn work(mut self, path: impl AsRef<Path>) -> Overlay {
860        self.work = Some(path.as_ref().to_path_buf());
861        self
862    }
863
864    /// The read-only lower layers, base-first.
865    pub fn get_lowers(&self) -> &[PathBuf] {
866        &self.lower
867    }
868
869    /// The upper layer every write lands in, when one was set.
870    pub fn get_upper(&self) -> Option<&Path> {
871        self.upper.as_deref()
872    }
873
874    /// The caller-named work directory, when one was set. `None` means the
875    /// library manages a hidden sibling of the upper.
876    pub fn get_work(&self) -> Option<&Path> {
877        self.work.as_deref()
878    }
879}
880
881/// Builder for a [`Cage`].
882///
883/// A sandbox needs at least a rootfs and a command:
884///
885/// ```no_run
886/// use ferroday_cage::Cage;
887///
888/// # fn main() -> Result<(), ferroday_cage::Error> {
889/// let status = Cage::builder()
890///     .rootfs("/srv/rootfs/alpine")
891///     .command("/bin/sh")
892///     .args(["-c", "echo hello"])
893///     .build()?
894///     .run()?;
895/// assert!(status.success());
896/// # Ok(())
897/// # }
898/// ```
899///
900/// By default the sandbox mounts `/proc`, a minimal `/dev`, and a tmpfs
901/// `/tmp` over the rootfs, runs in its own PID namespace under a reaping
902/// init and in an isolated loopback-only network namespace, starts in `/`
903/// with the deterministic base environment (`PATH` and `HOME`), and keeps
904/// the host's hostname. Every part of that profile has a builder method to
905/// change it.
906///
907/// With the `serde` feature the builder is the profile format: it
908/// serializes and deserializes as the sandbox specification, so a profile
909/// file is a `CageBuilder` in any serde format. Keys are kebab-case;
910/// unknown keys are rejected. In TOML:
911///
912/// ```toml
913/// rootfs = "/srv/rootfs/alpine"
914/// command = "/usr/bin/make"
915/// network = "host"
916/// workdir = "/build"
917///
918/// [env]
919/// CARGO_HOME = "/cache/cargo"
920///
921/// [[mount]]
922/// kind = "bind"
923/// source = "/home/user/project"
924/// target = "/build"
925///
926/// [[mount]]
927/// kind = "bind"
928/// source = "/home/user/cache"
929/// target = "/cache"
930/// read-only = true
931/// ```
932///
933/// # Trust
934///
935/// Deserializing a profile straight into a `CageBuilder` trusts it as
936/// code-equivalent configuration. A profile can bind any host path into the
937/// sandbox (`/`, `/etc/shadow`), issue a raw mount with an arbitrary source,
938/// type, flags, and data, share the host network namespace, and overmount the
939/// sandbox's managed `/proc` and `/dev`. Under the single-identity map these
940/// take effect as the calling user, so it is an isolation bypass rather than a
941/// privilege escalation — but a consumer that loads profiles from an untrusted
942/// source (a repository, a download, a multi-tenant store) hands that source
943/// the full sandbox surface.
944///
945/// Load a profile from an untrusted source through `RestrictedProfile`
946/// instead. It forbids the operations that map host resources into the sandbox
947/// or share a host namespace — bind mounts, raw mounts, and host networking —
948/// and yields a builder the consumer can extend with its own trusted calls.
949///
950/// # Inspection
951///
952/// A builder can be read back as well as written. Every setting has a `get_`
953/// accessor — [`get_binds`](Self::get_binds),
954/// [`get_network`](Self::get_network),
955/// [`get_identity_map`](Self::get_identity_map), and so on — so a consumer
956/// that loads configuration it did not write can answer "what does this bind?"
957/// and "does this share the host network?" before launching it. The prefix
958/// exists because the setters own the bare names, the same arrangement
959/// [`std::process::Command`] uses.
960///
961/// The whole builder is also `Debug`, and with `serde` it serializes back to
962/// the profile format, so `toml::to_string(&builder)` renders a configuration
963/// for review. Two things do not survive that, both because they are code or a
964/// resource rather than configuration: an [`id_mapper`](Self::id_mapper)
965/// delegate is simply absent from the output, and a builder holding a
966/// [`Stdio::from_fd`](crate::Stdio::from_fd) disposition fails to serialize at
967/// all, since a descriptor names nothing a profile could carry. Everything else
968/// round-trips.
969// Scalar fields precede the env table and the bind and raw-mount table
970// arrays so the serialized form is valid TOML in field order.
971#[derive(Debug, Clone)]
972#[cfg_attr(
973    feature = "serde",
974    derive(serde::Serialize, serde::Deserialize),
975    serde(default, rename_all = "kebab-case", deny_unknown_fields)
976)]
977pub struct CageBuilder {
978    rootfs: Option<PathBuf>,
979    command: Option<PathBuf>,
980    #[cfg_attr(
981        feature = "serde",
982        serde(with = "serde_os::string_vec", skip_serializing_if = "Vec::is_empty")
983    )]
984    args: Vec<OsString>,
985    network: Network,
986    #[cfg_attr(
987        feature = "serde",
988        serde(with = "serde_os::string_opt", skip_serializing_if = "Option::is_none")
989    )]
990    hostname: Option<OsString>,
991    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
992    workdir: Option<PathBuf>,
993    stdin: Stdio,
994    stdout: Stdio,
995    stderr: Stdio,
996    pid_namespace: bool,
997    // The managed mounts are `Option<bool>` rather than `bool` so that "left at
998    // its default" is distinguishable from "explicitly asked for". Only that
999    // distinction lets `managed_mounts(false)` refuse a contradicting
1000    // `mount_dev(true)` instead of silently overriding it. `None` reads as the
1001    // default, which every one of them documents as `true`.
1002    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
1003    managed_mounts: Option<bool>,
1004    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
1005    mount_proc: Option<bool>,
1006    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
1007    mount_dev: Option<bool>,
1008    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
1009    mount_tmp: Option<bool>,
1010    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
1011    resolv_conf: Option<bool>,
1012    // Whether the library's deterministic base environment is composed under
1013    // the caller's variables. `false` means the command's environment is
1014    // exactly what the caller set, with nothing of the library's underneath.
1015    // `Option` for the same reason the mount toggles carry one: a profile
1016    // records what its author asked for, and an unstated key stays unstated
1017    // through a render-and-reload cycle.
1018    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
1019    base_env: Option<bool>,
1020    stop_with_caller: bool,
1021    path_lookup: bool,
1022    // The identity fields sit between the scalars and the tables: the map
1023    // serializes as a string for the unit forms and a table for ranges, and
1024    // the run-as identity is always a table, so this position keeps the
1025    // serialized profile valid TOML in field order either way.
1026    #[cfg_attr(
1027        feature = "serde",
1028        serde(skip_serializing_if = "identity_map_is_default")
1029    )]
1030    identity_map: IdentityMap,
1031    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
1032    run_as: Option<Identity>,
1033    // The overlay root, set only for an overlay-rooted cage; `None` for a plain
1034    // `rootfs`. Exactly one of the two is in force. It serializes as a table, so
1035    // it sits after every scalar key: a scalar following a table header would be
1036    // parsed into that table.
1037    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
1038    overlay: Option<Overlay>,
1039    // The delegate is code, not configuration: never part of a profile.
1040    #[cfg_attr(feature = "serde", serde(skip))]
1041    id_mapper: Option<Arc<dyn IdMapper>>,
1042    // Keyed by resource, so a repeated call replaces and the applied order is
1043    // fixed. It serializes as the `[rlimit]` table, so it sits with the other
1044    // tables rather than among the scalars.
1045    // One of the two fields whose wire name is not what `rename_all` derives:
1046    // the Rust field is plural and the TOML table it names is singular, as a
1047    // table of entries reads. `mounts` below is the other, and they are the
1048    // only two — every other key in the format comes from `rename_all`.
1049    #[cfg_attr(
1050        feature = "serde",
1051        serde(
1052            rename = "rlimit",
1053            with = "crate::limits::serde_rlimits",
1054            skip_serializing_if = "BTreeMap::is_empty"
1055        )
1056    )]
1057    rlimits: BTreeMap<Resource, (Limit, Limit)>,
1058    #[cfg_attr(
1059        feature = "serde",
1060        serde(with = "serde_os::string_map", skip_serializing_if = "Vec::is_empty")
1061    )]
1062    env: Vec<(OsString, OsString)>,
1063    // Binds and raw mounts share one sequence so the order the caller declared
1064    // them in is the order they are applied in. Two kind-segregated lists could
1065    // not express a raw tmpfs populated by binds, because every bind would
1066    // precede every raw mount whatever the caller wrote.
1067    #[cfg_attr(
1068        feature = "serde",
1069        serde(rename = "mount", skip_serializing_if = "Vec::is_empty")
1070    )]
1071    mounts: Vec<Mount>,
1072    // The hardening request — Landlock grants, a seccomp policy, and a
1073    // capability posture — as the `[hardening]` table. The field holds the
1074    // request directly; a serde adapter bridges it to the profile's reviewable
1075    // form (see `crate::hardening::serde_request`).
1076    #[cfg(feature = "hardening")]
1077    #[cfg_attr(
1078        feature = "serde",
1079        serde(
1080            default,
1081            with = "crate::hardening::serde_request",
1082            skip_serializing_if = "crate::hardening::Request::has_no_hardening"
1083        )
1084    )]
1085    hardening: crate::hardening::Request,
1086    // In a build with `serde` but without `hardening`, a placeholder keeps the
1087    // `hardening` key known so a profile that configures it is refused with a
1088    // clear message rather than silently ignored. The field is never read; it
1089    // exists only for the rejecting `Deserialize` its type carries.
1090    #[cfg(all(feature = "serde", not(feature = "hardening")))]
1091    #[serde(default, skip_serializing)]
1092    #[allow(dead_code)]
1093    hardening: HardeningUnsupported,
1094}
1095
1096/// Placeholder occupying the `hardening` profile key in a build with `serde`
1097/// but without the `hardening` feature.
1098///
1099/// Its [`Deserialize`](serde::Deserialize) rejects a `[hardening]` table with
1100/// a clear message, so a profile's hardening posture is refused outright
1101/// rather than silently dropped by a library that cannot enforce it. It never
1102/// serializes and is never constructed with data.
1103#[cfg(all(feature = "serde", not(feature = "hardening")))]
1104#[derive(Debug, Clone, Default)]
1105struct HardeningUnsupported;
1106
1107#[cfg(all(feature = "serde", not(feature = "hardening")))]
1108impl<'de> serde::Deserialize<'de> for HardeningUnsupported {
1109    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1110        // Consume the table so the deserializer stays consistent, then refuse:
1111        // a hardening posture must never be silently discarded.
1112        serde::de::IgnoredAny::deserialize(deserializer)?;
1113        Err(serde::de::Error::custom(
1114            "this profile configures hardening, but the library was built without the \
1115             `hardening` feature",
1116        ))
1117    }
1118}
1119
1120impl Default for CageBuilder {
1121    fn default() -> Self {
1122        CageBuilder {
1123            rootfs: None,
1124            command: None,
1125            args: Vec::new(),
1126            network: Network::default(),
1127            hostname: None,
1128            workdir: None,
1129            stdin: Stdio::default(),
1130            stdout: Stdio::default(),
1131            stderr: Stdio::default(),
1132            pid_namespace: true,
1133            managed_mounts: None,
1134            mount_proc: None,
1135            mount_dev: None,
1136            mount_tmp: None,
1137            resolv_conf: None,
1138            base_env: None,
1139            stop_with_caller: false,
1140            path_lookup: false,
1141            identity_map: IdentityMap::Single,
1142            run_as: None,
1143            overlay: None,
1144            id_mapper: None,
1145            rlimits: BTreeMap::new(),
1146            env: Vec::new(),
1147            mounts: Vec::new(),
1148            #[cfg(feature = "hardening")]
1149            hardening: crate::hardening::Request::default(),
1150            #[cfg(all(feature = "serde", not(feature = "hardening")))]
1151            hardening: HardeningUnsupported,
1152        }
1153    }
1154}
1155
1156impl CageBuilder {
1157    /// Sets the directory that becomes the sandbox's root filesystem.
1158    ///
1159    /// The path is resolved and opened by [`build`](Self::build); it must
1160    /// exist, be a directory, and not be the host root. Setting a plain rootfs
1161    /// supersedes an [`overlay_rootfs`](Self::overlay_rootfs) call, and the
1162    /// reverse; the last call wins. A consumer overriding an untrusted profile's
1163    /// rootfs therefore clears any overlay it configured along with it.
1164    ///
1165    /// The rootfs must live under a directory the calling user controls, not a
1166    /// world-writable one. The launch resolves the path a second time inside the
1167    /// new mount namespace — a descriptor opened beforehand cannot anchor a
1168    /// mount there — so a local user who can replace a component of the path
1169    /// between the two resolutions could redirect the new root. This is the same
1170    /// requirement [`provision::ensure`](crate::provision::ensure) states for
1171    /// its destination.
1172    pub fn rootfs(mut self, path: impl AsRef<Path>) -> Self {
1173        self.rootfs = Some(path.as_ref().to_path_buf());
1174        self.overlay = None;
1175        self
1176    }
1177
1178    /// Roots the sandbox on an overlay of `lower`, with writes landing in
1179    /// `upper`.
1180    ///
1181    /// The command sees a merged view of the two: `lower` is read-only and stays
1182    /// pristine, while every create, modify, and delete the sandbox performs
1183    /// lands in `upper`. Discarding `upper` afterward reverts the sandbox's
1184    /// changes and leaves `lower` untouched — the "run against a base, discard
1185    /// the changes" model, and the basis for staging a build root of `base +
1186    /// increment` over a shared, unmodified base.
1187    ///
1188    /// `lower` must exist and be a directory, exactly as [`rootfs`](Self::rootfs)
1189    /// requires. `upper` is created if absent, along with a sibling work
1190    /// directory the overlay requires on the same filesystem; both persist on the
1191    /// host after the sandbox exits and are the caller's to keep or discard, and
1192    /// both should live under a directory the calling user controls rather than a
1193    /// world-writable one, for the reason `rootfs` gives and for the one in
1194    /// [Where the upper lives](Overlay#where-the-upper-lives). The
1195    /// overlay's work area holds a directory the kernel creates mode `0`, so a
1196    /// plain recursive delete cannot descend into it: discard the upper and its
1197    /// work directory with a removal that restores traversable permissions as it
1198    /// descends.
1199    ///
1200    /// This is an unprivileged overlay mounted from inside the sandbox's user
1201    /// namespace, which the host must support: [`build`](Self::build) refuses a
1202    /// host that cannot establish one, and
1203    /// [`host::overlay_blocker`](crate::host::overlay_blocker) reports what is
1204    /// missing. The upper's filesystem must record `user.*` extended attributes,
1205    /// so an on-disk filesystem (ext4, xfs, btrfs) works on Linux 5.11 and later,
1206    /// while a tmpfs upper needs Linux 6.6. Setting an overlay root supersedes a
1207    /// plain [`rootfs`](Self::rootfs), and the reverse; the last call wins.
1208    ///
1209    /// The shorthand for [`overlay`](Self::overlay), carrying the one lower and
1210    /// one upper an overlay always needs. Reach for the value form to stack
1211    /// several lowers or to name the work directory.
1212    pub fn overlay_rootfs(self, lower: impl AsRef<Path>, upper: impl AsRef<Path>) -> Self {
1213        self.overlay(Overlay::new().lower(lower).upper(upper))
1214    }
1215
1216    /// Roots the sandbox on an [`Overlay`].
1217    ///
1218    /// The extensible form of [`overlay_rootfs`](Self::overlay_rootfs): it takes
1219    /// the whole overlay as a value, so a stack of several lowers or a
1220    /// caller-named work directory is expressible, and further axes arrive as
1221    /// methods on [`Overlay`].
1222    ///
1223    /// Setting an overlay root supersedes a plain [`rootfs`](Self::rootfs), and
1224    /// the reverse; the last call wins.
1225    ///
1226    /// This is an unprivileged overlay mounted from inside the sandbox's user
1227    /// namespace, which the host must support: [`build`](Self::build) refuses a
1228    /// host that cannot establish one, and
1229    /// [`host::overlay_blocker`](crate::host::overlay_blocker) reports what is
1230    /// missing. The upper and work directories should live under a directory the
1231    /// calling user controls; see
1232    /// [Where the upper lives](Overlay#where-the-upper-lives).
1233    pub fn overlay(mut self, overlay: Overlay) -> Self {
1234        self.rootfs = None;
1235        self.overlay = Some(overlay);
1236        self
1237    }
1238
1239    /// Sets the command to execute inside the sandbox.
1240    ///
1241    /// The path is interpreted inside the sandbox's root filesystem and must
1242    /// be absolute, unless [`path_lookup`](Self::path_lookup) is enabled and
1243    /// the command has no slash, in which case it is resolved against the
1244    /// sandbox's `PATH`.
1245    pub fn command(mut self, program: impl AsRef<Path>) -> Self {
1246        self.command = Some(program.as_ref().to_path_buf());
1247        self
1248    }
1249
1250    /// Resolves a command with no slash against the sandbox's `PATH`, like a
1251    /// shell.
1252    ///
1253    /// Off by default: a command must be an absolute path, executed directly.
1254    /// With path lookup on, a command that contains no slash — a bare name such
1255    /// as `dpkg-buildpackage` — is searched for in each directory of the
1256    /// command's effective `PATH` (the deterministic base `PATH`, or a `PATH`
1257    /// the caller set through [`env`](Self::env)), running the first match. The
1258    /// search happens inside the sandbox against its own filesystem, after the
1259    /// root swap, so it finds the sandbox's executables rather than the host's.
1260    /// A command that contains a slash is still required to be absolute; path
1261    /// lookup never resolves a relative path.
1262    ///
1263    /// Only absolute `PATH` entries are searched — an empty or relative entry is
1264    /// skipped rather than resolved against the working directory. A `PATH` with
1265    /// no absolute entry therefore leaves nothing to search, and
1266    /// [`build`](Self::build) refuses it with
1267    /// [`ConfigError::SearchPathUnusable`] rather than fall back to a relative
1268    /// execution.
1269    pub fn path_lookup(mut self, enabled: bool) -> Self {
1270        self.path_lookup = enabled;
1271        self
1272    }
1273
1274    /// Appends one argument to the command's argument list.
1275    pub fn arg(mut self, arg: impl AsRef<OsStr>) -> Self {
1276        self.args.push(arg.as_ref().to_os_string());
1277        self
1278    }
1279
1280    /// Appends arguments to the command's argument list.
1281    pub fn args<I, S>(mut self, args: I) -> Self
1282    where
1283        I: IntoIterator<Item = S>,
1284        S: AsRef<OsStr>,
1285    {
1286        self.args
1287            .extend(args.into_iter().map(|arg| arg.as_ref().to_os_string()));
1288        self
1289    }
1290
1291    /// Clears the command's argument list.
1292    ///
1293    /// [`command`](Self::command) replaces the program but leaves the
1294    /// arguments in place, and [`arg`](Self::arg) and [`args`](Self::args)
1295    /// append to whatever is already configured. A consumer composing over a
1296    /// profile that carries its own arguments calls this before setting a new
1297    /// command, so the command is replaced as a unit rather than the new
1298    /// command inheriting the profile's arguments.
1299    pub fn clear_args(mut self) -> Self {
1300        self.args.clear();
1301        self
1302    }
1303
1304    /// Bind-mounts a host path read-write into the sandbox.
1305    ///
1306    /// `source` is a host path and must exist at build time; `target` is an
1307    /// absolute path inside the sandbox. Mounts are applied in the order they
1308    /// are configured — binds and raw mounts in one sequence — after the
1309    /// managed mount profile, save the `resolv.conf` bind of
1310    /// [`Network::Host`], which is applied last of all. Submounts of the
1311    /// source are included.
1312    ///
1313    /// A missing target is created inside the rootfs, including missing parent
1314    /// directories. Those directories are created mode `0755` and persist in
1315    /// the rootfs directory on the host after the sandbox exits, as a mount
1316    /// point directory of the managed profile does. A missing *file* target is
1317    /// created as an empty mode-`0644` file and does not persist: it is content
1318    /// the rootfs never had, present only for the mount to cover, and it is
1319    /// removed once the sandbox handle is dropped. A target the rootfs already
1320    /// ships is left untouched — the mount covers it for the sandbox's life and
1321    /// reveals it again afterwards — and so is one created inside another mount
1322    /// rather than in the rootfs itself.
1323    pub fn bind(mut self, source: impl AsRef<Path>, target: impl AsRef<Path>) -> Self {
1324        self.mounts.push(Mount::Bind(Bind {
1325            source: source.as_ref().to_path_buf(),
1326            target: target.as_ref().to_path_buf(),
1327            read_only: false,
1328        }));
1329        self
1330    }
1331
1332    /// Bind-mounts a host path read-only into the sandbox.
1333    ///
1334    /// Like [`bind`](Self::bind), but the mount is made read-only,
1335    /// recursively: submounts of the source are included in the bind and are
1336    /// made read-only along with it, so a read-only bind of a tree that
1337    /// carries nested mounts does not leave those nested mounts writable. On a
1338    /// kernel that predates `mount_setattr` (before Linux 5.12) only the top
1339    /// mount is made read-only and any submounts keep their own flags.
1340    ///
1341    /// # What read-only means here
1342    ///
1343    /// A boundary, not merely a view. The command cannot remount the bind
1344    /// read-write, and a write to it answers `EROFS`.
1345    ///
1346    /// The kernel locks a mount's flags when it copies a mount tree *into* a
1347    /// new user namespace, and the command enters one of its own between the
1348    /// resource limits and the hardening layer. The lock holds against a
1349    /// command that is root of that namespace with `CAP_SYS_ADMIN` and an
1350    /// unfiltered `mount` syscall, so nothing else has to be configured for a
1351    /// `bind_ro` to mean what it says: no capability drop, no seccomp filter,
1352    /// and no non-root [`run_as`](Self::run_as) identity. The same lock covers
1353    /// unmounting, so the bind cannot be detached to reveal the target
1354    /// underneath it either.
1355    pub fn bind_ro(mut self, source: impl AsRef<Path>, target: impl AsRef<Path>) -> Self {
1356        self.mounts.push(Mount::Bind(Bind {
1357            source: source.as_ref().to_path_buf(),
1358            target: target.as_ref().to_path_buf(),
1359            read_only: true,
1360        }));
1361        self
1362    }
1363
1364    /// Sets an environment variable for the command.
1365    ///
1366    /// The command's environment is built from scratch: the deterministic
1367    /// base (`PATH` and `HOME`) plus the variables set here, which override
1368    /// the base on collision. Nothing is inherited from the host.
1369    pub fn env(mut self, name: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> Self {
1370        self.env
1371            .push((name.as_ref().to_os_string(), value.as_ref().to_os_string()));
1372        self
1373    }
1374
1375    /// Sets environment variables for the command.
1376    ///
1377    /// Equivalent to calling [`env`](Self::env) for each pair.
1378    pub fn envs<I, K, V>(mut self, vars: I) -> Self
1379    where
1380        I: IntoIterator<Item = (K, V)>,
1381        K: AsRef<OsStr>,
1382        V: AsRef<OsStr>,
1383    {
1384        self.env.extend(
1385            vars.into_iter()
1386                .map(|(k, v)| (k.as_ref().to_os_string(), v.as_ref().to_os_string())),
1387        );
1388        self
1389    }
1390
1391    /// Selects the sandbox's network posture.
1392    ///
1393    /// The default is [`Network::Isolated`]: a new network namespace whose
1394    /// only interface is loopback.
1395    pub fn network(mut self, network: Network) -> Self {
1396        self.network = network;
1397        self
1398    }
1399
1400    /// Sets the hostname inside the sandbox.
1401    ///
1402    /// The sandbox always runs in its own UTS namespace; without this call
1403    /// it keeps the host's hostname. The name must be non-empty and at most
1404    /// 64 bytes.
1405    pub fn hostname(mut self, name: impl AsRef<OsStr>) -> Self {
1406        self.hostname = Some(name.as_ref().to_os_string());
1407        self
1408    }
1409
1410    /// Sets the command's working directory inside the sandbox.
1411    ///
1412    /// The path must be absolute and must exist inside the sandbox at launch;
1413    /// a missing directory is a setup error, not a fallback. The default is
1414    /// `/`.
1415    pub fn current_dir(mut self, path: impl AsRef<Path>) -> Self {
1416        self.workdir = Some(path.as_ref().to_path_buf());
1417        self
1418    }
1419
1420    /// Selects the disposition of the command's standard input.
1421    ///
1422    /// The default is [`Stdio::Inherit`], which also keeps the command in the
1423    /// caller's session; see [`Stdio`] for the whole rule.
1424    pub fn stdin(mut self, stdin: Stdio) -> Self {
1425        self.stdin = stdin;
1426        self
1427    }
1428
1429    /// Selects the disposition of the command's standard output.
1430    ///
1431    /// The default is [`Stdio::Inherit`], under which the command writes
1432    /// wherever the caller's own standard output goes — which at a shell
1433    /// prompt is the caller's terminal, readable and writable both.
1434    /// [`Stdio::Null`] discards the output, and [`Stdio::from_fd`] sends it to
1435    /// a descriptor the caller supplies.
1436    ///
1437    /// A launch that captures — [`Cage::run_with`], [`Cage::output`],
1438    /// [`Cage::spawn_with`] — supplies its own pipe for this stream, so it
1439    /// composes with [`Stdio::Inherit`] alone and is refused against a
1440    /// disposition that names a different destination.
1441    pub fn stdout(mut self, stdout: Stdio) -> Self {
1442        self.stdout = stdout;
1443        self
1444    }
1445
1446    /// Selects the disposition of the command's standard error.
1447    ///
1448    /// The counterpart of [`stdout`](Self::stdout), with the same default and
1449    /// the same interaction with a capturing launch.
1450    pub fn stderr(mut self, stderr: Stdio) -> Self {
1451        self.stderr = stderr;
1452        self
1453    }
1454
1455    /// Controls whether the sandbox gets its own PID namespace (default
1456    /// `true`).
1457    ///
1458    /// With a PID namespace, the command runs as PID 2 under a minimal init
1459    /// provided by the library, which collects orphaned descendants; when
1460    /// the command exits, the namespace is torn down and any processes it
1461    /// left running are terminated. Without one, the sandbox shares the
1462    /// host's PID namespace, `/proc` becomes a bind of the host's procfs,
1463    /// the command's descendants can outlive it, and
1464    /// [`kill`](crate::Running::kill) reaches only the command process
1465    /// itself.
1466    ///
1467    /// When output is captured, those surviving descendants also govern
1468    /// capture's endpoint: a descendant holding the command's streams keeps
1469    /// an unbounded wait blocked after the command exits; see
1470    /// [`Running::wait`](crate::Running::wait).
1471    pub fn pid_namespace(mut self, isolate: bool) -> Self {
1472        self.pid_namespace = isolate;
1473        self
1474    }
1475
1476    /// Ties the sandbox's lifetime to the caller's (default `false`).
1477    ///
1478    /// When enabled, the sandbox is stopped as soon as the calling process
1479    /// exits — however it exits — or the last handle to the launch (the
1480    /// [`Running`] and any [`KillHandle`]s) is dropped. The tie is a pipe
1481    /// held open by the handle, not a signal arrangement in the caller, so
1482    /// it works from any thread and involves no signal handlers.
1483    ///
1484    /// [`KillHandle`]: crate::KillHandle
1485    pub fn stop_with_caller(mut self, tie: bool) -> Self {
1486        self.stop_with_caller = tie;
1487        self
1488    }
1489
1490    /// Selects how the sandbox's user namespace maps identities to the
1491    /// host's.
1492    ///
1493    /// The default is [`IdentityMap::Single`]: root inside is the calling
1494    /// user outside, and no other id exists. A range map gives the sandbox
1495    /// more ids — so `chown` to a non-root id succeeds, packaging tools can
1496    /// drop privileges, and [`run_as`](Self::run_as) has identities to run
1497    /// as — but must be established from outside the new namespace:
1498    /// [`build`](Self::build) resolves the request through the configured
1499    /// [`id_mapper`](Self::id_mapper), or through the bundled delegates
1500    /// ([`DirectMapper`](crate::DirectMapper) first, then the shadow
1501    /// suite's helpers with the `subid` feature). A request no delegate can
1502    /// satisfy is rejected by `build` — never silently downgraded to the
1503    /// single-identity map.
1504    pub fn identity_map(mut self, map: IdentityMap) -> Self {
1505        self.identity_map = map;
1506        self
1507    }
1508
1509    /// Supplies the delegate that establishes a range identity map,
1510    /// replacing the bundled fallback chain.
1511    ///
1512    /// The delegate's [`resolve`](IdMapper::resolve) runs at
1513    /// [`build`](Self::build) and its [`apply`](IdMapper::apply) runs
1514    /// during each launch, against the launch stage held at an internal
1515    /// gate. [`IdentityMap::Single`] needs no delegate and never consults
1516    /// one.
1517    pub fn id_mapper(mut self, mapper: impl IdMapper + 'static) -> Self {
1518        self.id_mapper = Some(Arc::new(mapper));
1519        self
1520    }
1521
1522    /// Runs the command as a non-root identity inside the sandbox.
1523    ///
1524    /// The switch sits within the hardening layer, after securebits,
1525    /// no-new-privileges, and Landlock and before the capability drop and the
1526    /// seccomp filter, so the command holds the identity from its first
1527    /// instruction and its descendants inherit it. Every id must be contained in the
1528    /// [`identity_map`](Self::identity_map); supplementary groups
1529    /// additionally require a range gid map, because the single-identity
1530    /// map denies `setgroups`. Without a keep-capabilities request the
1531    /// kernel clears the command's capabilities across the switch, which is
1532    /// the boundary a non-root identity exists to draw; a kept capability
1533    /// set survives it, and retaining `CAP_SETUID`, `CAP_SETGID`, or
1534    /// `CAP_SETPCAP` alongside a non-root identity is rejected at build
1535    /// time.
1536    ///
1537    /// The launch sets no-new-privileges whenever a run-as identity is
1538    /// configured, in every build of the library and whatever else the sandbox
1539    /// requests, because the cleared capability set is only half of the
1540    /// boundary. A rootfs ships set-user-ID binaries and file capabilities
1541    /// owned by in-namespace uid 0 — the provisioners apply a tree's
1542    /// permission bits verbatim, so `/usr/bin/sudo` is a working
1543    /// `/usr/bin/sudo` — and without the flag the non-root command executes
1544    /// them and is root inside again. The flag closes that path for the
1545    /// command and everything it starts.
1546    pub fn run_as(mut self, identity: Identity) -> Self {
1547        self.run_as = Some(identity);
1548        self
1549    }
1550
1551    /// Adds a raw mount, applied in declaration order alongside the binds.
1552    ///
1553    /// See [`RawMount`]. The escape hatch for mounts the typed profile does
1554    /// not model; everything but the target goes to the kernel verbatim.
1555    ///
1556    /// Order matters here more than anywhere else on the builder: a raw mount
1557    /// establishing a filesystem over a path hides whatever was mounted there
1558    /// before it. Declaring a tmpfs and then the binds that populate it is the
1559    /// way to build a directory the sandbox owns outright.
1560    pub fn raw_mount(mut self, mount: RawMount) -> Self {
1561        self.mounts.push(Mount::Raw(mount));
1562        self
1563    }
1564
1565    /// Sets a resource limit on the command.
1566    ///
1567    /// The namespaces bound what the command can reach; a limit bounds what it
1568    /// can consume. Without one the command inherits the caller's limits,
1569    /// which on a typical host are generous enough to permit a fork bomb,
1570    /// unbounded memory growth, and filling the filesystem — none of which the
1571    /// isolation or the hardening layer addresses. See [`limits`](crate::limits)
1572    /// for what each [`Resource`] governs and the two whose kernel semantics
1573    /// need care inside a sandbox.
1574    ///
1575    /// The limit is applied to the command process before the hardening layer,
1576    /// so the command and every process it starts inherit it. Repeating a
1577    /// resource replaces the earlier setting; the limits are then applied in a
1578    /// fixed order that does not depend on the order they were configured.
1579    /// `soft` must not exceed `hard`, which [`build`](Self::build) checks.
1580    ///
1581    /// ```no_run
1582    /// use ferroday_cage::{Cage, Limit, Resource};
1583    ///
1584    /// # fn main() -> ferroday_cage::Result<()> {
1585    /// let cage = Cage::builder()
1586    ///     .rootfs("/srv/rootfs/alpine")
1587    ///     .command("/usr/bin/analyze")
1588    ///     // 64 processes, 512 MiB of address space, 64 MiB of file, and ten
1589    ///     // seconds of CPU.
1590    ///     .rlimit(Resource::Processes, 64, 64)
1591    ///     .rlimit(Resource::AddressSpace, 512 << 20, 512 << 20)
1592    ///     .rlimit(Resource::FileSize, 64 << 20, 64 << 20)
1593    ///     .rlimit(Resource::CpuTime, 10, Limit::UNLIMITED)
1594    ///     .build()?;
1595    /// # let _ = cage;
1596    /// # Ok(())
1597    /// # }
1598    /// ```
1599    ///
1600    /// With the `serde` feature the limits are part of the profile format, as
1601    /// an `[rlimit]` table keyed by the resource's kebab-case name. One value
1602    /// sets the soft and hard limits alike; a `{ soft, hard }` table sets them
1603    /// apart. Either position takes an amount or `"unlimited"`.
1604    ///
1605    /// ```toml
1606    /// [rlimit]
1607    /// processes = 64
1608    /// address-space = 536870912
1609    /// cpu-time = { soft = 10, hard = "unlimited" }
1610    /// ```
1611    pub fn rlimit(
1612        mut self,
1613        resource: Resource,
1614        soft: impl Into<Limit>,
1615        hard: impl Into<Limit>,
1616    ) -> Self {
1617        self.rlimits.insert(resource, (soft.into(), hard.into()));
1618        self
1619    }
1620
1621    /// Controls whether `/proc` is mounted (default `true`).
1622    ///
1623    /// With the default PID namespace, `/proc` is a fresh procfs instance
1624    /// showing only the sandbox's processes. With
1625    /// [`pid_namespace(false)`](Self::pid_namespace) the sandbox shares the
1626    /// host PID namespace and `/proc` is the host's procfs, bind-mounted.
1627    /// Either way, `/proc/net` and `/proc/sys/net` resolve through the
1628    /// sandbox's own network namespace.
1629    ///
1630    /// A container usually needs a procfs from somewhere: the nested user
1631    /// namespace the command enters — which is what locks the sandbox's mount
1632    /// flags — establishes its identity map through one, by path. Setting this
1633    /// to `false` therefore obliges the profile to mount a procfs of its own,
1634    /// and [`build`](Self::build) refuses one that does not with
1635    /// [`ConfigError::NestedUsernsNeedsProcfs`](crate::ConfigError::NestedUsernsNeedsProcfs).
1636    /// The exception is a range
1637    /// [`identity_map`](Self::identity_map) with
1638    /// [`pid_namespace(false)`](Self::pid_namespace), whose nested map is
1639    /// written from outside the sandbox through the host's own `/proc`: that
1640    /// combination reads no procfs of the profile's and needs none.
1641    pub fn mount_proc(mut self, mount: bool) -> Self {
1642        self.mount_proc = Some(mount);
1643        self
1644    }
1645
1646    /// Controls whether a minimal `/dev` is assembled (default `true`).
1647    ///
1648    /// The minimal `/dev` is a tmpfs holding the host's `null`, `zero`,
1649    /// `full`, `random`, `urandom`, and `tty` devices, the standard-stream
1650    /// and `fd` symlinks, a fresh `devpts` instance on `/dev/pts`, and a
1651    /// tmpfs on `/dev/shm`.
1652    ///
1653    /// `/dev/tty` is not an ordinary device node. It is the character device
1654    /// `5:0`, whose open returns *the opening process's controlling terminal*,
1655    /// whatever that happens to be — so what the sandbox reaches through it is
1656    /// decided by the sandbox's session, not by the bind. With the default
1657    /// [`Stdio::Inherit`] that is the caller's terminal, which the command was
1658    /// handed anyway as file descriptor 0; with any other standard-input
1659    /// disposition the sandbox has a session of its own and `/dev/tty` fails
1660    /// with `ENXIO`. See [`Stdio`] for the whole rule. Dropping this mount does
1661    /// not change any of that: it removes `/dev/tty` as a *name*, while an
1662    /// inherited terminal descriptor is still an inherited terminal.
1663    pub fn mount_dev(mut self, mount: bool) -> Self {
1664        self.mount_dev = Some(mount);
1665        self
1666    }
1667
1668    /// Controls whether a tmpfs is mounted on `/tmp` (default `true`).
1669    ///
1670    /// The tmpfs is `mode=1777`, and a `/tmp` the sandbox has to create to
1671    /// mount it on is created the same way, so the mount point a rootfs is left
1672    /// with is the directory it stands for.
1673    pub fn mount_tmp(mut self, mount: bool) -> Self {
1674        self.mount_tmp = Some(mount);
1675        self
1676    }
1677
1678    /// Controls whether the host's `resolv.conf` is bound into the sandbox
1679    /// (default `true`).
1680    ///
1681    /// A [`bind`](Self::bind) of the caller's own onto `/etc/resolv.conf`
1682    /// replaces the managed bind rather than sitting under it, so a caller who
1683    /// composes their own resolver configuration need not also decline this
1684    /// one.
1685    ///
1686    /// The bind only happens with [`Network::Host`], where host DNS
1687    /// configuration is meaningful: the host's `/etc/resolv.conf` is
1688    /// resolved to its real file and bound read-only onto
1689    /// `/etc/resolv.conf` in the rootfs. When the host has no usable
1690    /// `resolv.conf` the bind is omitted. An isolated network never binds
1691    /// it.
1692    ///
1693    /// A rootfs that ships no `/etc/resolv.conf` gets an empty one created to
1694    /// mount onto, and it is removed once the sandbox is gone: the tree is left
1695    /// as it was, rather than carrying a resolver configuration it never had
1696    /// into whatever is made of it. A rootfs that does ship the file keeps it,
1697    /// untouched — the bind covers it for the sandbox's life and reveals it
1698    /// again afterwards. An overlay root's created file lands in the upper,
1699    /// which is where the merged view's writes go, and is removed from there.
1700    /// A file bind a caller configures is treated the same way; see
1701    /// [`bind`](Self::bind).
1702    pub fn resolv_conf(mut self, bind: bool) -> Self {
1703        self.resolv_conf = Some(bind);
1704        self
1705    }
1706
1707    /// Controls whether the library contributes any mount of its own
1708    /// (default `true`).
1709    ///
1710    /// Set to `false`, the sandbox carries exactly the mounts the caller
1711    /// declared through [`bind`](Self::bind), [`bind_ro`](Self::bind_ro), and
1712    /// [`raw_mount`](Self::raw_mount), and nothing else — no `/proc`, no
1713    /// `/dev`, no `/tmp`, no `resolv.conf`. **A managed mount introduced in a
1714    /// later release is suppressed too**, which is the point: the individual
1715    /// toggles can only turn off the mounts that exist today, so they cannot
1716    /// protect a consumer from one added tomorrow.
1717    ///
1718    /// That makes this the mount-side counterpart of
1719    /// [`base_env`](Self::base_env), and the setting for a consumer whose
1720    /// output depends on the exact filesystem the command sees.
1721    ///
1722    /// Combining it with an explicit `mount_*(true)` is a contradiction and
1723    /// is refused by [`build`](Self::build) rather than resolved silently.
1724    /// Leaving those toggles alone is not a contradiction, so
1725    /// `managed_mounts(false)` on its own is all it takes.
1726    ///
1727    /// One mount the profile must then declare for itself is a procfs; see
1728    /// [`mount_proc`](Self::mount_proc) for why, and for the error a profile
1729    /// that declares none is refused with.
1730    pub fn managed_mounts(mut self, managed: bool) -> Self {
1731        self.managed_mounts = Some(managed);
1732        self
1733    }
1734
1735    /// Controls whether the library's deterministic base environment is
1736    /// composed under the caller's variables (default `true`).
1737    ///
1738    /// Set to `false`, the command's environment is exactly the pairs given
1739    /// to [`env`](Self::env) and [`envs`](Self::envs) — `PATH` and `HOME` are
1740    /// not supplied, and **no variable the library may add to its base in a
1741    /// later release is supplied either**. That is what this exists for: a
1742    /// caller can override base variables it knows the names of, but only an
1743    /// exact environment excludes one it cannot yet name.
1744    ///
1745    /// A command resolved through [`path_lookup`](Self::path_lookup) needs a
1746    /// `PATH` to search. With no base and no `PATH` of the caller's own there
1747    /// is nothing to search, and [`build`](Self::build) says so rather than
1748    /// searching a `PATH` the command will not have.
1749    pub fn base_env(mut self, base: bool) -> Self {
1750        self.base_env = Some(base);
1751        self
1752    }
1753
1754    /// The configured rootfs, when a plain one is set.
1755    ///
1756    /// `None` for an overlay-rooted cage, whose base is the first lower of
1757    /// [`get_overlay`](Self::get_overlay), and for a builder with no root at
1758    /// all — which [`build`](Self::build) rejects.
1759    pub fn get_rootfs(&self) -> Option<&Path> {
1760        self.rootfs.as_deref()
1761    }
1762
1763    /// The configured overlay root, when one is set.
1764    pub fn get_overlay(&self) -> Option<&Overlay> {
1765        self.overlay.as_ref()
1766    }
1767
1768    /// The configured command path.
1769    pub fn get_command(&self) -> Option<&Path> {
1770        self.command.as_deref()
1771    }
1772
1773    /// The command's arguments, excluding the conventional `argv[0]`.
1774    pub fn get_args(&self) -> &[OsString] {
1775        &self.args
1776    }
1777
1778    /// Whether a bare command name is resolved against the sandbox's `PATH`.
1779    pub fn get_path_lookup(&self) -> bool {
1780        self.path_lookup
1781    }
1782
1783    /// The caller-set environment variables, in the order they were set.
1784    ///
1785    /// These are the caller's additions; the command's full environment is
1786    /// these composed over the library's deterministic base.
1787    pub fn get_envs(&self) -> impl Iterator<Item = (&OsStr, &OsStr)> {
1788        self.env
1789            .iter()
1790            .map(|(name, value)| (name.as_os_str(), value.as_os_str()))
1791    }
1792
1793    /// Every mount the caller added, in the order they were added and the
1794    /// order they are applied.
1795    ///
1796    /// This is the whole picture. [`get_binds`](Self::get_binds) and
1797    /// [`get_raw_mounts`](Self::get_raw_mounts) narrow it to one kind, at the
1798    /// cost of the relative order between the two.
1799    pub fn get_mounts(&self) -> &[Mount] {
1800        &self.mounts
1801    }
1802
1803    /// The bind mounts the sandbox carries, in the order they were added.
1804    pub fn get_binds(&self) -> impl Iterator<Item = &Bind> {
1805        self.mounts.iter().filter_map(|mount| match mount {
1806            Mount::Bind(bind) => Some(bind),
1807            Mount::Raw(_) => None,
1808        })
1809    }
1810
1811    /// The raw mounts the sandbox carries, in the order they were added.
1812    pub fn get_raw_mounts(&self) -> impl Iterator<Item = &RawMount> {
1813        self.mounts.iter().filter_map(|mount| match mount {
1814            Mount::Raw(raw) => Some(raw),
1815            Mount::Bind(_) => None,
1816        })
1817    }
1818
1819    /// The sandbox's network posture.
1820    pub fn get_network(&self) -> Network {
1821        self.network
1822    }
1823
1824    /// Whether the host's `resolv.conf` bind is enabled, with the
1825    /// managed-mount master switch folded in.
1826    ///
1827    /// The library acts on this only under [`Network::Host`]. A caller that
1828    /// composes resolver configuration of its own — as `fcage` does for a
1829    /// native-stack run — can read it to honor the same opt-out.
1830    pub fn get_resolv_conf(&self) -> bool {
1831        self.managed(self.resolv_conf)
1832    }
1833
1834    /// Whether the sandbox gets its own PID namespace.
1835    pub fn get_pid_namespace(&self) -> bool {
1836        self.pid_namespace
1837    }
1838
1839    /// Whether `/proc` is mounted.
1840    pub fn get_mount_proc(&self) -> bool {
1841        self.managed(self.mount_proc)
1842    }
1843
1844    /// Whether the minimal `/dev` is assembled.
1845    pub fn get_mount_dev(&self) -> bool {
1846        self.managed(self.mount_dev)
1847    }
1848
1849    /// Whether a tmpfs is mounted on `/tmp`.
1850    pub fn get_mount_tmp(&self) -> bool {
1851        self.managed(self.mount_tmp)
1852    }
1853
1854    /// Whether the library contributes any mount of its own.
1855    pub fn get_managed_mounts(&self) -> bool {
1856        self.managed_mounts.unwrap_or(true)
1857    }
1858
1859    /// Whether the library's deterministic base environment is composed under
1860    /// the caller's variables.
1861    pub fn get_base_env(&self) -> bool {
1862        self.base_env.unwrap_or(true)
1863    }
1864
1865    /// Resolves one managed-mount toggle: the caller's explicit choice or the
1866    /// default, and never established when the managed profile as a whole is
1867    /// opted out of.
1868    ///
1869    /// The master switch wins here rather than being a contradiction, because
1870    /// [`build`](Self::build) has already refused the only combination where
1871    /// the two genuinely disagree — an explicit `true` under
1872    /// `managed_mounts(false)`.
1873    fn managed(&self, toggle: Option<bool>) -> bool {
1874        self.managed_mounts.unwrap_or(true) && toggle.unwrap_or(true)
1875    }
1876
1877    /// The sandbox's hostname, when one is set.
1878    pub fn get_hostname(&self) -> Option<&OsStr> {
1879        self.hostname.as_deref()
1880    }
1881
1882    /// The command's working directory inside the sandbox, when one is set.
1883    pub fn get_current_dir(&self) -> Option<&Path> {
1884        self.workdir.as_deref()
1885    }
1886
1887    /// The command's standard-input disposition.
1888    pub fn get_stdin(&self) -> &Stdio {
1889        &self.stdin
1890    }
1891
1892    /// The command's standard-output disposition.
1893    pub fn get_stdout(&self) -> &Stdio {
1894        &self.stdout
1895    }
1896
1897    /// The command's standard-error disposition.
1898    pub fn get_stderr(&self) -> &Stdio {
1899        &self.stderr
1900    }
1901
1902    /// Whether the sandbox's lifetime is tied to the caller's.
1903    pub fn get_stop_with_caller(&self) -> bool {
1904        self.stop_with_caller
1905    }
1906
1907    /// The requested identity map.
1908    pub fn get_identity_map(&self) -> &IdentityMap {
1909        &self.identity_map
1910    }
1911
1912    /// The identity the command runs as inside the sandbox, when one is set.
1913    pub fn get_run_as(&self) -> Option<&Identity> {
1914        self.run_as.as_ref()
1915    }
1916
1917    /// The configured resource limits, ordered by resource.
1918    pub fn get_rlimits(&self) -> impl Iterator<Item = (Resource, Limit, Limit)> {
1919        self.rlimits
1920            .iter()
1921            .map(|(resource, (soft, hard))| (*resource, *soft, *hard))
1922    }
1923
1924    /// Validates the configuration and freezes it into a [`Cage`].
1925    ///
1926    /// All fallible and allocating preparation happens here, in the calling
1927    /// process: the rootfs and bind sources are resolved, the mount profile
1928    /// is lowered into a frozen op list, and the command line and
1929    /// environment are marshaled into their final form. After `build`
1930    /// succeeds, launching performs no work that can fail for configuration
1931    /// reasons.
1932    pub fn build(self) -> Result<Cage, Error> {
1933        // Opting out of the managed mounts while explicitly asking for one of
1934        // them is a contradiction. Refuse it rather than let either side win
1935        // quietly: a consumer that wrote both wants something the sandbox
1936        // cannot deliver, and silently dropping one of the two would hand back
1937        // a sandbox that differs from the one described.
1938        if self.managed_mounts == Some(false) {
1939            for (toggle, name) in [
1940                (self.mount_proc, "mount_proc"),
1941                (self.mount_dev, "mount_dev"),
1942                (self.mount_tmp, "mount_tmp"),
1943                (self.resolv_conf, "resolv_conf"),
1944            ] {
1945                if toggle == Some(true) {
1946                    return Err(ConfigError::ManagedMountsContradiction { toggle: name }.into());
1947                }
1948            }
1949        }
1950
1951        // The managed profile resolved to plain booleans up front, before any
1952        // field is moved out of `self` below. Each is the caller's explicit
1953        // choice or the documented default, and none survives opting out of
1954        // the profile as a whole.
1955        let profile_managed = self.managed_mounts.unwrap_or(true);
1956        let mount_proc = profile_managed && self.mount_proc.unwrap_or(true);
1957        let mount_dev = profile_managed && self.mount_dev.unwrap_or(true);
1958        let mount_tmp = profile_managed && self.mount_tmp.unwrap_or(true);
1959        let resolv_conf = profile_managed && self.resolv_conf.unwrap_or(true);
1960        let base_env = self.base_env.unwrap_or(true);
1961
1962        // Exactly one root is in force: a plain rootfs, or an overlay whose
1963        // first lower is the base the overlay mounts over. The builder's
1964        // setters keep the two exclusive, but a deserialized profile can carry
1965        // both keys; taking either one would silently discard the other's
1966        // configuration, so a profile that names both is refused.
1967        let rootfs = match (&self.rootfs, &self.overlay) {
1968            (Some(_), Some(_)) => return Err(ConfigError::RootContradiction.into()),
1969            (Some(rootfs), None) => rootfs.clone(),
1970            (None, Some(overlay)) => overlay
1971                .lower
1972                .first()
1973                .ok_or(ConfigError::OverlayLowerMissing)?
1974                .clone(),
1975            (None, None) => return Err(ConfigError::RootfsMissing.into()),
1976        };
1977        let command = self.command.ok_or(ConfigError::CommandMissing)?;
1978        // An empty path is the absence of a command written down, and every
1979        // check below reads it as something else: it carries no slash, so a
1980        // path lookup takes it for a bare name to search, and joining it onto a
1981        // `PATH` entry yields that entry — leaving a candidate list of
1982        // directories that `SearchPathUnusable` has no reason to refuse. What
1983        // survives is an `execve` of a directory at launch, reported as a
1984        // failure of the sandbox rather than of the configuration.
1985        if command.as_os_str().is_empty() {
1986            return Err(ConfigError::CommandMissing.into());
1987        }
1988
1989        // A command with no slash is resolved against PATH only when path
1990        // lookup is opted into; otherwise, and for any command that contains a
1991        // slash, the path must be absolute. This is the execvp rule plus the
1992        // cage's absolute-path requirement: a slash-bearing relative path is
1993        // never searched.
1994        let searchable = self.path_lookup && !command_has_slash(&command);
1995        if !command.is_absolute() && !searchable {
1996            return Err(ConfigError::CommandNotAbsolute { command }.into());
1997        }
1998        let program = cstring(command.as_os_str())?;
1999        let program_search = if searchable {
2000            // A `PATH` with no absolute entry leaves nothing to search. Refuse
2001            // it here rather than fall through to an `execve` of the bare name,
2002            // which the kernel would resolve against the sandbox's working
2003            // directory — the relative resolution path lookup exists to avoid.
2004            let path = effective_path(&self.env, base_env);
2005            let candidates = search_candidates(&path, &command)?;
2006            if candidates.is_empty() {
2007                return Err(ConfigError::SearchPathUnusable { path }.into());
2008            }
2009            candidates
2010        } else {
2011            Vec::new()
2012        };
2013        // The label a failed exec step reports. `ENOENT` from `execve` means
2014        // either "no such binary" or "no such ELF interpreter", so naming the
2015        // command — and, for a path lookup, every candidate that was tried —
2016        // is what makes the report actionable.
2017        let exec_label = exec_label(&command, &program_search);
2018        let (rlimits, rlimit_labels) = lower_rlimits(&self.rlimits)?;
2019        let args = self
2020            .args
2021            .iter()
2022            .map(|arg| cstring(arg))
2023            .collect::<Result<Vec<_>, _>>()?;
2024
2025        // With the base opted out of, the caller's pairs are the whole
2026        // environment; the library contributes nothing, in this release or a
2027        // later one.
2028        let base: &[(&str, &str)] = if base_env { &BASE_ENV } else { &[] };
2029        let env = compose_env(base, &self.env)?;
2030        let hostname = self
2031            .hostname
2032            .map(|name| validate_hostname(&name))
2033            .transpose()?;
2034        let workdir = self
2035            .workdir
2036            .map(|path| validate_workdir(&path))
2037            .transpose()?
2038            .flatten();
2039
2040        // Resolve the rootfs to a canonical directory and prove it can be
2041        // opened as one, so a missing or unusable rootfs is a typed
2042        // configuration error here, before any fork. The launch itself
2043        // resolves the canonical path again inside the new mount namespace,
2044        // where descriptors opened here could not anchor the mounts.
2045        let canonical = std::fs::canonicalize(&rootfs).map_err(|err| {
2046            Error::from(ConfigError::RootfsUnusable {
2047                path: rootfs.clone(),
2048                source: err,
2049            })
2050        })?;
2051        if canonical.parent().is_none() {
2052            return Err(ConfigError::RootfsIsHostRoot.into());
2053        }
2054        rustix::fs::open(
2055            &canonical,
2056            OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
2057            Mode::empty(),
2058        )
2059        .map_err(|errno| ConfigError::RootfsUnusable {
2060            path: rootfs.clone(),
2061            source: errno.into(),
2062        })?;
2063        let rootfs_path = cstring(canonical.as_os_str())?;
2064
2065        // An overlay root: `canonical` is the base lower the overlay mounts
2066        // over, any further lowers are resolved beside it, and the caller's
2067        // upper — with its work directory — is prepared and frozen into the
2068        // mount options here, so the sandbox stage only replays them. A host
2069        // that cannot establish an unprivileged overlay is refused now, before
2070        // any fork.
2071        let overlay = self
2072            .overlay
2073            .as_ref()
2074            .map(|overlay| build_overlay_plan(&canonical, overlay))
2075            .transpose()?;
2076
2077        let mut unshare = UnshareFlags::NEWUSER
2078            | UnshareFlags::NEWNS
2079            | UnshareFlags::NEWUTS
2080            | UnshareFlags::NEWIPC
2081            | UnshareFlags::NEWCGROUP;
2082        if self.pid_namespace {
2083            unshare |= UnshareFlags::NEWPID;
2084        }
2085        let configure_loopback = match self.network {
2086            Network::Isolated => {
2087                unshare |= UnshareFlags::NEWNET;
2088                true
2089            }
2090            Network::None => {
2091                unshare |= UnshareFlags::NEWNET;
2092                false
2093            }
2094            Network::Host => false,
2095        };
2096
2097        // The managed profile, each mount resolved through `managed` so that
2098        // opting out of the profile as a whole suppresses every one of them —
2099        // including any added to this list in a later release.
2100        let mut lowering = Lowering::default();
2101        if mount_proc {
2102            if self.pid_namespace {
2103                lowering.push_proc_fresh()?;
2104            } else {
2105                lowering.push_proc_bind()?;
2106            }
2107        }
2108        if mount_dev {
2109            lowering.push_dev()?;
2110        }
2111        if mount_tmp {
2112            lowering.push_tmp()?;
2113        }
2114        // The caller's mounts in declaration order, both kinds in one pass.
2115        // The managed profile above goes first because it establishes the tree
2116        // these land in; among the caller's own, the order is theirs.
2117        for mount in &self.mounts {
2118            match mount {
2119                Mount::Bind(bind) => lowering.push_bind(bind)?,
2120                Mount::Raw(raw) => lowering.push_raw(raw)?,
2121            }
2122        }
2123        // The managed `resolv.conf` bind, unless the caller mounted something of
2124        // their own at that target. Theirs was pushed by the loop above and this
2125        // would land on top of it, which takes back the "among the caller's
2126        // own, the order is theirs" rule for the one mount they were most
2127        // deliberate about. A caller who mounts it has replaced the managed one
2128        // as surely as `resolv_conf(false)` declines it.
2129        if resolv_conf && matches!(self.network, Network::Host) && !mounts_resolv_conf(&self.mounts)
2130        {
2131            lowering.push_resolv_conf()?;
2132        }
2133
2134        // Every mount target the launch has to create as an empty file, as the
2135        // host path it comes to rest at. A file the sandbox creates only to
2136        // mount over is not part of the root, so the caller side takes it away
2137        // once the sandbox is gone. Writes land in the rootfs itself for a
2138        // plain root and in the upper for an overlay root, whose pivoted root
2139        // is the merged view; an existing file may come from any layer. Both
2140        // are canonical paths, so a root the caller named relatively does not
2141        // leave a path that re-resolves against whatever directory the process
2142        // happens to be in when the handle drops.
2143        let (writable, layers) = match &overlay {
2144            Some(plan) => {
2145                let mut layers = vec![plan.upper.clone()];
2146                layers.extend(plan.lower.iter().cloned());
2147                (plan.upper.as_path(), layers)
2148            }
2149            None => (canonical.as_path(), vec![canonical.clone()]),
2150        };
2151        let managed_placeholders = created_file_targets(&lowering.ops, writable, &layers);
2152
2153        // The identity plan: validate the requested map, resolve a range
2154        // request through the delegate seam, and validate the run-as
2155        // identity against the ranges that result — so an identity the map
2156        // cannot represent is a configuration error here, not a launch
2157        // failure.
2158        idmap::validate_request(&self.identity_map)?;
2159        let identity = match &self.identity_map {
2160            IdentityMap::Single => IdentityPlan::Single,
2161            request => {
2162                let (mapper, map) = resolve_mapper(self.id_mapper.clone(), request)?;
2163                idmap::validate_resolved(&map)?;
2164                IdentityPlan::Ranged { mapper, map }
2165            }
2166        };
2167        let run_as = self
2168            .run_as
2169            .as_ref()
2170            .map(|identity_request| validate_run_as(identity_request, &identity))
2171            .transpose()?;
2172
2173        // The nested user namespace the command enters before it hardens, which
2174        // is what locks the flags of every mount the sandbox established. Its
2175        // map reflects the one resolved above, and the procfs it is written
2176        // through is the profile's, so both are settled here and the command
2177        // stage replays frozen bytes.
2178        let nested = build_nested_plan(&identity, &lowering.ops, self.pid_namespace)?;
2179
2180        // The hardening request, compiled and validated here so the command
2181        // stage only replays frozen data.
2182        #[cfg(feature = "hardening")]
2183        let hardening = {
2184            let mut hardening = self.hardening.lower()?;
2185            // A kept set-id capability would let a non-root command return
2186            // to the mapped uid 0 under the securebits below; refuse the
2187            // combination rather than ship an identity that is not a
2188            // boundary.
2189            if let (Some(run_as_request), Some(keep)) = (&self.run_as, hardening.keep_caps)
2190                && run_as_request.uid != 0
2191            {
2192                use rustix::thread::CapabilitySet;
2193                for (capability, name) in [
2194                    (CapabilitySet::SETUID, "CAP_SETUID"),
2195                    (CapabilitySet::SETGID, "CAP_SETGID"),
2196                    (CapabilitySet::SETPCAP, "CAP_SETPCAP"),
2197                ] {
2198                    if keep & capability.bits() != 0 {
2199                        return Err(
2200                            ConfigError::SetidCapWithNonRootIdentity { capability: name }.into(),
2201                        );
2202                    }
2203                }
2204            }
2205            // The securebits that carry a kept capability set across the
2206            // identity switch. An explicitly empty keep set needs them too:
2207            // its bounding-set narrowing runs after the switch, which would
2208            // otherwise have cleared the CAP_SETPCAP it requires.
2209            hardening.set_securebits = matches!(&self.run_as, Some(identity) if identity.uid != 0)
2210                && hardening.keep_caps.is_some();
2211            // Under host networking there is no network namespace, so a
2212            // Landlock network grant is the sandbox's only network boundary.
2213            // Refuse a build whose grant this kernel cannot enforce rather than
2214            // reach the host network unrestricted, the same posture the
2215            // restriction fallback takes for its Landlock-only confinement.
2216            if matches!(self.network, Network::Host) {
2217                hardening.ensure_host_net_enforceable()?;
2218            }
2219            hardening
2220        };
2221
2222        Ok(Cage {
2223            plan: LaunchPlan {
2224                confinement: Confinement::Container,
2225                identity,
2226                nested: Some(nested),
2227                run_as,
2228                program,
2229                program_search,
2230                args,
2231                env,
2232                rootfs_path,
2233                overlay,
2234                unshare,
2235                pid_namespace: self.pid_namespace,
2236                // Lower each disposition into the plan's own form; the descriptor
2237                // variant carries the shared descriptor through unchanged.
2238                stdin: StdioPlan::of(&self.stdin),
2239                stdout: StdioPlan::of(&self.stdout),
2240                stderr: StdioPlan::of(&self.stderr),
2241                // The session follows standard input alone: it is the stream
2242                // whose inheritance carries the caller's session with it.
2243                own_session: !matches!(self.stdin, Stdio::Inherit),
2244                stop_with_caller: self.stop_with_caller,
2245                hostname,
2246                configure_loopback,
2247                network: self.network,
2248                workdir,
2249                ops: lowering.ops,
2250                op_labels: lowering.labels,
2251                managed_placeholders,
2252                exec_label,
2253                rlimits,
2254                rlimit_labels,
2255                #[cfg(feature = "hardening")]
2256                hardening,
2257            },
2258        })
2259    }
2260}
2261
2262/// Whether the identity map is the default, for profile serialization.
2263#[cfg(feature = "serde")]
2264fn identity_map_is_default(map: &IdentityMap) -> bool {
2265    matches!(map, IdentityMap::Single)
2266}
2267
2268/// A [`CageBuilder`] deserialized under the restricted policy, for a profile
2269/// from a source the consumer does not control.
2270///
2271/// Deserializing a profile straight into a [`CageBuilder`] trusts it as
2272/// code-equivalent configuration (see the builder's "Trust" section): the
2273/// profile can bind any host path, issue raw mounts, share the host network
2274/// namespace, and overmount the sandbox's managed mounts. When the profile
2275/// comes from an untrusted source — a repository, a download, a multi-tenant
2276/// store — deserialize it into a `RestrictedProfile` instead, in the same
2277/// serde format:
2278///
2279/// ```toml
2280/// rootfs = "/srv/rootfs/alpine"
2281/// command = "/usr/bin/make"
2282/// workdir = "/build"
2283///
2284/// [env]
2285/// CARGO_HOME = "/cache/cargo"
2286/// ```
2287///
2288/// The restricted policy forbids the operations that map host resources into
2289/// the sandbox, write to the host outside it, or share a host namespace: a bind
2290/// mount, a raw mount, host networking, an overlay root (`[overlay]`, whose upper
2291/// creates host directories at a path the profile picks), and sharing the host
2292/// PID namespace (`pid-namespace = false`, which would bind the host's `/proc`
2293/// into the sandbox). A profile using any of them is rejected with
2294/// [`ConfigError::ProfileOperationForbidden`]. Everything else — the rootfs
2295/// (never the host root, which [`CageBuilder::build`] rejects for every
2296/// profile), command, arguments, environment, working directory, hostname,
2297/// hardening posture, resource limits, the identity map and run-as identity,
2298/// and the remaining mount toggles — is accepted, so a restricted profile
2299/// still shapes the sandbox.
2300///
2301/// # What the restricted policy does not constrain
2302///
2303/// The policy bounds what the profile may *map in*, not the host authority the
2304/// resulting command wields, which remains the calling user's:
2305///
2306/// - **Rootfs reach.** The profile's rootfs is bind-mounted onto itself
2307///   read-write and pivoted to `/`, so the command gets read-write access
2308///   (bounded by the calling user's credentials) to whatever host subtree the
2309///   profile named. A profile naming `rootfs = "/home/service"` therefore
2310///   reaches that subtree. A consumer that does not trust the profile to
2311///   choose its own rootfs should override it — `into_builder().rootfs(path)`
2312///   with a directory the consumer controls — after loading.
2313/// - **Identity.** A range [`identity_map`](CageBuilder::identity_map) and a
2314///   [`run_as`](CageBuilder::run_as) identity survive the filter. Under the
2315///   bundled delegates this is safe: each validates the requested ids against
2316///   the caller's own authority (`CAP_SETUID`/`CAP_SETGID` and the readable
2317///   map, or the caller's `/etc/subuid` allocation). A consumer that supplies
2318///   a custom privileged [`id_mapper`](CageBuilder::id_mapper) must validate
2319///   the profile-supplied outside ids against the caller's authority itself;
2320///   otherwise a profile could map host root into the sandbox.
2321///
2322/// [`into_builder`](Self::into_builder) yields the validated builder, which the
2323/// consumer extends with its own trusted calls — including any binds it means
2324/// to allow — before [`build`](CageBuilder::build).
2325///
2326/// [`ConfigError::ProfileOperationForbidden`]: crate::ConfigError::ProfileOperationForbidden
2327#[cfg(feature = "serde")]
2328#[derive(Debug, Clone)]
2329pub struct RestrictedProfile(CageBuilder);
2330
2331#[cfg(feature = "serde")]
2332impl RestrictedProfile {
2333    /// The validated builder, for extending with the consumer's own trusted
2334    /// calls and building into a [`Cage`].
2335    pub fn into_builder(self) -> CageBuilder {
2336        self.0
2337    }
2338}
2339
2340#[cfg(feature = "serde")]
2341impl<'de> serde::Deserialize<'de> for RestrictedProfile {
2342    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
2343        let builder = CageBuilder::deserialize(deserializer)?;
2344        builder
2345            .check_restricted()
2346            .map_err(serde::de::Error::custom)?;
2347        Ok(RestrictedProfile(builder))
2348    }
2349}
2350
2351#[cfg(feature = "serde")]
2352impl CageBuilder {
2353    /// Rejects the profile operations the restricted policy reserves for a
2354    /// trusted profile: bind mounts, raw mounts, host networking, an overlay
2355    /// root, and sharing the host PID namespace. Backs [`RestrictedProfile`]'s
2356    /// deserialization.
2357    fn check_restricted(&self) -> Result<(), ConfigError> {
2358        // Destructured exhaustively, with no `..`: a new builder field fails to
2359        // compile here until someone classifies it, so the untrusted-profile
2360        // surface cannot widen by omission. A knob that reaches the host belongs
2361        // in the forbidden group below; anything else is bound to `_` here as a
2362        // deliberate record that it was considered and allowed.
2363        //
2364        // It carries a second duty. `RestrictionBuilder` is this type's
2365        // deliberate twin, and a field added here without a counterpart there
2366        // is how the two come apart; the test beside that type destructures it
2367        // the same way, and between them neither grows a field the other has
2368        // not been asked about.
2369        let CageBuilder {
2370            // Reaches host resources, and is refused below.
2371            mounts,
2372            network,
2373            overlay,
2374            pid_namespace,
2375            // Bounded by the calling user's own authority, and allowed. See
2376            // this type's documentation for what a profile still governs.
2377            rootfs: _,
2378            command: _,
2379            args: _,
2380            hostname: _,
2381            workdir: _,
2382            // A stream disposition reaches no host resource a profile could not
2383            // already reach: `Inherit` is what a launch does anyway, `Null` only
2384            // narrows, and `Fd` has no serial form and so cannot appear in a
2385            // profile at all.
2386            stdin: _,
2387            stdout: _,
2388            stderr: _,
2389            mount_proc: _,
2390            mount_dev: _,
2391            mount_tmp: _,
2392            resolv_conf: _,
2393            // Both narrow the sandbox rather than widen it. Opting out of the
2394            // managed mounts removes mounts; opting out of the base
2395            // environment removes variables. Neither can reach a host resource
2396            // the profile could not already reach.
2397            managed_mounts: _,
2398            base_env: _,
2399            stop_with_caller: _,
2400            path_lookup: _,
2401            identity_map: _,
2402            run_as: _,
2403            // A limit reaches nothing: it is set on the command process, and an
2404            // unprivileged `setrlimit` can only lower a hard limit or move a
2405            // soft one within the hard limit the caller already had. The worst
2406            // a profile can do with one is constrain its own command.
2407            rlimits: _,
2408            // Code, never part of a profile: `serde(skip)` keeps it unset here.
2409            id_mapper: _,
2410            env: _,
2411            // Tightens the sandbox rather than widening it, and is validated on
2412            // its own; a build without the feature refuses the key outright.
2413            hardening: _,
2414        } = self;
2415
2416        // Reported by kind rather than as one "a mount" refusal, so the message
2417        // names the operation the profile actually asked for.
2418        if mounts.iter().any(|mount| matches!(mount, Mount::Bind(_))) {
2419            return Err(ConfigError::ProfileOperationForbidden {
2420                operation: "a bind mount",
2421            });
2422        }
2423        if mounts.iter().any(|mount| matches!(mount, Mount::Raw(_))) {
2424            return Err(ConfigError::ProfileOperationForbidden {
2425                operation: "a raw mount",
2426            });
2427        }
2428        if matches!(network, Network::Host) {
2429            return Err(ConfigError::ProfileOperationForbidden {
2430                operation: "host networking",
2431            });
2432        }
2433        // An overlay root reaches further onto the host than the rootfs it
2434        // layers over. The rootfs must already exist, so a profile naming one
2435        // reaches only a subtree that is already there; an upper is *created*,
2436        // along with a sibling work directory, at whatever host path the profile
2437        // names, and the preflight probe then runs in its parent. Reserve it for
2438        // a trusted profile, which sets it through `overlay` after loading.
2439        if overlay.is_some() {
2440            return Err(ConfigError::ProfileOperationForbidden {
2441                operation: "an overlay root",
2442            });
2443        }
2444        // A shared host PID namespace is a host-resource mapping in disguise:
2445        // with `pid_namespace = false` the default `mount_proc` binds the
2446        // host's own `/proc` read-write into the sandbox (see
2447        // `push_proc_bind`), so the command can enumerate, read, and signal
2448        // every host process sharing its mapped uid — the same reach an
2449        // explicit `[[mount]] kind = "bind"` of /proc grants, which this
2450        // filter already rejects. Reserve it for a trusted profile.
2451        if !pid_namespace {
2452            return Err(ConfigError::ProfileOperationForbidden {
2453                operation: "the host PID namespace",
2454            });
2455        }
2456        Ok(())
2457    }
2458}
2459
2460/// Resolves a range request through the configured delegate, or the bundled
2461/// fallback chain: [`DirectMapper`](crate::DirectMapper) first, then the
2462/// subordinate delegate when the `subid` feature is enabled. Each refusal is
2463/// collected, and the host probe's diagnosis appended, so the error names
2464/// what to fix.
2465fn resolve_mapper(
2466    configured: Option<Arc<dyn IdMapper>>,
2467    request: &IdentityMap,
2468) -> Result<(Arc<dyn IdMapper>, idmap::ResolvedMap), Error> {
2469    // A configured delegate is the whole chain: its refusal is final.
2470    if let Some(mapper) = configured {
2471        return match mapper.resolve(request) {
2472            Ok(map) => Ok((mapper, map)),
2473            Err(err) => Err(ConfigError::IdentityMapUnavailable {
2474                reason: err.to_string(),
2475            }
2476            .into()),
2477        };
2478    }
2479
2480    idmap::resolve_default_chain(request)
2481        .map_err(|reason| ConfigError::IdentityMapUnavailable { reason }.into())
2482}
2483
2484/// Validates a run-as identity against the identity plan and freezes it.
2485///
2486/// Every id must be contained in the map that will exist at launch, and
2487/// supplementary groups require a range gid map: the single-identity map is
2488/// established by denying `setgroups`, so no group list can ever be set
2489/// inside it.
2490fn validate_run_as(identity: &Identity, plan: &IdentityPlan) -> Result<RunAsPlan, Error> {
2491    let unmapped =
2492        |id: u32, space: &'static str| Error::from(ConfigError::RunAsUnmapped { id, space });
2493    match plan {
2494        IdentityPlan::Single => {
2495            if identity.uid != 0 {
2496                return Err(unmapped(identity.uid, "uid"));
2497            }
2498            if identity.gid != 0 {
2499                return Err(unmapped(identity.gid, "gid"));
2500            }
2501            if !identity.groups.is_empty() {
2502                return Err(ConfigError::RunAsGroupsWithSingleMap.into());
2503            }
2504        }
2505        IdentityPlan::Ranged { map, .. } => {
2506            if !idmap::maps_inside_id(map.uid(), identity.uid) {
2507                return Err(unmapped(identity.uid, "uid"));
2508            }
2509            if !idmap::maps_inside_id(map.gid(), identity.gid) {
2510                return Err(unmapped(identity.gid, "gid"));
2511            }
2512            for &group in &identity.groups {
2513                if !idmap::maps_inside_id(map.gid(), group) {
2514                    return Err(unmapped(group, "supplementary group"));
2515                }
2516            }
2517        }
2518    }
2519    // `from_raw_unchecked` is the safe constructor without the debug assert
2520    // against the sentinel -1; the kernel rejects unusable ids at the
2521    // switch, and the map validation above has already bounded these.
2522    Ok(RunAsPlan {
2523        uid: rustix::thread::Uid::from_raw_unchecked(identity.uid),
2524        gid: rustix::thread::Gid::from_raw_unchecked(identity.gid),
2525        groups: identity
2526            .groups
2527            .iter()
2528            .map(|&group| rustix::thread::Gid::from_raw_unchecked(group))
2529            .collect(),
2530    })
2531}
2532
2533/// Composes the nested user namespace's plan: the map it takes, and the procfs
2534/// paths through which that map is established.
2535///
2536/// The map is the identity reflection of the sandbox's own, so the command sees
2537/// the same ids one namespace deeper; the tier follows the sandbox's, because
2538/// the kernel's one-id exception is what lets a process write its own map and
2539/// nothing richer.
2540///
2541/// A procfs is required exactly where the chosen route reaches for one, and a
2542/// profile that mounts none is then refused here rather than at launch, where
2543/// the failure would be an `ENOENT` on a path nobody asked for. Two of the
2544/// three routes reach for one: a self-written map, whose command names its own
2545/// map files by path after the pivot, and a delegated map inside a PID
2546/// namespace, whose delegate is inside the sandbox. The third — a delegated map
2547/// without a PID namespace — is written by the launch stage, outside, through
2548/// the host's own `/proc`, and needs nothing of the profile.
2549fn build_nested_plan(
2550    identity: &IdentityPlan,
2551    ops: &[MountOp],
2552    pid_namespace: bool,
2553) -> Result<NestedPlan, Error> {
2554    let (map, uid, gid) = match identity {
2555        // The single-identity tier collapses to one extent of one id, which is
2556        // all it ever had, and which the command may write for itself. Any
2557        // procfs serves: a process writing its own map reaches it through
2558        // `self`, which every instance resolves for its reader.
2559        IdentityPlan::Single => {
2560            let procfs_target =
2561                visible_procfs(ops, false).ok_or(ConfigError::NestedUsernsNeedsProcfs)?;
2562            // The map files as the command names them after the pivot:
2563            // absolute, under the profile's own procfs target.
2564            let under_procfs = |leaf: &str| -> Result<CString, ConfigError> {
2565                let target = procfs_target.to_bytes();
2566                let mut path = Vec::with_capacity(target.len() + leaf.len() + 2);
2567                path.push(b'/');
2568                path.extend_from_slice(target);
2569                path.push(b'/');
2570                path.extend_from_slice(leaf.as_bytes());
2571                CString::new(path).map_err(|_| ConfigError::EmbeddedNul)
2572            };
2573            let root = [idmap::IdRange {
2574                inside: 0,
2575                outside: 0,
2576                count: 1,
2577            }];
2578            (
2579                NestedMap::SelfWritten {
2580                    setgroups_path: under_procfs("self/setgroups")?,
2581                    gid_map_path: under_procfs("self/gid_map")?,
2582                    uid_map_path: under_procfs("self/uid_map")?,
2583                },
2584                root.to_vec(),
2585                root.to_vec(),
2586            )
2587        }
2588        IdentityPlan::Ranged { map, .. } => {
2589            // Inside a PID namespace the delegate is the sandbox's own init and
2590            // reads the profile's procfs, which must then exist and be a *fresh*
2591            // instance: the delegate names the command's map files by the pid
2592            // its `fork` returned, an in-namespace pid a bound procfs from
2593            // outside does not index. See `procfs_op_target`.
2594            let procfs_target = if pid_namespace {
2595                Some(
2596                    visible_procfs(ops, true)
2597                        .ok_or(ConfigError::NestedUsernsNeedsProcfs)?
2598                        .to_owned(),
2599                )
2600            } else {
2601                None
2602            };
2603            (
2604                NestedMap::Delegated { procfs_target },
2605                idmap::reflect_inside(map.uid()),
2606                idmap::reflect_inside(map.gid()),
2607            )
2608        }
2609    };
2610
2611    Ok(NestedPlan {
2612        map,
2613        uid_lines: idmap::format_map(&uid),
2614        gid_lines: idmap::format_map(&gid),
2615    })
2616}
2617
2618/// The rootfs-relative target of the procfs the sandbox ends up with, or `None`
2619/// when the profile mounts none the nested map can be established through.
2620///
2621/// The ops execute in order, so a later mount can hide an earlier one; the scan
2622/// follows the same order and drops a procfs another mount comes to rest on top
2623/// of. Where a profile mounts more than one, the last still visible is taken —
2624/// any of them would serve.
2625fn visible_procfs(ops: &[MountOp], needs_fresh: bool) -> Option<&CStr> {
2626    // Walked backwards, because what is wanted is the *last* one still visible:
2627    // a candidate qualifies when nothing after it comes to rest on top of it.
2628    // Carrying only the most recent candidate forward instead would report no
2629    // procfs at all for a profile that mounted two and covered the second,
2630    // though the first is still standing.
2631    for (at, op) in ops.iter().enumerate().rev() {
2632        let Some(target) = procfs_op_target(op, needs_fresh) else {
2633            continue;
2634        };
2635        let covered = ops[at + 1..]
2636            .iter()
2637            .filter_map(|later| later.action.target())
2638            .any(|over| covers(over, target));
2639        if !covered {
2640            return Some(target);
2641        }
2642    }
2643    None
2644}
2645
2646/// The rootfs-relative target an op mounts a usable procfs on, or `None` for
2647/// every other op.
2648///
2649/// A fresh procfs instance is mounted by the stage that performs the setup, so
2650/// it presents the sandbox's own PID namespace whatever that namespace is. A
2651/// bind carries whichever namespace its source presents instead, which matters
2652/// in exactly one case: a delegate names the command's map files by the pid its
2653/// `fork` returned, which is a pid of the sandbox's own PID namespace, and a
2654/// bound procfs from outside that namespace does not index it. Everything else
2655/// is served by either kind, because a process writing its *own* map reaches it
2656/// through `self`, which every procfs instance resolves for its reader.
2657///
2658/// A raw mount is read by what the kernel will do with it rather than by what it
2659/// says: `mount(2)` ignores `fstype` entirely when `MS_BIND` is set, so a raw
2660/// op carrying that flag is a bind however it spells its filesystem, and is held
2661/// to the bind rule rather than to the fresh-instance one. Reading it the other
2662/// way would let `RawMount::new("/proc").source("/proc").fstype("proc")
2663/// .flags(MS_BIND)` satisfy a requirement it does not meet, and the failure
2664/// would arrive at launch instead of as the typed build error this machinery
2665/// exists to give.
2666fn procfs_op_target(op: &MountOp, needs_fresh: bool) -> Option<&CStr> {
2667    match &op.action {
2668        MountAction::Procfs { target } => Some(target),
2669        MountAction::Raw {
2670            source,
2671            target,
2672            fstype,
2673            flags,
2674            ..
2675        } => {
2676            if flags.contains(MountFlags::BIND) {
2677                (!needs_fresh && is_procfs(source)).then_some(target.as_c_str())
2678            } else {
2679                (fstype.as_bytes() == b"proc").then_some(target.as_c_str())
2680            }
2681        }
2682        MountAction::Bind { source, target, .. } if !needs_fresh && is_procfs(source) => {
2683            Some(target)
2684        }
2685        _ => None,
2686    }
2687}
2688
2689/// Whether the bind source is a procfs, by the filesystem's own magic number
2690/// rather than by its path: the managed profile binds `/proc`, and a caller is
2691/// free to bind a procfs mounted anywhere else.
2692///
2693/// A source that cannot be interrogated is not one, which is the safe answer:
2694/// the build then refuses rather than composing paths into a filesystem that
2695/// holds no map files.
2696fn is_procfs(source: &CStr) -> bool {
2697    rustix::fs::statfs(source).is_ok_and(|stat| stat.f_type == rustix::fs::PROC_SUPER_MAGIC)
2698}
2699
2700/// Whether a mount at `mount` hides an entry at `covered`: the same path, or a
2701/// directory the covered path sits beneath.
2702///
2703/// Both are rootfs-relative and carry no leading slash, so the comparison is a
2704/// plain component-aligned prefix test.
2705fn covers(mount: &CStr, covered: &CStr) -> bool {
2706    let (mount, covered) = (mount.to_bytes(), covered.to_bytes());
2707    covered.starts_with(mount) && (covered.len() == mount.len() || covered[mount.len()] == b'/')
2708}
2709
2710/// Builder methods for the opt-in hardening layer: Landlock filesystem and
2711/// network rules, seccomp syscall filters, and capability drops. See
2712/// [`crate::FsAccess`], [`crate::NetAccess`], [`crate::SeccompPolicy`], and
2713/// [`crate::Capability`].
2714#[cfg(feature = "hardening")]
2715impl CageBuilder {
2716    /// Grants `access` beneath `path` under a Landlock ruleset.
2717    ///
2718    /// Configuring any grant enrolls the sandbox in Landlock: the command may
2719    /// then reach a path only where a grant allows it, and everything else on
2720    /// the filesystem is denied. `path` is a sandbox path (absolute, resolved
2721    /// after the pivot), so grants describe the mounted view — the rootfs, the
2722    /// binds, and the `/proc`, `/dev`, and `/tmp` mounts alike.
2723    ///
2724    /// The ruleset is enforced on a kernel that offers Landlock, best-effort
2725    /// down to the kernel's supported ABI; a kernel without the Landlock LSM
2726    /// is a setup error, so a requested restriction never silently fails to
2727    /// apply.
2728    ///
2729    /// An empty `access` is refused at build with
2730    /// [`ConfigError::LandlockGrantEmpty`](crate::ConfigError::LandlockGrantEmpty):
2731    /// it reads as "grant nothing here" and would behave as "deny the whole
2732    /// filesystem", the command's own binary included.
2733    pub fn landlock_fs(
2734        mut self,
2735        access: crate::hardening::FsAccess,
2736        path: impl AsRef<Path>,
2737    ) -> Self {
2738        self.hardening.grant_fs(access, path.as_ref());
2739        self
2740    }
2741
2742    /// Grants network `access` on the TCP `port` under a Landlock ruleset.
2743    ///
2744    /// Configuring any grant — filesystem or network — enrolls the sandbox in
2745    /// Landlock. A network grant then governs both TCP bind and connect: the
2746    /// command may bind only ports granted [`NetAccess::BIND`] and connect only
2747    /// to ports granted [`NetAccess::CONNECT`], and every other bind and
2748    /// connect is denied. A grant of `port` `0` for [`NetAccess::BIND`] permits
2749    /// binding to a kernel-assigned ephemeral port.
2750    ///
2751    /// Network rights require Landlock ABI 4. On an older kernel that offers
2752    /// Landlock they are narrowed away best-effort, exactly as an unsupported
2753    /// filesystem right is; [`host::landlock_abi`](crate::host::landlock_abi)
2754    /// reports whether the running kernel reaches ABI 4.
2755    ///
2756    /// # Where the downgrade is refused, and where it is silent
2757    ///
2758    /// Under [`Network::Host`] a network grant is the sandbox's only network
2759    /// boundary, so a kernel that cannot enforce it is a build error rather than
2760    /// a downgrade.
2761    ///
2762    /// Under [`Network::Isolated`] and [`Network::None`] the network namespace
2763    /// is the boundary and the grant is additive hardening, so the downgrade is
2764    /// silent. That includes the case where the grant is the *only* Landlock
2765    /// request made: with every network right masked away and no filesystem
2766    /// grant to carry, no ruleset is built at all, and the command runs with the
2767    /// namespace as its sole network confinement. Check
2768    /// [`host::landlock_abi`](crate::host::landlock_abi) before the build where
2769    /// that matters.
2770    ///
2771    /// An empty `access` is refused at build with
2772    /// [`ConfigError::LandlockGrantEmpty`](crate::ConfigError::LandlockGrantEmpty):
2773    /// enrolling a grant denies everything ungranted and the kernel skips a rule
2774    /// that permits nothing, so it would deny all TCP rather than nothing.
2775    ///
2776    /// [`NetAccess::BIND`]: crate::NetAccess::BIND
2777    /// [`NetAccess::CONNECT`]: crate::NetAccess::CONNECT
2778    pub fn landlock_net(mut self, access: crate::hardening::NetAccess, port: u16) -> Self {
2779        self.hardening.grant_net(access, port);
2780        self
2781    }
2782
2783    /// Applies a seccomp syscall filter to the command.
2784    ///
2785    /// See [`SeccompPolicy`](crate::SeccompPolicy) for the curated profile,
2786    /// caller-authored rules, and the pre-compiled escape hatch. The filter
2787    /// binds the command and its descendants.
2788    pub fn seccomp(mut self, policy: crate::hardening::SeccompPolicy) -> Self {
2789        self.hardening.set_seccomp(policy);
2790        self
2791    }
2792
2793    /// Drops every capability from the command.
2794    ///
2795    /// The command runs mapped to root inside the user namespace but holds no
2796    /// capabilities, so operations gated on one are refused. The bounding and
2797    /// ambient sets are cleared too, so no capability can be regained across
2798    /// `execve`.
2799    pub fn drop_all_capabilities(mut self) -> Self {
2800        self.hardening.drop_all_capabilities();
2801        self
2802    }
2803
2804    /// Drops every capability except those named.
2805    ///
2806    /// Like [`drop_all_capabilities`](Self::drop_all_capabilities), but the
2807    /// listed capabilities are retained in the permitted, effective,
2808    /// inheritable, bounding, and ambient sets.
2809    pub fn keep_capabilities<I>(mut self, caps: I) -> Self
2810    where
2811        I: IntoIterator<Item = crate::hardening::Capability>,
2812    {
2813        self.hardening.keep_capabilities(caps);
2814        self
2815    }
2816}
2817
2818/// Composes a command's environment: the given deterministic base plus the
2819/// caller's variables, later entries overriding earlier ones, frozen as
2820/// sorted `NAME=value` strings.
2821pub(crate) fn compose_env(
2822    base: &[(&str, &str)],
2823    vars: &[(OsString, OsString)],
2824) -> Result<Vec<CString>, Error> {
2825    let mut env: BTreeMap<OsString, OsString> = base
2826        .iter()
2827        .map(|(name, value)| (OsString::from(name), OsString::from(value)))
2828        .collect();
2829    for (name, value) in vars {
2830        if name.is_empty() || name.as_bytes().contains(&b'=') {
2831            return Err(ConfigError::EnvNameInvalid { name: name.clone() }.into());
2832        }
2833        env.insert(name.clone(), value.clone());
2834    }
2835    env.iter()
2836        .map(|(name, value)| {
2837            let mut entry = Vec::with_capacity(name.len() + value.len() + 1);
2838            entry.extend_from_slice(name.as_bytes());
2839            entry.push(b'=');
2840            entry.extend_from_slice(value.as_bytes());
2841            CString::new(entry).map_err(|_| ConfigError::EmbeddedNul.into())
2842        })
2843        .collect()
2844}
2845
2846/// Validates a hostname: non-empty, at most 64 bytes, no NUL.
2847fn validate_hostname(name: &OsStr) -> Result<CString, Error> {
2848    if name.is_empty() || name.len() > 64 {
2849        return Err(ConfigError::HostnameInvalid.into());
2850    }
2851    Ok(cstring(name)?)
2852}
2853
2854/// Validates a working directory: absolute, no NUL. Returns `None` for `/`,
2855/// where no `chdir` is needed.
2856pub(crate) fn validate_workdir(path: &Path) -> Result<Option<CString>, Error> {
2857    if !path.is_absolute() {
2858        return Err(ConfigError::WorkdirNotAbsolute {
2859            path: path.to_path_buf(),
2860        }
2861        .into());
2862    }
2863    if path == Path::new("/") {
2864        return Ok(None);
2865    }
2866    Ok(Some(cstring(path.as_os_str())?))
2867}
2868
2869/// Prepares an overlay root: creates the upper and its work directory, refuses
2870/// a host that cannot mount an unprivileged overlay, and freezes the mount
2871/// options.
2872///
2873/// `lower` is the already-canonicalized rootfs. The upper is created if absent
2874/// and resolved, and a hidden work directory — which the overlay requires empty
2875/// and on the same filesystem as the upper — is created beside it. The three
2876/// layer paths are checked for the `,` and `:` characters the overlay option
2877/// string reserves, the host is preflighted through
2878/// [`overlay_blocker`](crate::host::overlay_blocker), and the
2879/// `lowerdir=…,upperdir=…,workdir=…,userxattr` option string is built once.
2880fn build_overlay_plan(base: &Path, overlay: &Overlay) -> Result<OverlayPlan, Error> {
2881    // The base is already canonical: it is the first lower, resolved as the
2882    // rootfs. Resolve any further lowers the same way, so a missing or
2883    // non-directory layer is a typed configuration error here.
2884    let mut lowers = vec![base.to_path_buf()];
2885    for lower in overlay.lower.iter().skip(1) {
2886        let resolved = std::fs::canonicalize(lower)
2887            .map_err(|err| overlay_io(OverlayLayer::Lower, lower, err))?;
2888        rustix::fs::open(
2889            &resolved,
2890            OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
2891            Mode::empty(),
2892        )
2893        .map_err(|errno| ConfigError::OverlayDirUnusable {
2894            layer: OverlayLayer::Lower,
2895            path: lower.clone(),
2896            source: errno.into(),
2897        })?;
2898        lowers.push(resolved);
2899    }
2900
2901    // An overlay with no upper would be a read-only merge; the sandbox needs
2902    // somewhere for its writes to land, so the upper is required.
2903    let Some(upper) = overlay.upper.as_deref() else {
2904        return Err(ConfigError::OverlayUpperMissing.into());
2905    };
2906
2907    // Create and resolve the upper. A relative or non-existent upper is created;
2908    // the result is the absolute path the mount options name.
2909    let upper = prepare_layer_dir(OverlayLayer::Upper, upper)?;
2910
2911    // The upper needs a parent directory: the work directory the library
2912    // derives is a sibling of the upper, and the host preflight below runs in
2913    // the parent so its scratch entries land beside the upper rather than inside
2914    // it. The upper is canonical here, so the only path without one is `/` —
2915    // which an overlay could not use anyway, since its writes would land over
2916    // the whole host root.
2917    let Some(scratch) = upper.parent().map(Path::to_path_buf) else {
2918        return Err(ConfigError::OverlayUpperIsRoot { path: upper }.into());
2919    };
2920
2921    // The work directory sits beside the upper, on the same filesystem, so the
2922    // overlay can move files between them. A hidden sibling keyed to the upper's
2923    // name keeps it out of the way and distinct per upper; a caller may name its
2924    // own instead.
2925    let work = match overlay.work.as_deref() {
2926        Some(work) => work.to_path_buf(),
2927        None => overlay_work_dir(&upper).ok_or(ConfigError::OverlayUpperIsRoot {
2928            path: upper.clone(),
2929        })?,
2930    };
2931    let work = prepare_layer_dir(OverlayLayer::Work, &work)?;
2932
2933    // The overlay moves files between the upper and the work directory, so the
2934    // kernel requires them on one filesystem. A caller that named its own work
2935    // directory can have put it elsewhere; comparing the two devices refuses
2936    // that here, with the layer named, rather than at the mount as a bare
2937    // EXDEV from a syscall the caller never issued.
2938    let device = |layer, path: &Path| {
2939        rustix::fs::stat(path)
2940            .map(|stat| stat.st_dev)
2941            .map_err(|errno| overlay_errno(layer, path, errno))
2942    };
2943    if device(OverlayLayer::Upper, &upper)? != device(OverlayLayer::Work, &work)? {
2944        return Err(ConfigError::OverlayDirUnusable {
2945            layer: OverlayLayer::Work,
2946            path: work,
2947            source: Errno::XDEV.into(),
2948        }
2949        .into());
2950    }
2951
2952    // The option string is comma-separated and colon-delimited; a layer path
2953    // bearing either character would misparse, so refuse it rather than build a
2954    // truncated mount.
2955    for path in lowers
2956        .iter()
2957        .map(PathBuf::as_path)
2958        .chain([upper.as_path(), work.as_path()])
2959    {
2960        let bytes = path.as_os_str().as_bytes();
2961        if bytes.contains(&b',') || bytes.contains(&b':') {
2962            return Err(ConfigError::OverlayPathInvalid {
2963                path: path.to_path_buf(),
2964            }
2965            .into());
2966        }
2967    }
2968
2969    // Refuse a host that cannot establish an unprivileged overlay whose upper
2970    // lives on this filesystem, before any fork — the same fail-early posture
2971    // the identity-map validation takes. The probe runs on the upper's parent
2972    // (its own filesystem, since the upper is a directory within it), so its
2973    // scratch files land beside the upper rather than inside it.
2974    if let Some(blocker) = crate::host::overlay_blocker(&scratch) {
2975        return Err(ConfigError::OverlayUnavailable { blocker }.into());
2976    }
2977
2978    // `lowerdir=` is highest-precedence-first, the reverse of this type's
2979    // base-first order, so the resolved stack is emitted reversed.
2980    let mut options = Vec::new();
2981    options.extend_from_slice(b"lowerdir=");
2982    for (i, lower) in lowers.iter().rev().enumerate() {
2983        if i > 0 {
2984            options.push(b':');
2985        }
2986        options.extend_from_slice(lower.as_os_str().as_bytes());
2987    }
2988    options.extend_from_slice(b",upperdir=");
2989    options.extend_from_slice(upper.as_os_str().as_bytes());
2990    options.extend_from_slice(b",workdir=");
2991    options.extend_from_slice(work.as_os_str().as_bytes());
2992    options.extend_from_slice(b",userxattr");
2993    let options = CString::new(options).map_err(|_| ConfigError::EmbeddedNul)?;
2994    Ok(OverlayPlan {
2995        options,
2996        lower: lowers,
2997        upper,
2998        work,
2999    })
3000}
3001
3002/// The overlay work directory the library manages beside `upper`: a hidden
3003/// sibling keyed to the upper's name, on the same filesystem the overlay
3004/// requires it share with the upper. Returns `None` when `upper` has no parent or
3005/// no file name.
3006///
3007/// Shared by the overlay-plan construction here and the layered-build layer's
3008/// disposal, so the directory an overlay-rooted cage creates and the one the
3009/// build layer removes are the same.
3010pub(crate) fn overlay_work_dir(upper: &Path) -> Option<PathBuf> {
3011    let (parent, name) = (upper.parent()?, upper.file_name()?);
3012    let mut work_name = OsString::from(".");
3013    work_name.push(name);
3014    work_name.push(".work");
3015    Some(parent.join(work_name))
3016}
3017
3018/// The mode an overlay layer directory the library creates is given.
3019///
3020/// Stated rather than left to the process umask, for the same reason
3021/// [`DIR_MODE`] is: the directory outlives the sandbox on the host, and whether
3022/// the layer a build keeps is readable is not a property worth taking from
3023/// whatever umask the build happened to run under. A layer directory that was
3024/// already there is left at the mode its owner gave it.
3025const OVERLAY_DIR_MODE: RawMode = 0o755;
3026
3027/// Creates an overlay layer directory if it is absent and returns its absolute
3028/// path, refusing to adopt an entry the calling user does not own.
3029///
3030/// The layer directories are the one part of an overlay root the library
3031/// creates on the host, and the caller may well name them under a directory it
3032/// shares — a scratch area, a build tree several jobs write to. A plain
3033/// `create_dir_all` would succeed against anything already at the path that
3034/// *resolves* to a directory, a symbolic link to one included, and the resolved
3035/// destination is what would then go into `upperdir=`: every write the sandbox
3036/// makes would land wherever the link pointed, and the caller would read that
3037/// back afterwards as the increment its build produced.
3038///
3039/// So the leaf is created with `mkdirat` against a descriptor for its parent,
3040/// which refuses a name that exists rather than following it, and an `EEXIST` is
3041/// adopted only when `fstatat` — with `AT_SYMLINK_NOFOLLOW`, so a link is a link
3042/// and not its destination — reports a directory belonging to the calling user.
3043/// The same discipline [`host::overlay_blocker`](crate::host::overlay_blocker)
3044/// already applies to its throwaway probe entries in the same directory.
3045///
3046/// Ancestors above the leaf are created with `create_dir_all`, as before: the
3047/// caller named them, so reaching them through a symbolic link is the caller's
3048/// business, the same position [`open_dir`] takes. The absolute path is composed
3049/// from the *parent's* canonical path and the leaf's own name, so the leaf is
3050/// never resolved as a link that the checks above just established it is not.
3051fn prepare_layer_dir(layer: OverlayLayer, path: &Path) -> Result<PathBuf, Error> {
3052    // A path with no file name is `/` or a `..` chain; neither is a directory
3053    // this can create, and `/` as a layer is refused by name below.
3054    let (Some(parent), Some(name)) = (path.parent(), path.file_name()) else {
3055        return Err(ConfigError::OverlayUpperIsRoot {
3056            path: path.to_path_buf(),
3057        }
3058        .into());
3059    };
3060    // An empty parent means a bare relative name: the working directory.
3061    let parent = if parent.as_os_str().is_empty() {
3062        Path::new(".")
3063    } else {
3064        parent
3065    };
3066    std::fs::create_dir_all(parent).map_err(|err| overlay_io(layer, parent, err))?;
3067    let dirfd = rustix::fs::open(
3068        parent,
3069        OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
3070        Mode::empty(),
3071    )
3072    .map_err(|errno| overlay_errno(layer, parent, errno))?;
3073
3074    match rustix::fs::mkdirat(&dirfd, name, Mode::from_raw_mode(OVERLAY_DIR_MODE)) {
3075        // The mode is set again, because `mkdir` narrows it by the process
3076        // umask and the layer outlives the sandbox on the host: a build under
3077        // `umask 077` would otherwise leave a `0700` upper for whatever consumes
3078        // it next. Only the directory this call just created is chmod-ed, so an
3079        // adopted one keeps the mode its owner gave it — the same rule the
3080        // sandbox stage follows for a mount point it creates.
3081        Ok(()) => {
3082            rustix::fs::chmodat(
3083                &dirfd,
3084                name,
3085                Mode::from_raw_mode(OVERLAY_DIR_MODE),
3086                AtFlags::empty(),
3087            )
3088            .map_err(|errno| overlay_errno(layer, path, errno))?;
3089        }
3090        Err(Errno::EXIST) => {
3091            let stat = rustix::fs::statat(&dirfd, name, AtFlags::SYMLINK_NOFOLLOW)
3092                .map_err(|errno| overlay_errno(layer, path, errno))?;
3093            let is_dir = FileType::from_raw_mode(stat.st_mode) == FileType::Directory;
3094            if !is_dir || stat.st_uid != rustix::process::geteuid().as_raw() {
3095                return Err(ConfigError::OverlayDirUnowned {
3096                    layer,
3097                    path: path.to_path_buf(),
3098                }
3099                .into());
3100            }
3101        }
3102        Err(errno) => return Err(overlay_errno(layer, path, errno)),
3103    }
3104
3105    let parent = std::fs::canonicalize(parent).map_err(|err| overlay_io(layer, parent, err))?;
3106    Ok(parent.join(name))
3107}
3108
3109/// Builds the configuration error for a host I/O failure preparing an overlay
3110/// layer directory, naming which of the two the failure was.
3111fn overlay_io(layer: OverlayLayer, path: &Path, err: std::io::Error) -> Error {
3112    ConfigError::OverlayDirUnusable {
3113        layer,
3114        path: path.to_path_buf(),
3115        source: err,
3116    }
3117    .into()
3118}
3119
3120/// [`overlay_io`] for the raw-syscall paths, which report an [`Errno`] rather
3121/// than an [`std::io::Error`].
3122fn overlay_errno(layer: OverlayLayer, path: &Path, errno: Errno) -> Error {
3123    ConfigError::OverlayDirUnusable {
3124        layer,
3125        path: path.to_path_buf(),
3126        source: errno.into(),
3127    }
3128    .into()
3129}
3130
3131/// Lowers the mount profile into the frozen op list the child process
3132/// executes.
3133///
3134/// Each op carries every path it needs as a rootfs-relative `CString`, with
3135/// mount-target directory chains precomputed as [`MountDir`]s, so the child
3136/// process performs no path manipulation. The label list is parallel to the ops
3137/// and caller-side only: it names the mount in a setup error.
3138#[derive(Default)]
3139struct Lowering {
3140    ops: Vec<MountOp>,
3141    labels: Vec<String>,
3142}
3143
3144impl Lowering {
3145    fn push(&mut self, op: MountOp, label: String) {
3146        self.ops.push(op);
3147        self.labels.push(label);
3148    }
3149
3150    /// A fresh procfs instance, presenting the sandbox's own PID namespace.
3151    fn push_proc_fresh(&mut self) -> Result<(), Error> {
3152        self.push(
3153            MountOp {
3154                dirs: dir_chain(&["proc"], DIR_MODE)?,
3155                create_file: None,
3156                action: MountAction::Procfs {
3157                    target: rel_cstring(&["proc"])?,
3158                },
3159            },
3160            "/proc".to_string(),
3161        );
3162        Ok(())
3163    }
3164
3165    /// The host's procfs, bind-mounted: without a new PID namespace a fresh
3166    /// procfs instance would present the same processes — and the kernel
3167    /// refuses one in a user namespace when the host /proc carries
3168    /// overmounts (a systemd binfmt_misc automount, for example). The bind
3169    /// carries those submounts along.
3170    fn push_proc_bind(&mut self) -> Result<(), Error> {
3171        self.push(
3172            MountOp {
3173                dirs: dir_chain(&["proc"], DIR_MODE)?,
3174                create_file: None,
3175                action: MountAction::Bind {
3176                    source: cstring(OsStr::new("/proc"))?,
3177                    target: rel_cstring(&["proc"])?,
3178                    read_only: false,
3179                },
3180            },
3181            "/proc".to_string(),
3182        );
3183        Ok(())
3184    }
3185
3186    fn push_dev(&mut self) -> Result<(), Error> {
3187        // The /dev tmpfs is NOSUID but deliberately not NODEV: it must carry
3188        // the device nodes bound below, which NODEV would render inoperable.
3189        // The nodes themselves are bind-mounted read-write from the host, not
3190        // recreated (mknod is denied to an unprivileged process, even in a
3191        // user namespace). This is safe in the sandbox's user namespace: the
3192        // bound nodes are the host's own null/zero/random/etc., not fresh
3193        // nodes the sandbox could use to reach an arbitrary device, and access
3194        // to them is governed by the namespace. It mirrors what bubblewrap
3195        // does, and the choice is intentional rather than an oversight.
3196        self.push(
3197            MountOp {
3198                dirs: dir_chain(&["dev"], DIR_MODE)?,
3199                create_file: None,
3200                action: MountAction::Tmpfs {
3201                    target: rel_cstring(&["dev"])?,
3202                    flags: MountFlags::NOSUID,
3203                    data: c"mode=0755".to_owned(),
3204                },
3205            },
3206            "/dev".to_string(),
3207        );
3208        for device in DEV_DEVICES {
3209            self.push(
3210                MountOp {
3211                    dirs: Vec::new(),
3212                    create_file: Some(rel_cstring(&["dev", device])?),
3213                    action: MountAction::Bind {
3214                        source: cstring(OsStr::new(&format!("/dev/{device}")))?,
3215                        target: rel_cstring(&["dev", device])?,
3216                        read_only: false,
3217                    },
3218                },
3219                format!("/dev/{device}"),
3220            );
3221        }
3222        for (name, content) in DEV_SYMLINKS {
3223            self.push(
3224                MountOp {
3225                    dirs: Vec::new(),
3226                    create_file: None,
3227                    action: MountAction::Symlink {
3228                        parent: rel_cstring(&["dev"])?,
3229                        leaf: cstring(OsStr::new(name))?,
3230                        content: cstring(OsStr::new(content))?,
3231                    },
3232                },
3233                format!("/dev/{name}"),
3234            );
3235        }
3236        self.push(
3237            MountOp {
3238                // The mount point is an ordinary directory: devpts's `mode=` is
3239                // the mode of the pseudo-terminals it allocates, not of its
3240                // own root, so there is nothing here to mirror.
3241                dirs: dir_chain(&["dev", "pts"], DIR_MODE)?,
3242                create_file: None,
3243                action: MountAction::Devpts {
3244                    target: rel_cstring(&["dev", "pts"])?,
3245                    // No gid= option: the conventional tty group is not
3246                    // mapped under the default single-identity map and an
3247                    // unmapped gid fails the mount.
3248                    data: c"newinstance,ptmxmode=0666,mode=0620".to_owned(),
3249                },
3250            },
3251            "/dev/pts".to_string(),
3252        );
3253        self.push(
3254            MountOp {
3255                dirs: dir_chain(&["dev", "shm"], STICKY_DIR_MODE)?,
3256                create_file: None,
3257                action: MountAction::Tmpfs {
3258                    target: rel_cstring(&["dev", "shm"])?,
3259                    flags: MountFlags::NOSUID | MountFlags::NODEV,
3260                    data: c"mode=1777".to_owned(),
3261                },
3262            },
3263            "/dev/shm".to_string(),
3264        );
3265        Ok(())
3266    }
3267
3268    fn push_tmp(&mut self) -> Result<(), Error> {
3269        self.push(
3270            MountOp {
3271                dirs: dir_chain(&["tmp"], STICKY_DIR_MODE)?,
3272                create_file: None,
3273                action: MountAction::Tmpfs {
3274                    target: rel_cstring(&["tmp"])?,
3275                    flags: MountFlags::NOSUID | MountFlags::NODEV,
3276                    data: c"mode=1777".to_owned(),
3277                },
3278            },
3279            "/tmp".to_string(),
3280        );
3281        Ok(())
3282    }
3283
3284    fn push_bind(&mut self, bind: &Bind) -> Result<(), Error> {
3285        let source = std::fs::canonicalize(&bind.source).map_err(|err| {
3286            Error::from(ConfigError::BindSourceUnusable {
3287                path: bind.source.clone(),
3288                source: err,
3289            })
3290        })?;
3291        let source_is_dir = source.is_dir();
3292        let components = sandbox_path_components(&bind.target)?;
3293
3294        let (dirs, create_file) = if source_is_dir {
3295            (dir_chain(&components, DIR_MODE)?, None)
3296        } else {
3297            // A file bind creates the parent directories and then the
3298            // target file itself.
3299            let parents = &components[..components.len() - 1];
3300            (
3301                dir_chain(parents, DIR_MODE)?,
3302                Some(rel_cstring(&components)?),
3303            )
3304        };
3305
3306        let label = format!(
3307            "{} at {}{}",
3308            source.display(),
3309            bind.target.display(),
3310            if bind.read_only { ", read-only" } else { "" },
3311        );
3312        self.push(
3313            MountOp {
3314                dirs,
3315                create_file,
3316                action: MountAction::Bind {
3317                    source: cstring(source.as_os_str())?,
3318                    target: rel_cstring(&components)?,
3319                    read_only: bind.read_only,
3320                },
3321            },
3322            label,
3323        );
3324        Ok(())
3325    }
3326
3327    fn push_raw(&mut self, raw: &RawMount) -> Result<(), Error> {
3328        let components = sandbox_path_components(&raw.target)?;
3329        let flags = u32::try_from(raw.flags)
3330            .map_err(|_| ConfigError::MountFlagsInvalid { flags: raw.flags })?;
3331        let source = match &raw.source {
3332            Some(path) => cstring(path.as_os_str())?,
3333            None => CString::default(),
3334        };
3335        let fstype = match &raw.fstype {
3336            Some(fstype) => CString::new(fstype.as_str()).map_err(|_| ConfigError::EmbeddedNul)?,
3337            None => CString::default(),
3338        };
3339        let data = match &raw.data {
3340            Some(data) => CString::new(data.as_str()).map_err(|_| ConfigError::EmbeddedNul)?,
3341            None => CString::default(),
3342        };
3343        self.push(
3344            MountOp {
3345                dirs: dir_chain(&components, DIR_MODE)?,
3346                create_file: None,
3347                action: MountAction::Raw {
3348                    source,
3349                    target: rel_cstring(&components)?,
3350                    fstype,
3351                    flags: MountFlags::from_bits_retain(flags),
3352                    data,
3353                },
3354            },
3355            format!("raw mount at {}", raw.target.display()),
3356        );
3357        Ok(())
3358    }
3359
3360    /// The host's `resolv.conf`, bound read-only over the rootfs's own.
3361    ///
3362    /// Resolving the host's `resolv.conf` can fail — it is commonly a symlink
3363    /// into a resolver's runtime directory, which need not exist — and a host
3364    /// without a usable one simply gets no bind.
3365    ///
3366    /// A bind needs its target to exist before anything can be mounted onto it,
3367    /// so a root shipping no `resolv.conf` gets an empty one created. That file
3368    /// would otherwise outlive the sandbox as a resolver configuration the root
3369    /// never had, which an export carries into whatever is made of the tree;
3370    /// [`created_file_targets`] picks it up from the finished op list, along
3371    /// with every other target the launch creates, and the caller side removes
3372    /// it once the sandbox is gone.
3373    fn push_resolv_conf(&mut self) -> Result<(), Error> {
3374        let Ok(source) = std::fs::canonicalize(RESOLV_CONF) else {
3375            return Ok(());
3376        };
3377        if !source.is_file() {
3378            return Ok(());
3379        }
3380        self.push(
3381            MountOp {
3382                dirs: dir_chain(&["etc"], DIR_MODE)?,
3383                create_file: Some(rel_cstring(&["etc", "resolv.conf"])?),
3384                action: MountAction::Bind {
3385                    source: cstring(source.as_os_str())?,
3386                    target: rel_cstring(&["etc", "resolv.conf"])?,
3387                    read_only: true,
3388                },
3389            },
3390            RESOLV_CONF.to_string(),
3391        );
3392        Ok(())
3393    }
3394}
3395
3396/// The host paths of the mount targets `ops` has to create as empty files.
3397///
3398/// A file bind needs its target to exist, so the launch creates one where the
3399/// root ships none — the managed `/etc/resolv.conf` bind of host networking,
3400/// and any file bind a caller configures. That file is the sandbox's own
3401/// contribution, not part of the root, so it is reported here for the caller
3402/// side to remove once the sandbox is gone.
3403///
3404/// `writable` is the host directory the sandbox's writes land in, and `layers`
3405/// the host directories an already-present target could come from; for a plain
3406/// rootfs both are that rootfs, and for an overlay root the upper and the whole
3407/// layer stack respectively.
3408///
3409/// Two targets are deliberately left out, because neither is a file in the root
3410/// at a path this can name:
3411///
3412/// - one already present in any layer, which the root ships and the launch
3413///   therefore does not create. Presence is any entry at the path, a symbolic
3414///   link included: the launch creates the target with `O_EXCL`, so an entry of
3415///   any kind is one it leaves alone;
3416/// - one under the target of an earlier op, which is created inside that mount
3417///   — a tmpfs that vanishes with the namespace, or a caller's own bind source
3418///   — rather than in the root directory on the host.
3419///
3420/// Directories the launch creates are not placeholders: a mount point directory
3421/// is part of what a launch contributes to a tree, created at the mode of the
3422/// directory it stands for, and is left in place.
3423fn created_file_targets(
3424    ops: &[MountOp],
3425    writable: &Path,
3426    layers: &[PathBuf],
3427) -> ManagedPlaceholders {
3428    let mut mounted: Vec<PathBuf> = Vec::new();
3429    let mut paths = Vec::new();
3430    let layers: Vec<Option<OwnedFd>> = layers.iter().map(|layer| open_root(layer)).collect();
3431    for op in ops {
3432        if let Some(file) = &op.create_file {
3433            let relative = Path::new(OsStr::from_bytes(file.to_bytes()));
3434            let covered = mounted.iter().any(|mount| relative.starts_with(mount));
3435            let present = layers
3436                .iter()
3437                .any(|layer| entry_in_root(layer.as_ref(), relative) != Presence::Absent);
3438            if !covered && !present {
3439                paths.push(relative.to_path_buf());
3440            }
3441        }
3442        if let Some(target) = op.action.target() {
3443            mounted.push(PathBuf::from(OsStr::from_bytes(target.to_bytes())));
3444        }
3445    }
3446    ManagedPlaceholders {
3447        root: if paths.is_empty() {
3448            PathBuf::new()
3449        } else {
3450            writable.to_path_buf()
3451        },
3452        paths,
3453    }
3454}
3455
3456/// What a root-relative lookup found.
3457#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3458pub(crate) enum Presence {
3459    /// Nothing is at the path.
3460    Absent,
3461    /// An entry of some kind is at the path.
3462    Present,
3463    /// The lookup could not reach a verdict — a permission refusal, an
3464    /// unreadable root. Treated as [`Present`](Self::Present) everywhere here:
3465    /// what the answer decides is whether the launch created an entry that this
3466    /// side may later unlink, and "not sure" must not become "mine to remove".
3467    Unknown,
3468}
3469
3470/// Opens a root directory for the root-relative lookups below, or `None` when it
3471/// cannot be opened at all.
3472///
3473/// `O_PATH`: this descriptor is only ever the anchor of an `openat2`, so it needs
3474/// no read access to the directory itself.
3475pub(crate) fn open_root(root: &Path) -> Option<OwnedFd> {
3476    rustix::fs::open(
3477        root,
3478        OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
3479        Mode::empty(),
3480    )
3481    .ok()
3482}
3483
3484/// Whether `relative` names an entry inside the tree rooted at `root`.
3485///
3486/// Resolved with `openat2` and `RESOLVE_IN_ROOT`, the same way the sandbox stage
3487/// resolves every mount target against the new-root descriptor, so that an
3488/// absolute symbolic link inside the tree — `./etc -> /etc` in an untrusted
3489/// unpacked tarball, or the `/etc/resolv.conf -> /run/systemd/resolve/…` any
3490/// image taken from a resolved host ships — resolves *within the tree* rather
3491/// than against the host's own root. Joining the two paths and asking the host
3492/// instead would answer a question about the host's filesystem, and would let a
3493/// later removal keyed to the same path land outside the tree entirely.
3494///
3495/// `O_NOFOLLOW` on the final component, so an entry is an entry whatever its
3496/// kind: the launch creates its target with `O_EXCL` and leaves anything already
3497/// there alone, dangling symbolic link included.
3498pub(crate) fn entry_in_root(root: Option<&OwnedFd>, relative: &Path) -> Presence {
3499    let Some(root) = root else {
3500        return Presence::Unknown;
3501    };
3502    let Ok(relative) = CString::new(relative.as_os_str().as_bytes()) else {
3503        return Presence::Unknown;
3504    };
3505    // Not `provision::containment::open_dir`: the question is whether a *leaf*
3506    // is there, of whatever kind, so this opens with `O_NOFOLLOW` and without
3507    // `O_DIRECTORY`, and every failure becomes a verdict rather than an error.
3508    #[expect(clippy::disallowed_methods)]
3509    let opened = rustix::fs::openat2(
3510        root,
3511        relative.as_c_str(),
3512        OFlags::PATH | OFlags::NOFOLLOW | OFlags::CLOEXEC,
3513        Mode::empty(),
3514        ResolveFlags::IN_ROOT,
3515    );
3516    match opened {
3517        Ok(_) => Presence::Present,
3518        // Nothing at the path, or a non-directory where a directory would have
3519        // to be for anything to sit under it: either way the entry is absent.
3520        Err(Errno::NOENT | Errno::NOTDIR) => Presence::Absent,
3521        Err(_) => Presence::Unknown,
3522    }
3523}
3524
3525/// Splits an absolute sandbox path into its ordinary components.
3526///
3527/// The bare root is refused on top of what [`path::absolute_components`]
3528/// refuses: a mount target names something *inside* the rootfs, and the rootfs
3529/// itself is what the target would be established over.
3530fn sandbox_path_components(path: &Path) -> Result<Vec<&OsStr>, Error> {
3531    let invalid = || {
3532        Error::from(ConfigError::MountTargetInvalid {
3533            path: path.to_path_buf(),
3534        })
3535    };
3536    let components = path::absolute_components(path).map_err(|_| invalid())?;
3537    if components.is_empty() {
3538        return Err(invalid());
3539    }
3540    Ok(components)
3541}
3542
3543/// Builds the mount-target directory chain for a rootfs-relative path: one
3544/// [`MountDir`] per component, the first parent being the empty string, meaning
3545/// the rootfs itself.
3546///
3547/// The mount point itself — the last component — is created at `leaf_mode`,
3548/// the mode of the directory it stands for once the mount that covers it is
3549/// gone. Every ancestor created on the way to it is an ordinary directory.
3550fn dir_chain<S: AsRef<OsStr>>(
3551    components: &[S],
3552    leaf_mode: RawMode,
3553) -> Result<Vec<MountDir>, Error> {
3554    let mut chain = Vec::with_capacity(components.len());
3555    let mut parent: Vec<u8> = Vec::new();
3556    for (depth, component) in components.iter().enumerate() {
3557        chain.push(MountDir {
3558            parent: CString::new(parent.clone()).map_err(|_| ConfigError::EmbeddedNul)?,
3559            leaf: cstring(component.as_ref())?,
3560            // Only the mount point itself carries the profile's mode; an
3561            // ancestor created on the way to it is an ordinary directory.
3562            mode: if depth + 1 == components.len() {
3563                leaf_mode
3564            } else {
3565                DIR_MODE
3566            },
3567        });
3568        if !parent.is_empty() {
3569            parent.push(b'/');
3570        }
3571        parent.extend_from_slice(component.as_ref().as_bytes());
3572    }
3573    Ok(chain)
3574}
3575
3576/// Joins rootfs-relative components with `/` into a `CString`.
3577fn rel_cstring<S: AsRef<OsStr>>(components: &[S]) -> Result<CString, Error> {
3578    let mut path: Vec<u8> = Vec::new();
3579    for component in components {
3580        if !path.is_empty() {
3581            path.push(b'/');
3582        }
3583        path.extend_from_slice(component.as_ref().as_bytes());
3584    }
3585    Ok(CString::new(path).map_err(|_| ConfigError::EmbeddedNul)?)
3586}
3587
3588/// Converts an `OsStr` to a `CString`, rejecting interior NUL bytes.
3589pub(crate) fn cstring(value: &OsStr) -> Result<CString, ConfigError> {
3590    CString::new(value.as_bytes()).map_err(|_| ConfigError::EmbeddedNul)
3591}
3592
3593/// Validates the configured resource limits and lowers them into the plan's
3594/// frozen form, paired with the labels that name each in a setup error.
3595///
3596/// The map's iteration order is the resource's own, so the applied order does
3597/// not depend on the order the caller configured them in.
3598pub(crate) fn lower_rlimits(
3599    rlimits: &BTreeMap<Resource, (Limit, Limit)>,
3600) -> Result<(Vec<RlimitPlan>, Vec<String>), Error> {
3601    let mut plans = Vec::with_capacity(rlimits.len());
3602    let mut labels = Vec::with_capacity(rlimits.len());
3603    for (resource, (soft, hard)) in rlimits {
3604        if !soft.fits_within(*hard) {
3605            return Err(ConfigError::RlimitInvalid {
3606                resource: *resource,
3607                soft: *soft,
3608                hard: *hard,
3609            }
3610            .into());
3611        }
3612        plans.push(RlimitPlan {
3613            resource: *resource,
3614            kernel: resource.to_kernel(),
3615            limit: rustix::process::Rlimit {
3616                current: soft.amount(),
3617                maximum: hard.amount(),
3618            },
3619        });
3620        labels.push(format!("{resource} soft {soft}, hard {hard}"));
3621    }
3622    Ok((plans, labels))
3623}
3624
3625/// The label a failed exec step reports as its subject.
3626///
3627/// For an ordinary command it is the path that was executed. For a path lookup
3628/// it is the bare name followed by every candidate the search tried, since the
3629/// reported errno is the search's verdict rather than any one candidate's.
3630pub(crate) fn exec_label(command: &Path, search: &[CString]) -> String {
3631    let command = command.display();
3632    if search.is_empty() {
3633        return command.to_string();
3634    }
3635    let candidates: Vec<String> = search
3636        .iter()
3637        .map(|candidate| {
3638            Path::new(OsStr::from_bytes(candidate.as_bytes()))
3639                .display()
3640                .to_string()
3641        })
3642        .collect();
3643    format!("{command}, searched {}", candidates.join(", "))
3644}
3645
3646/// Whether a command path contains a path separator, so it names a location
3647/// rather than a bare name to resolve against `PATH`.
3648pub(crate) fn command_has_slash(command: &Path) -> bool {
3649    command.as_os_str().as_bytes().contains(&b'/')
3650}
3651
3652/// The `PATH` a path-lookup command is resolved against: the last `PATH` the
3653/// caller set through [`CageBuilder::env`], or the deterministic
3654/// [`BASE_PATH`] when none is set and the base environment is in force. It
3655/// mirrors the value the command's own environment will carry, so lookup and
3656/// the running command agree on `PATH`.
3657///
3658/// With the base environment opted out of and no `PATH` of the caller's own,
3659/// the command will run with no `PATH` at all, so there is genuinely nothing
3660/// to search. Returning empty here rather than the base is what makes
3661/// [`CageBuilder::build`] report that, instead of resolving the command
3662/// against a `PATH` the command will never see.
3663fn effective_path(env: &[(OsString, OsString)], base_env: bool) -> OsString {
3664    env.iter()
3665        .rev()
3666        .find(|(key, _)| key == "PATH")
3667        .map(|(_, value)| value.clone())
3668        .unwrap_or_else(|| {
3669            if base_env {
3670                OsString::from(BASE_PATH)
3671            } else {
3672                OsString::new()
3673            }
3674        })
3675}
3676
3677/// The candidate absolute paths a bare `command` resolves to: one per absolute
3678/// directory in `path`, in order. Empty and relative `PATH` entries are
3679/// skipped rather than resolved against the working directory, so lookup never
3680/// depends on the sandbox's current directory. The candidates are executed in
3681/// order by the command stage.
3682///
3683/// The list may come back empty, when `path` holds no absolute entry at all;
3684/// [`CageBuilder::build`] turns that into a configuration error rather than
3685/// letting the command stage fall back to the bare name.
3686fn search_candidates(path: &OsStr, command: &Path) -> Result<Vec<CString>, ConfigError> {
3687    path.as_bytes()
3688        .split(|byte| *byte == b':')
3689        .filter_map(|entry| {
3690            let dir = Path::new(OsStr::from_bytes(entry));
3691            dir.is_absolute()
3692                .then(|| cstring(dir.join(command).as_os_str()))
3693        })
3694        .collect()
3695}
3696
3697/// Serde adapters for the `OsString`-typed builder fields.
3698///
3699/// Profiles are text files, so these fields serialize through `String`: a
3700/// non-UTF-8 value is a serialization error, and the programmatic builder
3701/// API keeps its full `OsStr` capability.
3702#[cfg(feature = "serde")]
3703pub(crate) mod serde_os {
3704    /// `Vec<OsString>` as a sequence of strings.
3705    pub(super) mod string_vec {
3706        use std::ffi::OsString;
3707
3708        use serde::ser::Error as _;
3709        use serde::{Deserialize, Deserializer, Serialize, Serializer};
3710
3711        pub(crate) fn serialize<S: Serializer>(
3712            values: &[OsString],
3713            serializer: S,
3714        ) -> Result<S::Ok, S::Error> {
3715            let strings = values
3716                .iter()
3717                .map(|value| {
3718                    value
3719                        .to_str()
3720                        .ok_or_else(|| S::Error::custom("a non-UTF-8 value cannot be serialized"))
3721                })
3722                .collect::<Result<Vec<&str>, _>>()?;
3723            strings.serialize(serializer)
3724        }
3725
3726        pub(crate) fn deserialize<'de, D: Deserializer<'de>>(
3727            deserializer: D,
3728        ) -> Result<Vec<OsString>, D::Error> {
3729            Ok(Vec::<String>::deserialize(deserializer)?
3730                .into_iter()
3731                .map(OsString::from)
3732                .collect())
3733        }
3734    }
3735
3736    /// `Option<OsString>` as an optional string.
3737    pub(super) mod string_opt {
3738        use std::ffi::OsString;
3739
3740        use serde::ser::Error as _;
3741        use serde::{Deserialize, Deserializer, Serialize, Serializer};
3742
3743        pub(crate) fn serialize<S: Serializer>(
3744            value: &Option<OsString>,
3745            serializer: S,
3746        ) -> Result<S::Ok, S::Error> {
3747            match value {
3748                Some(value) => value
3749                    .to_str()
3750                    .ok_or_else(|| S::Error::custom("a non-UTF-8 value cannot be serialized"))?
3751                    .serialize(serializer),
3752                None => serializer.serialize_none(),
3753            }
3754        }
3755
3756        pub(crate) fn deserialize<'de, D: Deserializer<'de>>(
3757            deserializer: D,
3758        ) -> Result<Option<OsString>, D::Error> {
3759            Ok(Option::<String>::deserialize(deserializer)?.map(OsString::from))
3760        }
3761    }
3762
3763    /// Environment pairs as a name-to-value map.
3764    ///
3765    /// Serialization folds the pairs through a map, so a later duplicate
3766    /// overrides an earlier one — the same override the builder applies.
3767    // `pub(crate)` rather than `pub(super)`: the resolved-inputs record
3768    // serializes its environment through this same adapter, so the two agree
3769    // on how an environment appears in text.
3770    pub(crate) mod string_map {
3771        use std::collections::BTreeMap;
3772        use std::ffi::OsString;
3773
3774        use serde::ser::Error as _;
3775        use serde::{Deserialize, Deserializer, Serialize, Serializer};
3776
3777        pub(crate) fn serialize<S: Serializer>(
3778            pairs: &[(OsString, OsString)],
3779            serializer: S,
3780        ) -> Result<S::Ok, S::Error> {
3781            let mut map = BTreeMap::new();
3782            for (name, value) in pairs {
3783                let name = name
3784                    .to_str()
3785                    .ok_or_else(|| S::Error::custom("a non-UTF-8 name cannot be serialized"))?;
3786                let value = value
3787                    .to_str()
3788                    .ok_or_else(|| S::Error::custom("a non-UTF-8 value cannot be serialized"))?;
3789                map.insert(name, value);
3790            }
3791            map.serialize(serializer)
3792        }
3793
3794        pub(crate) fn deserialize<'de, D: Deserializer<'de>>(
3795            deserializer: D,
3796        ) -> Result<Vec<(OsString, OsString)>, D::Error> {
3797            Ok(BTreeMap::<String, String>::deserialize(deserializer)?
3798                .into_iter()
3799                .map(|(name, value)| (OsString::from(name), OsString::from(value)))
3800                .collect())
3801        }
3802    }
3803}
3804
3805#[cfg(test)]
3806mod tests {
3807    use super::*;
3808    use crate::scratch::Scratch;
3809
3810    fn sh_in(rootfs: &str) -> CageBuilder {
3811        Cage::builder().rootfs(rootfs).command("/bin/sh")
3812    }
3813
3814    #[test]
3815    fn build_requires_a_rootfs() {
3816        let err = Cage::builder().command("/bin/sh").build().unwrap_err();
3817        assert!(matches!(err, Error::Config(ConfigError::RootfsMissing)));
3818    }
3819
3820    #[test]
3821    fn build_requires_a_command() {
3822        let err = Cage::builder().rootfs("/").build().unwrap_err();
3823        assert!(matches!(err, Error::Config(ConfigError::CommandMissing)));
3824    }
3825
3826    #[test]
3827    fn build_rejects_a_relative_command() {
3828        let err = sh_in("/tmp").command("bin/sh").build().unwrap_err();
3829        assert!(matches!(
3830            err,
3831            Error::Config(ConfigError::CommandNotAbsolute { .. })
3832        ));
3833    }
3834
3835    #[test]
3836    fn build_rejects_a_bare_command_without_path_lookup() {
3837        // A slash-free command is not resolved against PATH unless the caller
3838        // opts in; by default it is the same absolute-path error as any other
3839        // non-absolute command.
3840        let err = sh_in("/tmp").command("sh").build().unwrap_err();
3841        assert!(matches!(
3842            err,
3843            Error::Config(ConfigError::CommandNotAbsolute { .. })
3844        ));
3845    }
3846
3847    #[test]
3848    fn path_lookup_resolves_a_bare_command_against_the_base_path() {
3849        // With path lookup on, a bare name builds: argv[0] stays the name and
3850        // the search list carries one candidate per absolute PATH directory.
3851        let cage = sh_in("/tmp")
3852            .command("sh")
3853            .path_lookup(true)
3854            .build()
3855            .expect("a bare command builds under path lookup");
3856        assert_eq!(cage.plan.program, CString::new("sh").unwrap());
3857        assert_eq!(
3858            cage.plan.program_search,
3859            [
3860                "/usr/local/sbin/sh",
3861                "/usr/local/bin/sh",
3862                "/usr/sbin/sh",
3863                "/usr/bin/sh",
3864                "/sbin/sh",
3865                "/bin/sh",
3866            ]
3867            .map(|path| CString::new(path).unwrap()),
3868        );
3869    }
3870
3871    #[test]
3872    fn path_lookup_still_rejects_a_relative_path_with_a_slash() {
3873        // Path lookup resolves bare names only; a relative path that names a
3874        // location is never searched and stays an absolute-path error.
3875        let err = sh_in("/tmp")
3876            .command("bin/sh")
3877            .path_lookup(true)
3878            .build()
3879            .unwrap_err();
3880        assert!(matches!(
3881            err,
3882            Error::Config(ConfigError::CommandNotAbsolute { .. })
3883        ));
3884    }
3885
3886    #[test]
3887    fn path_lookup_leaves_an_absolute_command_unsearched() {
3888        // An absolute command is executed directly even under path lookup: no
3889        // search list, so the exact path is what runs.
3890        let cage = sh_in("/tmp")
3891            .command("/bin/sh")
3892            .path_lookup(true)
3893            .build()
3894            .expect("an absolute command builds");
3895        assert!(cage.plan.program_search.is_empty());
3896    }
3897
3898    #[test]
3899    fn path_lookup_follows_a_caller_set_path() {
3900        // The search uses the command's effective PATH, so a PATH the caller
3901        // set through env drives resolution, and relative or empty entries are
3902        // skipped rather than resolved against the working directory.
3903        let cage = sh_in("/tmp")
3904            .command("make")
3905            .path_lookup(true)
3906            .env("PATH", "/opt/bin::relative:/usr/bin")
3907            .build()
3908            .expect("a bare command builds under a caller PATH");
3909        assert_eq!(
3910            cage.plan.program_search,
3911            ["/opt/bin/make", "/usr/bin/make"].map(|path| CString::new(path).unwrap()),
3912        );
3913    }
3914
3915    #[test]
3916    fn path_lookup_rejects_a_path_with_nothing_to_search() {
3917        // Skipping every entry leaves no candidate. Executing the bare name
3918        // instead would resolve it against the sandbox's working directory,
3919        // which is exactly what path lookup is defined not to do.
3920        let err = sh_in("/tmp")
3921            .command("make")
3922            .path_lookup(true)
3923            .env("PATH", "relative:")
3924            .build()
3925            .unwrap_err();
3926        match err {
3927            Error::Config(ConfigError::SearchPathUnusable { path }) => {
3928                assert_eq!(path, OsStr::new("relative:"));
3929            }
3930            other => panic!("unexpected error: {other}"),
3931        }
3932    }
3933
3934    #[test]
3935    fn an_empty_command_names_no_command_at_all() {
3936        // Every check below reads an empty path as something else: it carries
3937        // no slash, so a path lookup takes it for a bare name, and joining it
3938        // onto a `PATH` entry yields that entry -- so the search finds a list of
3939        // directories and refuses nothing, deferring an `execve` of a directory
3940        // to launch. It is the absence of a command, and is reported as one.
3941        for builder in [
3942            sh_in("/tmp").command("").path_lookup(true),
3943            sh_in("/tmp").command(""),
3944        ] {
3945            assert!(
3946                matches!(
3947                    builder.build().unwrap_err(),
3948                    Error::Config(ConfigError::CommandMissing),
3949                ),
3950                "an empty command must not build",
3951            );
3952        }
3953    }
3954
3955    #[test]
3956    fn path_lookup_without_a_base_or_a_path_has_nothing_to_search() {
3957        // The base is where `PATH` would otherwise come from. Opting out of it
3958        // and setting none leaves the command with no `PATH` at all, so the
3959        // lookup is reported rather than resolved against the base the command
3960        // will not carry.
3961        let err = sh_in("/tmp")
3962            .command("make")
3963            .path_lookup(true)
3964            .base_env(false)
3965            .build()
3966            .unwrap_err();
3967        match err {
3968            Error::Config(ConfigError::SearchPathUnusable { path }) => {
3969                assert_eq!(path, OsStr::new(""));
3970            }
3971            other => panic!("unexpected error: {other}"),
3972        }
3973    }
3974
3975    #[test]
3976    fn path_lookup_without_a_base_uses_the_callers_own_path() {
3977        // The caller's `PATH` is the command's `PATH`, so the lookup resolves
3978        // against exactly what the command will see.
3979        let cage = sh_in("/tmp")
3980            .command("sh")
3981            .path_lookup(true)
3982            .base_env(false)
3983            .env("PATH", "/sbin")
3984            .build()
3985            .unwrap();
3986        assert_eq!(cage.plan.program_search, [c"/sbin/sh".to_owned()]);
3987        assert_eq!(cage.plan.env, [c"PATH=/sbin".to_owned()]);
3988    }
3989
3990    #[test]
3991    fn an_exact_environment_composes_no_base_underneath() {
3992        let cage = sh_in("/tmp").base_env(false).env("A", "b").build().unwrap();
3993        assert_eq!(cage.plan.env, [c"A=b".to_owned()]);
3994    }
3995
3996    #[test]
3997    fn opting_out_of_the_managed_mounts_establishes_none_of_them() {
3998        // The caller's own procfs is all the plan carries: none of the managed
3999        // profile's mounts survive the opt-out, and the one the nested user
4000        // namespace's map needs is the caller's to declare.
4001        let cage = sh_in("/tmp")
4002            .managed_mounts(false)
4003            .bind("/proc", "/proc")
4004            .build()
4005            .unwrap();
4006        assert_eq!(
4007            cage.plan.op_labels.len(),
4008            1,
4009            "expected only the caller's own mount, got {:?}",
4010            cage.plan.op_labels,
4011        );
4012    }
4013
4014    #[test]
4015    fn opting_out_while_asking_for_a_managed_mount_is_refused() {
4016        // The two cannot both be honored, and silently dropping either would
4017        // hand back a sandbox that differs from the one described.
4018        let err = sh_in("/tmp")
4019            .managed_mounts(false)
4020            .mount_dev(true)
4021            .build()
4022            .unwrap_err();
4023        match err {
4024            Error::Config(ConfigError::ManagedMountsContradiction { toggle }) => {
4025                assert_eq!(toggle, "mount_dev");
4026            }
4027            other => panic!("unexpected error: {other}"),
4028        }
4029    }
4030
4031    #[test]
4032    fn opting_out_alongside_an_explicit_off_toggle_is_not_a_contradiction() {
4033        // Both say the same thing, so there is nothing to refuse.
4034        let cage = sh_in("/tmp")
4035            .managed_mounts(false)
4036            .mount_dev(false)
4037            .bind("/proc", "/proc")
4038            .build()
4039            .unwrap();
4040        assert_eq!(cage.plan.op_labels.len(), 1);
4041    }
4042
4043    #[test]
4044    fn build_rejects_a_nul_byte_in_an_argument() {
4045        let err = sh_in("/tmp")
4046            .arg(OsStr::from_bytes(b"with\0nul"))
4047            .build()
4048            .unwrap_err();
4049        assert!(matches!(err, Error::Config(ConfigError::EmbeddedNul)));
4050    }
4051
4052    #[test]
4053    fn build_rejects_a_missing_rootfs_with_the_os_error() {
4054        let err = sh_in("/ferroday-cage/does/not/exist").build().unwrap_err();
4055        match err {
4056            Error::Config(ConfigError::RootfsUnusable { source, .. }) => {
4057                assert_eq!(source.raw_os_error(), Some(Errno::NOENT.raw_os_error()));
4058            }
4059            other => panic!("expected RootfsUnusable, got {other:?}"),
4060        }
4061    }
4062
4063    #[test]
4064    fn build_rejects_a_rootfs_that_is_not_a_directory() {
4065        let err = sh_in("/dev/null").build().unwrap_err();
4066        match err {
4067            Error::Config(ConfigError::RootfsUnusable { source, .. }) => {
4068                assert_eq!(source.raw_os_error(), Some(Errno::NOTDIR.raw_os_error()));
4069            }
4070            other => panic!("expected RootfsUnusable, got {other:?}"),
4071        }
4072    }
4073
4074    #[test]
4075    fn build_rejects_the_host_root_as_rootfs() {
4076        let err = sh_in("/").build().unwrap_err();
4077        assert!(matches!(err, Error::Config(ConfigError::RootfsIsHostRoot)));
4078    }
4079
4080    #[test]
4081    fn build_accepts_a_real_directory() {
4082        let cage = sh_in("/tmp").arg("-c").arg("true").build().unwrap();
4083        // The plan pins the canonicalized rootfs; its debug form names the
4084        // frozen program.
4085        assert!(format!("{cage:?}").contains("/bin/sh"));
4086    }
4087
4088    #[test]
4089    fn a_built_cage_clones_its_frozen_plan() {
4090        let cage = sh_in("/tmp").arg("-c").arg("true").build().unwrap();
4091        let clone = cage.clone();
4092        // The clone is an independent copy carrying the same frozen program
4093        // and arguments; the original is untouched and both remain usable.
4094        assert_eq!(clone.plan.program, cage.plan.program);
4095        assert_eq!(clone.plan.args, cage.plan.args);
4096        assert_eq!(clone.plan.rootfs_path, cage.plan.rootfs_path);
4097    }
4098
4099    #[test]
4100    fn clear_args_drops_prior_arguments_for_a_unit_override() {
4101        // The composition a profile-driven consumer performs: a builder that
4102        // already carries a command and arguments — as if deserialized from a
4103        // profile — has its command replaced as a unit. clear_args drops the
4104        // profile's arguments so the new command does not inherit them.
4105        let cage = sh_in("/tmp")
4106            .args(["-c", "profile default"])
4107            .clear_args()
4108            .command("/bin/echo")
4109            .arg("override")
4110            .build()
4111            .unwrap();
4112        assert!(format!("{cage:?}").contains("/bin/echo"));
4113        let args: Vec<&str> = cage
4114            .plan
4115            .args
4116            .iter()
4117            .map(|arg| arg.to_str().unwrap())
4118            .collect();
4119        assert_eq!(args, ["override"]);
4120    }
4121
4122    #[test]
4123    fn build_rejects_a_missing_bind_source() {
4124        let err = sh_in("/tmp")
4125            .bind("/ferroday-cage/no/such/source", "/data")
4126            .build()
4127            .unwrap_err();
4128        match err {
4129            Error::Config(ConfigError::BindSourceUnusable { source, .. }) => {
4130                assert_eq!(source.raw_os_error(), Some(Errno::NOENT.raw_os_error()));
4131            }
4132            other => panic!("expected BindSourceUnusable, got {other:?}"),
4133        }
4134    }
4135
4136    #[test]
4137    fn build_rejects_a_relative_bind_target() {
4138        let err = sh_in("/tmp").bind("/etc", "data").build().unwrap_err();
4139        assert!(matches!(
4140            err,
4141            Error::Config(ConfigError::MountTargetInvalid { .. })
4142        ));
4143    }
4144
4145    #[test]
4146    fn build_rejects_the_root_as_bind_target() {
4147        let err = sh_in("/tmp").bind("/etc", "/").build().unwrap_err();
4148        assert!(matches!(
4149            err,
4150            Error::Config(ConfigError::MountTargetInvalid { .. })
4151        ));
4152    }
4153
4154    #[test]
4155    fn build_rejects_dot_dot_in_a_bind_target() {
4156        let err = sh_in("/tmp")
4157            .bind("/etc", "/data/../escape")
4158            .build()
4159            .unwrap_err();
4160        assert!(matches!(
4161            err,
4162            Error::Config(ConfigError::MountTargetInvalid { .. })
4163        ));
4164    }
4165
4166    #[test]
4167    fn build_rejects_a_relative_workdir() {
4168        let err = sh_in("/tmp").current_dir("srv").build().unwrap_err();
4169        assert!(matches!(
4170            err,
4171            Error::Config(ConfigError::WorkdirNotAbsolute { .. })
4172        ));
4173    }
4174
4175    #[test]
4176    fn build_rejects_an_empty_hostname() {
4177        let err = sh_in("/tmp").hostname("").build().unwrap_err();
4178        assert!(matches!(err, Error::Config(ConfigError::HostnameInvalid)));
4179    }
4180
4181    #[test]
4182    fn build_rejects_an_overlong_hostname() {
4183        let err = sh_in("/tmp").hostname("h".repeat(65)).build().unwrap_err();
4184        assert!(matches!(err, Error::Config(ConfigError::HostnameInvalid)));
4185    }
4186
4187    #[test]
4188    fn build_rejects_an_env_name_with_equals() {
4189        let err = sh_in("/tmp").env("NAME=EXTRA", "v").build().unwrap_err();
4190        assert!(matches!(
4191            err,
4192            Error::Config(ConfigError::EnvNameInvalid { .. })
4193        ));
4194    }
4195
4196    #[test]
4197    fn the_environment_is_the_base_plus_overrides_sorted() {
4198        let cage = sh_in("/tmp")
4199            .env("ZED", "last")
4200            .env("PATH", "/custom")
4201            .build()
4202            .unwrap();
4203        let env: Vec<&str> = cage
4204            .plan
4205            .env
4206            .iter()
4207            .map(|entry| entry.to_str().unwrap())
4208            .collect();
4209        assert_eq!(env, ["HOME=/root", "PATH=/custom", "ZED=last"]);
4210    }
4211
4212    #[test]
4213    fn the_default_profile_mounts_proc_dev_and_tmp() {
4214        let cage = sh_in("/tmp").build().unwrap();
4215        assert!(cage.plan.op_labels.contains(&"/proc".to_string()));
4216        assert!(cage.plan.op_labels.contains(&"/dev".to_string()));
4217        assert!(cage.plan.op_labels.contains(&"/tmp".to_string()));
4218        // Isolated network: no resolv.conf bind.
4219        assert!(
4220            !cage
4221                .plan
4222                .op_labels
4223                .contains(&"/etc/resolv.conf".to_string())
4224        );
4225        assert!(cage.plan.configure_loopback);
4226    }
4227
4228    #[test]
4229    fn the_default_plan_isolates_pids_and_mounts_a_fresh_proc() {
4230        let cage = sh_in("/tmp").build().unwrap();
4231        assert!(cage.plan.pid_namespace);
4232        assert!(cage.plan.unshare.contains(UnshareFlags::NEWPID));
4233        assert!(matches!(cage.plan.stdin, StdioPlan::Inherit));
4234        assert!(!cage.plan.stop_with_caller);
4235        let proc_op = &cage.plan.ops[cage
4236            .plan
4237            .op_labels
4238            .iter()
4239            .position(|label| label == "/proc")
4240            .unwrap()];
4241        assert!(matches!(proc_op.action, MountAction::Procfs { .. }));
4242    }
4243
4244    #[test]
4245    fn without_a_pid_namespace_proc_is_a_bind_of_the_host_procfs() {
4246        let cage = sh_in("/tmp").pid_namespace(false).build().unwrap();
4247        assert!(!cage.plan.pid_namespace);
4248        assert!(!cage.plan.unshare.contains(UnshareFlags::NEWPID));
4249        let proc_op = &cage.plan.ops[cage
4250            .plan
4251            .op_labels
4252            .iter()
4253            .position(|label| label == "/proc")
4254            .unwrap()];
4255        assert!(matches!(proc_op.action, MountAction::Bind { .. }));
4256    }
4257
4258    #[test]
4259    fn stdin_and_lifetime_options_reach_the_plan() {
4260        let cage = sh_in("/tmp")
4261            .stdin(Stdio::Null)
4262            .stop_with_caller(true)
4263            .build()
4264            .unwrap();
4265        assert!(matches!(cage.plan.stdin, StdioPlan::Null));
4266        assert!(cage.plan.stop_with_caller);
4267    }
4268
4269    #[test]
4270    fn each_stream_lowers_into_its_own_plan_field() {
4271        // Three independent dispositions, so a sandbox that discards its
4272        // standard error keeps whatever the other two were set to.
4273        let cage = sh_in("/tmp")
4274            .stdin(Stdio::from_fd(open_null()))
4275            .stderr(Stdio::Null)
4276            .build()
4277            .unwrap();
4278        assert!(matches!(cage.plan.stdin, StdioPlan::Fd(_)));
4279        assert!(matches!(cage.plan.stdout, StdioPlan::Inherit));
4280        assert!(matches!(cage.plan.stderr, StdioPlan::Null));
4281    }
4282
4283    #[test]
4284    fn the_session_follows_standard_input_alone() {
4285        // The output pair is closed by its own disposition, not by the session,
4286        // so directing it elsewhere must not start one: a command that still
4287        // inherits standard input is still in the caller's session, where its
4288        // job control lives.
4289        let cage = sh_in("/tmp")
4290            .stdout(Stdio::Null)
4291            .stderr(Stdio::Null)
4292            .build()
4293            .unwrap();
4294        assert!(!cage.plan.own_session);
4295
4296        let cage = sh_in("/tmp").stdin(Stdio::Null).build().unwrap();
4297        assert!(cage.plan.own_session);
4298    }
4299
4300    #[test]
4301    fn a_capturing_launch_is_refused_against_a_directed_output_stream() {
4302        // Capture supplies a pipe, which is a destination; a plan naming one of
4303        // its own means the caller asked for two different things. Refused at
4304        // the launch rather than at build, because the same sandbox launches
4305        // without contradiction through an entry point that captures nothing —
4306        // and refused before anything is forked, which is what lets this assert
4307        // it without a rootfs to launch into.
4308        for builder in [
4309            sh_in("/tmp").stdout(Stdio::Null),
4310            sh_in("/tmp").stderr(Stdio::from_fd(open_null())),
4311        ] {
4312            let cage = builder.build().expect("the sandbox itself is valid");
4313            let err = cage.output().unwrap_err();
4314            assert!(
4315                matches!(
4316                    err,
4317                    Error::Config(ConfigError::StreamAttachmentConflict { .. })
4318                ),
4319                "{err:?}",
4320            );
4321        }
4322    }
4323
4324    #[test]
4325    fn a_capturing_launch_is_indifferent_to_standard_input() {
4326        // Capture never touches standard input, so no disposition of it
4327        // contradicts a capturing launch. The launch below gets as far as
4328        // trying to build a sandbox over `/tmp`, which is exactly the point:
4329        // whatever it fails with, it is not the stream refusal.
4330        let cage = sh_in("/tmp")
4331            .stdin(Stdio::from_fd(open_null()))
4332            .build()
4333            .unwrap();
4334        assert!(!matches!(
4335            cage.output(),
4336            Err(Error::Config(ConfigError::StreamAttachmentConflict { .. })),
4337        ));
4338    }
4339
4340    /// An owned descriptor for `/dev/null`, for the descriptor disposition.
4341    fn open_null() -> OwnedFd {
4342        std::fs::File::open("/dev/null")
4343            .expect("/dev/null is openable")
4344            .into()
4345    }
4346
4347    #[test]
4348    fn a_raw_mount_lowers_with_its_flags_in_declaration_order() {
4349        // Declared before the bind, so it is applied before the bind. The two
4350        // kinds share one sequence precisely so this is expressible.
4351        let cage = sh_in("/tmp")
4352            .raw_mount(
4353                RawMount::new("/raw")
4354                    .source("none")
4355                    .fstype("tmpfs")
4356                    .flags(0x6) // MS_NOSUID | MS_NODEV
4357                    .data("mode=0777"),
4358            )
4359            .bind("/etc", "/host-etc")
4360            .build()
4361            .unwrap();
4362        let raw_index = cage
4363            .plan
4364            .op_labels
4365            .iter()
4366            .position(|label| label == "raw mount at /raw")
4367            .unwrap();
4368        let bind_index = cage
4369            .plan
4370            .op_labels
4371            .iter()
4372            .position(|label| label.contains("/host-etc"))
4373            .unwrap();
4374        assert!(
4375            raw_index < bind_index,
4376            "the declared order is the applied order"
4377        );
4378        match &cage.plan.ops[raw_index].action {
4379            MountAction::Raw {
4380                source,
4381                fstype,
4382                flags,
4383                data,
4384                ..
4385            } => {
4386                assert_eq!(source.to_str().unwrap(), "none");
4387                assert_eq!(fstype.to_str().unwrap(), "tmpfs");
4388                assert_eq!(flags.bits(), 0x6);
4389                assert_eq!(data.to_str().unwrap(), "mode=0777");
4390            }
4391            other => panic!("expected a raw mount action, got {other:?}"),
4392        }
4393    }
4394
4395    #[test]
4396    fn a_raw_mount_target_is_validated_like_a_bind_target() {
4397        let err = sh_in("/tmp")
4398            .raw_mount(RawMount::new("relative"))
4399            .build()
4400            .unwrap_err();
4401        assert!(matches!(
4402            err,
4403            Error::Config(ConfigError::MountTargetInvalid { .. })
4404        ));
4405    }
4406
4407    #[test]
4408    fn raw_mount_flags_beyond_the_kernel_word_are_rejected() {
4409        let err = sh_in("/tmp")
4410            .raw_mount(RawMount::new("/raw").flags(1 << 40))
4411            .build()
4412            .unwrap_err();
4413        assert!(matches!(
4414            err,
4415            Error::Config(ConfigError::MountFlagsInvalid { flags }) if flags == 1 << 40
4416        ));
4417    }
4418
4419    #[test]
4420    fn a_nul_byte_in_raw_mount_data_is_rejected() {
4421        let err = sh_in("/tmp")
4422            .raw_mount(RawMount::new("/raw").fstype("tmpfs").data("mode\u{0}777"))
4423            .build()
4424            .unwrap_err();
4425        assert!(matches!(err, Error::Config(ConfigError::EmbeddedNul)));
4426    }
4427
4428    #[test]
4429    fn toggles_remove_the_profile_mounts() {
4430        // Each toggle removes its own mount; the procfs the nested user
4431        // namespace's map is established through comes back as the caller's
4432        // own, which is what `mount_proc(false)` now obliges them to supply.
4433        let cage = sh_in("/tmp")
4434            .mount_proc(false)
4435            .mount_dev(false)
4436            .mount_tmp(false)
4437            .bind("/proc", "/proc")
4438            .build()
4439            .unwrap();
4440        assert_eq!(cage.plan.op_labels.len(), 1, "{:?}", cage.plan.op_labels);
4441        assert!(cage.plan.op_labels[0].starts_with("/proc at /proc"));
4442    }
4443
4444    #[test]
4445    fn a_container_with_no_procfs_at_all_is_refused() {
4446        // The nested user namespace that locks the sandbox's mount flags
4447        // establishes its identity map through a procfs, by path, from inside
4448        // the sandbox. A profile that mounts none is refused here rather than
4449        // failing at launch on a path nobody asked for.
4450        let err = sh_in("/tmp").mount_proc(false).build().unwrap_err();
4451        assert!(matches!(
4452            err,
4453            Error::Config(ConfigError::NestedUsernsNeedsProcfs),
4454        ));
4455    }
4456
4457    #[test]
4458    fn a_covered_procfs_does_not_hide_one_that_is_still_standing() {
4459        // The ops run in order, so a later mount can bury an earlier one, and
4460        // the scan follows the same order. What it must not do is track only
4461        // the most recent candidate: this profile mounts a procfs, mounts a
4462        // second, and covers the second — and the first is still there to
4463        // establish the map through.
4464        let cage = sh_in("/tmp")
4465            .mount_proc(false)
4466            .raw_mount(RawMount::new("/proc").fstype("proc"))
4467            .raw_mount(RawMount::new("/mnt").fstype("proc"))
4468            .raw_mount(RawMount::new("/mnt").fstype("tmpfs"))
4469            .build()
4470            .expect("the first procfs is still visible");
4471        let nested = cage.plan.nested.as_ref().expect("a container nests");
4472        let crate::mechanism::NestedMap::SelfWritten { uid_map_path, .. } = &nested.map else {
4473            panic!("this tier writes its own map: {:?}", nested.map);
4474        };
4475        // Composed from the procfs that is still standing, not from the one
4476        // the tmpfs came to rest on.
4477        assert_eq!(uid_map_path.to_bytes(), b"/proc/self/uid_map");
4478    }
4479
4480    #[test]
4481    fn a_raw_mount_that_binds_is_a_bind_whatever_it_calls_its_filesystem() {
4482        // `mount(2)` ignores `fstype` when `MS_BIND` is set, so a raw op
4483        // carrying that flag mounts whatever its source already is. Reading it
4484        // by the name it gives would have this configuration satisfy the
4485        // fresh-procfs requirement it does not meet, and the failure would
4486        // arrive at launch rather than here.
4487        let err = sh_in("/tmp")
4488            .mount_proc(false)
4489            .identity_map(IdentityMap::ranges(
4490                vec![crate::IdRange {
4491                    inside: 0,
4492                    outside: 1000,
4493                    count: 1,
4494                }],
4495                vec![crate::IdRange {
4496                    inside: 0,
4497                    outside: 1000,
4498                    count: 1,
4499                }],
4500            ))
4501            .id_mapper(FakeMapper)
4502            .raw_mount(
4503                RawMount::new("/proc")
4504                    .source("/proc")
4505                    .fstype("proc")
4506                    .flags(MountFlags::BIND.bits().into()),
4507            )
4508            .build()
4509            .unwrap_err();
4510        assert!(
4511            matches!(err, Error::Config(ConfigError::NestedUsernsNeedsProcfs)),
4512            "{err:?}",
4513        );
4514    }
4515
4516    #[test]
4517    fn a_delegated_map_outside_a_pid_namespace_needs_no_procfs() {
4518        // The requirement follows the route the map actually takes. A range map
4519        // without a PID namespace is written by the launch stage, outside the
4520        // sandbox, through the host's own `/proc` — opened before the pivot,
4521        // and never named through the profile — so a profile that mounts no
4522        // procfs serves it. Refusing this configuration would rule out
4523        // `managed_mounts(false)`, the reproducibility opt-out, alongside a
4524        // range map for no reason the code has.
4525        let cage = sh_in("/tmp")
4526            .mount_proc(false)
4527            .pid_namespace(false)
4528            .identity_map(IdentityMap::ranges(
4529                vec![crate::IdRange {
4530                    inside: 0,
4531                    outside: 1000,
4532                    count: 1,
4533                }],
4534                vec![crate::IdRange {
4535                    inside: 0,
4536                    outside: 1000,
4537                    count: 1,
4538                }],
4539            ))
4540            .id_mapper(FakeMapper)
4541            .build()
4542            .expect("no procfs is needed where no in-sandbox delegate reads one");
4543        let nested = cage.plan.nested.as_ref().expect("a container nests");
4544        assert!(matches!(
4545            nested.map,
4546            crate::mechanism::NestedMap::Delegated {
4547                procfs_target: None
4548            },
4549        ));
4550    }
4551
4552    #[test]
4553    fn a_ranged_map_in_a_pid_namespace_will_not_take_a_bound_procfs() {
4554        // The sandbox's own delegate names the command's map files by the pid
4555        // its `fork` returned, which is a pid of the sandbox's PID namespace. A
4556        // bind of the host's procfs does not index that namespace, so it cannot
4557        // serve — while the same bind serves the single-identity tier, whose
4558        // command reaches its own map through `self`.
4559        let ranged = || {
4560            sh_in("/tmp")
4561                .managed_mounts(false)
4562                .bind("/proc", "/proc")
4563                .identity_map(IdentityMap::ranges(
4564                    vec![crate::IdRange {
4565                        inside: 0,
4566                        outside: 1000,
4567                        count: 1,
4568                    }],
4569                    vec![crate::IdRange {
4570                        inside: 0,
4571                        outside: 1000,
4572                        count: 1,
4573                    }],
4574                ))
4575                .id_mapper(FakeMapper)
4576        };
4577        assert!(matches!(
4578            ranged().build().unwrap_err(),
4579            Error::Config(ConfigError::NestedUsernsNeedsProcfs),
4580        ));
4581        // Without a PID namespace of its own the sandbox's pids are the
4582        // caller's, and the bind indexes exactly the processes the delegate
4583        // names.
4584        ranged()
4585            .pid_namespace(false)
4586            .build()
4587            .expect("a bound procfs serves where the pids agree");
4588    }
4589
4590    #[test]
4591    fn denied_network_isolates_but_leaves_loopback_down() {
4592        let cage = sh_in("/tmp").network(Network::None).build().unwrap();
4593        assert!(cage.plan.unshare.contains(UnshareFlags::NEWNET));
4594        assert!(!cage.plan.configure_loopback);
4595        assert!(
4596            !cage
4597                .plan
4598                .op_labels
4599                .contains(&"/etc/resolv.conf".to_string())
4600        );
4601    }
4602
4603    /// A test delegate resolving whatever it is asked, recording nothing:
4604    /// the subordinate form resolves to a fixed allocation-shaped map.
4605    #[derive(Debug)]
4606    struct FakeMapper;
4607
4608    impl crate::IdMapper for FakeMapper {
4609        fn resolve(&self, request: &IdentityMap) -> Result<crate::ResolvedMap, crate::IdMapError> {
4610            let fixed = |own| {
4611                vec![
4612                    crate::IdRange {
4613                        inside: 0,
4614                        outside: own,
4615                        count: 1,
4616                    },
4617                    crate::IdRange {
4618                        inside: 1,
4619                        outside: 100000,
4620                        count: 65536,
4621                    },
4622                ]
4623            };
4624            Ok(match request {
4625                IdentityMap::Ranges { uid, gid, .. } => {
4626                    crate::ResolvedMap::new(uid.clone(), gid.clone())
4627                }
4628                _ => crate::ResolvedMap::new(fixed(1000), fixed(1000)),
4629            })
4630        }
4631
4632        fn apply(&self, _pid: u32, _map: &crate::ResolvedMap) -> Result<(), crate::IdMapError> {
4633            Ok(())
4634        }
4635    }
4636
4637    fn subordinate_cage() -> CageBuilder {
4638        sh_in("/tmp")
4639            .identity_map(IdentityMap::Subordinate)
4640            .id_mapper(FakeMapper)
4641    }
4642
4643    #[test]
4644    fn the_default_identity_plan_is_the_single_map() {
4645        let cage = sh_in("/tmp").build().unwrap();
4646        assert!(matches!(cage.plan.identity, IdentityPlan::Single));
4647        assert!(cage.plan.run_as.is_none());
4648    }
4649
4650    #[test]
4651    fn a_delegate_resolved_map_reaches_the_plan() {
4652        let cage = subordinate_cage().build().unwrap();
4653        match &cage.plan.identity {
4654            IdentityPlan::Ranged { map, .. } => {
4655                assert_eq!(map.uid().len(), 2);
4656                assert_eq!(map.uid()[0].outside, 1000);
4657                assert_eq!(map.gid()[1].count, 65536);
4658            }
4659            other => panic!("expected a ranged identity plan, got {other:?}"),
4660        }
4661    }
4662
4663    #[test]
4664    fn an_invalid_range_request_is_rejected_before_any_delegate() {
4665        let err = sh_in("/tmp")
4666            .identity_map(IdentityMap::ranges(vec![], vec![]))
4667            .id_mapper(FakeMapper)
4668            .build()
4669            .unwrap_err();
4670        assert!(matches!(
4671            err,
4672            Error::Config(ConfigError::IdentityMapInvalid { .. })
4673        ));
4674    }
4675
4676    #[test]
4677    fn an_unsatisfiable_range_request_is_a_config_error() {
4678        // Without a configured delegate, the bundled chain must refuse: the
4679        // direct mapper requires capabilities an unprivileged test run does
4680        // not hold. Skip silently when the test itself runs privileged.
4681        let effective = rustix::thread::capabilities(None)
4682            .map(|sets| sets.effective)
4683            .unwrap_or(rustix::thread::CapabilitySet::empty());
4684        if effective.contains(rustix::thread::CapabilitySet::SETUID) {
4685            return;
4686        }
4687        let extent = crate::IdRange {
4688            inside: 0,
4689            outside: 4_000_000_000,
4690            count: 1,
4691        };
4692        let request = IdentityMap::ranges(vec![extent], vec![extent]);
4693        let err = sh_in("/tmp").identity_map(request).build().unwrap_err();
4694        assert!(matches!(
4695            err,
4696            Error::Config(ConfigError::IdentityMapUnavailable { .. })
4697        ));
4698    }
4699
4700    #[test]
4701    fn run_as_requires_a_mapped_uid() {
4702        let err = subordinate_cage()
4703            .run_as(Identity::new(70000, 0))
4704            .build()
4705            .unwrap_err();
4706        assert!(matches!(
4707            err,
4708            Error::Config(ConfigError::RunAsUnmapped {
4709                id: 70000,
4710                space: "uid"
4711            })
4712        ));
4713    }
4714
4715    #[test]
4716    fn run_as_requires_a_mapped_gid_and_groups() {
4717        let err = subordinate_cage()
4718            .run_as(Identity::new(250, 70000))
4719            .build()
4720            .unwrap_err();
4721        assert!(matches!(
4722            err,
4723            Error::Config(ConfigError::RunAsUnmapped {
4724                id: 70000,
4725                space: "gid"
4726            })
4727        ));
4728
4729        let err = subordinate_cage()
4730            .run_as(Identity::new(250, 250).groups([250, 70000]))
4731            .build()
4732            .unwrap_err();
4733        assert!(matches!(
4734            err,
4735            Error::Config(ConfigError::RunAsUnmapped {
4736                id: 70000,
4737                space: "supplementary group"
4738            })
4739        ));
4740    }
4741
4742    #[test]
4743    fn a_mapped_run_as_freezes_into_the_plan() {
4744        let cage = subordinate_cage()
4745            .run_as(Identity::new(250, 250).groups([250, 100]))
4746            .build()
4747            .unwrap();
4748        let run_as = cage.plan.run_as.as_ref().unwrap();
4749        assert_eq!(run_as.uid.as_raw(), 250);
4750        assert_eq!(run_as.gid.as_raw(), 250);
4751        let groups: Vec<u32> = run_as.groups.iter().map(|group| group.as_raw()).collect();
4752        assert_eq!(groups, [250, 100]);
4753    }
4754
4755    #[test]
4756    fn run_as_root_is_valid_under_the_single_map() {
4757        let cage = sh_in("/tmp").run_as(Identity::new(0, 0)).build().unwrap();
4758        let run_as = cage.plan.run_as.as_ref().unwrap();
4759        assert_eq!(run_as.uid.as_raw(), 0);
4760    }
4761
4762    #[test]
4763    fn run_as_nonroot_under_the_single_map_is_unmapped() {
4764        let err = sh_in("/tmp")
4765            .run_as(Identity::new(250, 250).groups([250]))
4766            .build()
4767            .unwrap_err();
4768        assert!(matches!(
4769            err,
4770            Error::Config(ConfigError::RunAsUnmapped { id: 250, .. })
4771        ));
4772    }
4773
4774    #[test]
4775    fn run_as_groups_under_the_single_map_are_rejected() {
4776        // Group 0 is mapped, but establishing the single-identity map denies
4777        // setgroups, so no group list can ever be applied inside it.
4778        let err = sh_in("/tmp")
4779            .run_as(Identity::new(0, 0).groups([0]))
4780            .build()
4781            .unwrap_err();
4782        assert!(matches!(
4783            err,
4784            Error::Config(ConfigError::RunAsGroupsWithSingleMap)
4785        ));
4786    }
4787
4788    #[cfg(feature = "hardening")]
4789    #[test]
4790    fn a_kept_setid_capability_with_nonroot_run_as_is_rejected() {
4791        use crate::hardening::Capability;
4792        let err = subordinate_cage()
4793            .run_as(Identity::new(250, 250))
4794            .keep_capabilities([Capability::NetBindService, Capability::Setuid])
4795            .build()
4796            .unwrap_err();
4797        assert!(matches!(
4798            err,
4799            Error::Config(ConfigError::SetidCapWithNonRootIdentity {
4800                capability: "CAP_SETUID"
4801            })
4802        ));
4803    }
4804
4805    #[cfg(feature = "hardening")]
4806    #[test]
4807    fn nonroot_run_as_with_kept_caps_sets_the_securebits() {
4808        use crate::hardening::Capability;
4809        let cage = subordinate_cage()
4810            .run_as(Identity::new(250, 250))
4811            .keep_capabilities([Capability::NetBindService])
4812            .build()
4813            .unwrap();
4814        assert!(cage.plan.hardening.set_securebits);
4815        // drop_all is an explicitly empty keep set: its post-switch
4816        // bounding-set narrowing needs the securebits too.
4817        let cage = subordinate_cage()
4818            .run_as(Identity::new(250, 250))
4819            .drop_all_capabilities()
4820            .build()
4821            .unwrap();
4822        assert!(cage.plan.hardening.set_securebits);
4823    }
4824
4825    #[cfg(feature = "hardening")]
4826    #[test]
4827    fn the_securebits_stay_clear_without_run_as_or_without_kept_caps() {
4828        use crate::hardening::Capability;
4829        // Kept capabilities under the root identity: no switch, no bits.
4830        let cage = sh_in("/tmp")
4831            .keep_capabilities([Capability::NetBindService])
4832            .build()
4833            .unwrap();
4834        assert!(!cage.plan.hardening.set_securebits);
4835        // A non-root identity without a keep request: the kernel clearing
4836        // the capabilities across the switch is the correct outcome.
4837        let cage = subordinate_cage()
4838            .run_as(Identity::new(250, 250))
4839            .build()
4840            .unwrap();
4841        assert!(!cage.plan.hardening.set_securebits);
4842    }
4843
4844    /// Whether the plan holds the managed `resolv.conf` bind.
4845    fn binds_resolv_conf(cage: &Cage) -> bool {
4846        cage.plan
4847            .op_labels
4848            .contains(&"/etc/resolv.conf".to_string())
4849    }
4850
4851    /// `path` expressed relative to the process's working directory, for a
4852    /// test that needs a root the caller named relatively. Both are absolute
4853    /// and physical — `current_dir` reports the kernel's own answer — so
4854    /// ascending to the root and descending again names the same directory.
4855    fn relative_to_working_dir(path: &Path) -> PathBuf {
4856        let working = std::env::current_dir().expect("a working directory");
4857        let mut relative = PathBuf::new();
4858        for _ in working.components().skip(1) {
4859            relative.push("..");
4860        }
4861        relative.extend(path.components().skip(1));
4862        relative
4863    }
4864
4865    /// Whether the host end of the `resolv.conf` bind is available at all; the
4866    /// rootfs end is what each case below varies.
4867    fn host_has_resolv_conf() -> bool {
4868        std::fs::canonicalize("/etc/resolv.conf")
4869            .map(|path| path.is_file())
4870            .unwrap_or(false)
4871    }
4872
4873    #[test]
4874    fn host_network_skips_the_loopback_and_may_bind_resolv_conf() {
4875        let cage = sh_in("/tmp").network(Network::Host).build().unwrap();
4876        assert!(!cage.plan.configure_loopback);
4877        assert_eq!(binds_resolv_conf(&cage), host_has_resolv_conf());
4878
4879        let cage = sh_in("/tmp")
4880            .network(Network::Host)
4881            .resolv_conf(false)
4882            .build()
4883            .unwrap();
4884        assert!(!binds_resolv_conf(&cage));
4885        assert!(cage.plan.managed_placeholders.is_empty());
4886    }
4887
4888    #[test]
4889    fn a_callers_own_resolv_conf_mount_replaces_the_managed_one() {
4890        // The caller's mounts go last so their order is theirs, and a managed
4891        // mount pushed after them takes that back for the one target they were
4892        // most deliberate about: the sandbox's DNS would be the host's however
4893        // explicitly they said otherwise.
4894        if !host_has_resolv_conf() {
4895            return;
4896        }
4897        let dir = Scratch::new("resolv-replaced");
4898        std::fs::create_dir_all(dir.join("etc")).unwrap();
4899        std::fs::write(dir.join("etc/hosts"), b"127.0.0.1 localhost\n").unwrap();
4900        let rootfs = dir.to_str().unwrap();
4901
4902        let cage = sh_in(rootfs)
4903            .network(Network::Host)
4904            .bind_ro(dir.join("etc/hosts"), "/etc/resolv.conf")
4905            .build()
4906            .unwrap();
4907        let binds: Vec<&String> = cage
4908            .plan
4909            .op_labels
4910            .iter()
4911            .filter(|label| label.contains("/etc/resolv.conf"))
4912            .collect();
4913        assert_eq!(binds.len(), 1, "one bind at the target, not two: {binds:?}");
4914        assert!(
4915            binds[0].contains("etc/hosts"),
4916            "the caller's own bind is the one in force: {binds:?}",
4917        );
4918        // Written a way the mount target reader normalizes, and the same
4919        // target, so the managed bind is still replaced.
4920        let cage = sh_in(rootfs)
4921            .network(Network::Host)
4922            .bind_ro(dir.join("etc/hosts"), "/etc/./resolv.conf")
4923            .build()
4924            .unwrap();
4925        assert_eq!(
4926            cage.plan
4927                .op_labels
4928                .iter()
4929                .filter(|label| label.contains("resolv.conf"))
4930                .count(),
4931            1,
4932        );
4933
4934        // The same claim spelled through the raw escape hatch. `RawMount`
4935        // carries a source and the kernel's own flags, so a bind is expressible
4936        // there too -- and a caller who reaches for it has been more explicit
4937        // about the target than one who called `bind_ro`, not less.
4938        let cage = sh_in(rootfs)
4939            .network(Network::Host)
4940            .raw_mount(
4941                RawMount::new("/etc/resolv.conf")
4942                    .source(dir.join("etc/hosts"))
4943                    .flags(0x1000), // MS_BIND
4944            )
4945            .build()
4946            .unwrap();
4947        assert_eq!(
4948            cage.plan
4949                .op_labels
4950                .iter()
4951                .filter(|label| label.contains("resolv.conf"))
4952                .count(),
4953            1,
4954            "a raw mount at the target replaces the managed bind as a bind does",
4955        );
4956    }
4957
4958    #[test]
4959    fn a_resolv_conf_target_the_launch_creates_is_recorded_for_removal() {
4960        // A bind needs its target to exist, so a rootfs shipping no resolv.conf
4961        // has one created. The plan records that path, which is how the caller
4962        // side knows to take it away once the sandbox is gone. A rootfs that
4963        // ships the file has nothing created and so nothing to remove.
4964        if !host_has_resolv_conf() {
4965            return;
4966        }
4967        let dir = Scratch::new("resolv");
4968        std::fs::create_dir_all(dir.join("etc")).unwrap();
4969        let rootfs = dir.to_str().unwrap();
4970
4971        let created = sh_in(rootfs).network(Network::Host).build().unwrap();
4972        assert!(binds_resolv_conf(&created));
4973        let canonical = std::fs::canonicalize(&dir).unwrap();
4974        assert_eq!(
4975            (
4976                created.plan.managed_placeholders.root.as_path(),
4977                created.plan.managed_placeholders.paths.as_slice(),
4978            ),
4979            (canonical.as_path(), &[PathBuf::from("etc/resolv.conf")][..]),
4980            "a target the launch creates must be recorded for removal"
4981        );
4982
4983        // The same rootfs named relative to the working directory records the
4984        // same canonical path. The removal happens whenever the handle drops,
4985        // which need not be in the directory the cage was built in, so a
4986        // relative path recorded here would later resolve somewhere else.
4987        let relative = relative_to_working_dir(&std::fs::canonicalize(&dir).unwrap());
4988        let named_relatively = sh_in(relative.to_str().unwrap())
4989            .network(Network::Host)
4990            .build()
4991            .unwrap();
4992        assert_eq!(
4993            named_relatively.plan.managed_placeholders.root, canonical,
4994            "a relatively named rootfs must still record an absolute root"
4995        );
4996
4997        std::fs::write(dir.join("etc/resolv.conf"), b"nameserver 192.0.2.1\n").unwrap();
4998        let shipped = sh_in(rootfs).network(Network::Host).build().unwrap();
4999        assert!(binds_resolv_conf(&shipped));
5000        assert!(
5001            shipped.plan.managed_placeholders.is_empty(),
5002            "a file the rootfs ships is not the sandbox's to remove"
5003        );
5004
5005        // An isolated network never binds it, so it never creates one either.
5006        let isolated = sh_in(rootfs).build().unwrap();
5007        assert!(!binds_resolv_conf(&isolated));
5008        assert!(isolated.plan.managed_placeholders.is_empty());
5009    }
5010
5011    #[test]
5012    fn an_overlay_root_records_the_placeholder_in_the_upper() {
5013        // The bind's target is created inside the pivoted root, which for an
5014        // overlay cage is the merged view, so the file lands in the upper. A
5015        // placeholder recorded against the lower would name a path that never
5016        // exists — a silent no-op leaving the upper with a resolver
5017        // configuration the base never had, which an export of that upper
5018        // carries onward.
5019        if !host_has_resolv_conf() {
5020            return;
5021        }
5022        let dir = Scratch::new("resolv-overlay");
5023        let base = dir.join("base");
5024        let upper = dir.join("upper");
5025        std::fs::create_dir_all(base.join("etc")).unwrap();
5026
5027        let built = Cage::builder()
5028            .overlay_rootfs(&base, &upper)
5029            .network(Network::Host)
5030            .command("/bin/sh")
5031            .build();
5032        let Ok(cage) = built else {
5033            // A host that cannot establish an unprivileged overlay refuses the
5034            // build, so there is no plan to assert against.
5035            assert!(
5036                std::env::var_os("FERRODAY_CAGE_REQUIRE_OVERLAY").is_none(),
5037                "overlay-rooted cages are required but unavailable here"
5038            );
5039            eprintln!("skipping: an unprivileged overlay is unavailable here");
5040            return;
5041        };
5042        assert!(binds_resolv_conf(&cage));
5043        assert_eq!(
5044            (
5045                cage.plan.managed_placeholders.root.as_path(),
5046                cage.plan.managed_placeholders.paths.as_slice(),
5047            ),
5048            (
5049                std::fs::canonicalize(&upper).unwrap().as_path(),
5050                &[PathBuf::from("etc/resolv.conf")][..],
5051            ),
5052            "an overlay root's placeholder belongs to the upper"
5053        );
5054
5055        // A file any layer ships is not the sandbox's to create or remove,
5056        // even though the layer is not the one writes land in.
5057        std::fs::write(base.join("etc/resolv.conf"), b"nameserver 192.0.2.1\n").unwrap();
5058        let shipped = Cage::builder()
5059            .overlay_rootfs(&base, &upper)
5060            .network(Network::Host)
5061            .command("/bin/sh")
5062            .build()
5063            .unwrap();
5064        assert!(shipped.plan.managed_placeholders.is_empty());
5065    }
5066
5067    #[test]
5068    fn a_caller_file_bind_target_the_launch_creates_is_recorded_for_removal() {
5069        // A caller's file bind creates its target the same way the managed
5070        // resolv.conf bind does, and leaves the same residue if nothing takes
5071        // it away — an empty file the root never shipped, carried into an
5072        // export of the tree.
5073        let dir = Scratch::new("bind-placeholder");
5074        let rootfs = dir.join("rootfs");
5075        let source = dir.join("source");
5076        std::fs::create_dir_all(rootfs.join("etc")).unwrap();
5077        std::fs::create_dir_all(dir.join("host-etc")).unwrap();
5078        std::fs::write(&source, b"contents").unwrap();
5079        std::fs::write(rootfs.join("etc/shipped"), b"already here").unwrap();
5080        let canonical = std::fs::canonicalize(&rootfs).unwrap();
5081        let placeholders =
5082            |builder: CageBuilder| builder.build().unwrap().plan.managed_placeholders;
5083
5084        // The paths are recorded relative to the root's writable layer, and
5085        // resolved against a descriptor for it when the time comes to remove
5086        // them; the root is named once, canonically.
5087        let recorded = placeholders(
5088            Cage::builder()
5089                .rootfs(&rootfs)
5090                .command("/bin/sh")
5091                .bind_ro(&source, "/etc/created"),
5092        );
5093        assert_eq!(recorded.root, canonical);
5094        assert_eq!(recorded.paths, [Path::new("etc/created")]);
5095
5096        // A target the rootfs ships is not the launch's to create or remove.
5097        let cage = Cage::builder()
5098            .rootfs(&rootfs)
5099            .command("/bin/sh")
5100            .bind_ro(&source, "/etc/shipped");
5101        assert!(placeholders(cage).is_empty());
5102
5103        // A symbolic link is an entry the rootfs ships, dangling or not: the
5104        // launch creates the target with `O_EXCL` and so leaves it alone. Read
5105        // as an absent file, a dangling one would record a placeholder for a
5106        // path the launch never wrote to, while the file it did create — at the
5107        // link's destination — went unrecorded and stayed.
5108        std::os::unix::fs::symlink("../var/absent", rootfs.join("etc/linked")).unwrap();
5109        std::os::unix::fs::symlink("shipped", rootfs.join("etc/resolvable")).unwrap();
5110        for target in ["/etc/linked", "/etc/resolvable"] {
5111            let cage = Cage::builder()
5112                .rootfs(&rootfs)
5113                .command("/bin/sh")
5114                .bind_ro(&source, target);
5115            assert!(
5116                placeholders(cage).is_empty(),
5117                "{target} is an entry the rootfs ships"
5118            );
5119        }
5120
5121        // A target under an earlier mount is created inside that mount, not in
5122        // the rootfs directory on the host: in the managed tmpfs on /tmp, which
5123        // vanishes with the namespace, and in the caller's own bind source,
5124        // which is theirs.
5125        let cage = Cage::builder()
5126            .rootfs(&rootfs)
5127            .command("/bin/sh")
5128            .bind_ro(&source, "/tmp/under-a-tmpfs")
5129            .bind(dir.join("host-etc"), "/etc")
5130            .bind_ro(&source, "/etc/under-a-bind");
5131        assert!(placeholders(cage).is_empty());
5132
5133        // A directory bind creates no file, so it contributes no placeholder;
5134        // the mount-point directory it creates stays.
5135        let cage = Cage::builder()
5136            .rootfs(&rootfs)
5137            .command("/bin/sh")
5138            .bind_ro(dir.join("host-etc"), "/mnt/data");
5139        assert!(placeholders(cage).is_empty());
5140    }
5141
5142    #[test]
5143    fn a_root_internal_absolute_symlink_is_judged_inside_the_root() {
5144        // A rootfs may hold an absolute symbolic link — an unpacked tarball is
5145        // entitled to ship `./absolute -> /etc`, and any image taken from a
5146        // systemd-resolved host ships `/etc/resolv.conf` as one. The launch
5147        // resolves its mount targets against the new-root descriptor with
5148        // RESOLVE_IN_ROOT, so such a link lands inside the rootfs; the
5149        // bookkeeping here has to agree, or it records a path the launch never
5150        // wrote to and answers a question about the host's filesystem instead.
5151        let dir = Scratch::new("placeholder-in-root");
5152        let rootfs = dir.join("rootfs");
5153        let source = dir.join("source");
5154        std::fs::create_dir_all(rootfs.join("real")).unwrap();
5155        std::fs::write(&source, b"contents").unwrap();
5156        // `/absolute` inside the rootfs points at `/real` inside the rootfs.
5157        std::os::unix::fs::symlink("/real", rootfs.join("absolute")).unwrap();
5158        // The same name exists on the host and holds a file, so a judgement
5159        // made against the host's root would read the target as already
5160        // shipped and record nothing.
5161        std::fs::create_dir_all(dir.join("host-side/real")).unwrap();
5162        std::fs::write(dir.join("host-side/real/entry"), b"host").unwrap();
5163
5164        let placeholders =
5165            |builder: CageBuilder| builder.build().unwrap().plan.managed_placeholders;
5166        let recorded = placeholders(
5167            Cage::builder()
5168                .rootfs(&rootfs)
5169                .command("/bin/sh")
5170                .bind_ro(&source, "/absolute/entry"),
5171        );
5172        assert_eq!(
5173            recorded.paths,
5174            [Path::new("absolute/entry")],
5175            "a target behind a root-internal absolute symlink is the launch's to create"
5176        );
5177
5178        // And once the rootfs ships it — at the path the link resolves to
5179        // inside the rootfs — there is nothing for the launch to create.
5180        std::fs::write(rootfs.join("real/entry"), b"shipped").unwrap();
5181        assert!(
5182            placeholders(
5183                Cage::builder()
5184                    .rootfs(&rootfs)
5185                    .command("/bin/sh")
5186                    .bind_ro(&source, "/absolute/entry"),
5187            )
5188            .is_empty(),
5189            "the link must resolve inside the rootfs, where the entry now is"
5190        );
5191    }
5192
5193    #[test]
5194    fn a_managed_mount_point_carries_the_mode_of_the_directory_it_stands_for() {
5195        // The mount hides the mode for as long as it is established, but the
5196        // mount point outlives the sandbox: a `/tmp` the sandbox creates must be
5197        // the 1777 directory `/tmp` is, not a 0755 one nothing unprivileged can
5198        // write to.
5199        let cage = sh_in("/tmp").build().unwrap();
5200        let mode_of = |target: &str| -> RawMode {
5201            let (op, _) = cage
5202                .plan
5203                .ops
5204                .iter()
5205                .zip(&cage.plan.op_labels)
5206                .find(|(_, label)| label.as_str() == target)
5207                .unwrap_or_else(|| panic!("no {target} op in {:?}", cage.plan.op_labels));
5208            op.dirs.last().expect("a mount point to create").mode
5209        };
5210        assert_eq!(mode_of("/tmp"), 0o1777);
5211        assert_eq!(mode_of("/dev/shm"), 0o1777);
5212        assert_eq!(mode_of("/dev"), 0o755);
5213        assert_eq!(mode_of("/proc"), 0o755);
5214        // devpts's `mode=` is the mode of the pseudo-terminals it allocates,
5215        // not of its own root, so its mount point is an ordinary directory.
5216        assert_eq!(mode_of("/dev/pts"), 0o755);
5217
5218        // An ancestor created on the way to a mount point is an ordinary
5219        // directory whatever the mount point itself is.
5220        let cage = sh_in("/tmp")
5221            .bind("/etc/hostname", "/a/b/hostname")
5222            .build()
5223            .expect("a file bind into fresh directories builds");
5224        let (op, _) = cage
5225            .plan
5226            .ops
5227            .iter()
5228            .zip(&cage.plan.op_labels)
5229            .find(|(_, label)| label.ends_with("at /a/b/hostname"))
5230            .expect("the bind op");
5231        assert_eq!(op.dirs.len(), 2, "both /a and /a/b are created");
5232        assert!(op.dirs.iter().all(|dir| dir.mode == 0o755));
5233    }
5234}
5235
5236#[cfg(all(test, feature = "serde"))]
5237mod serde_tests {
5238    use super::*;
5239    use crate::scratch::Scratch;
5240
5241    /// A profile naming every field the format has, for the key-pinning test
5242    /// below. Nothing here is a meaningful configuration; it exists so that
5243    /// serializing it renders every key.
5244    ///
5245    /// The `hardening` feature is required, and not because the test wants it:
5246    /// a build without it refuses a profile that configures hardening, which is
5247    /// a promise of its own that
5248    /// [`a_hardening_profile_is_rejected_without_the_feature`] holds. A profile
5249    /// naming every key therefore only parses where every key exists.
5250    #[cfg(feature = "hardening")]
5251    const EVERY_KEY_PROFILE: &str = r#"
5252rootfs = "/srv/rootfs"
5253command = "/bin/sh"
5254args = ["-c", "true"]
5255network = "host"
5256hostname = "builder"
5257workdir = "/build"
5258stdin = "null"
5259stdout = "inherit"
5260stderr = "null"
5261pid-namespace = false
5262managed-mounts = true
5263mount-proc = true
5264mount-dev = false
5265mount-tmp = true
5266resolv-conf = false
5267base-env = false
5268stop-with-caller = true
5269path-lookup = true
5270run-as = { uid = 1000, gid = 1000, groups = [10, 20] }
5271
5272[identity-map.ranges]
5273uid = [{ inside = 0, outside = 100000, count = 65536 }]
5274gid = [{ inside = 0, outside = 100000, count = 65536 }]
5275
5276[overlay]
5277lower = ["/srv/base"]
5278upper = "/var/tmp/upper"
5279work = "/var/tmp/work"
5280
5281[rlimit]
5282processes = 64
5283cpu-time = { soft = 10, hard = "unlimited" }
5284
5285[env]
5286PATH = "/usr/bin"
5287
5288[[mount]]
5289kind = "bind"
5290source = "/etc"
5291target = "/host-etc"
5292read-only = true
5293
5294[[mount]]
5295kind = "raw"
5296target = "/raw"
5297fstype = "tmpfs"
5298flags = 6
5299data = "mode=0777"
5300
5301[hardening]
5302seccomp = "curated"
5303keep-caps = ["net-bind-service"]
5304
5305[[hardening.landlock-fs]]
5306path = "/usr"
5307access = "rx"
5308
5309[[hardening.landlock-net]]
5310port = 443
5311access = "c"
5312"#;
5313
5314    /// The keys the exhaustive profile above renders, in sorted order.
5315    #[cfg(feature = "hardening")]
5316    const EXPECTED_PROFILE_KEYS: [&str; 60] = [
5317        "args",
5318        "base-env",
5319        "command",
5320        "env",
5321        "env.PATH",
5322        "hardening",
5323        "hardening.keep-caps",
5324        "hardening.landlock-fs",
5325        "hardening.landlock-fs[].access",
5326        "hardening.landlock-fs[].path",
5327        "hardening.landlock-net",
5328        "hardening.landlock-net[].access",
5329        "hardening.landlock-net[].port",
5330        "hardening.seccomp",
5331        "hostname",
5332        "identity-map",
5333        "identity-map.ranges",
5334        "identity-map.ranges.gid",
5335        "identity-map.ranges.gid[].count",
5336        "identity-map.ranges.gid[].inside",
5337        "identity-map.ranges.gid[].outside",
5338        "identity-map.ranges.uid",
5339        "identity-map.ranges.uid[].count",
5340        "identity-map.ranges.uid[].inside",
5341        "identity-map.ranges.uid[].outside",
5342        "managed-mounts",
5343        "mount",
5344        "mount-dev",
5345        "mount-proc",
5346        "mount-tmp",
5347        "mount[].data",
5348        "mount[].flags",
5349        "mount[].fstype",
5350        "mount[].kind",
5351        "mount[].read-only",
5352        "mount[].source",
5353        "mount[].target",
5354        "network",
5355        "overlay",
5356        "overlay.lower",
5357        "overlay.upper",
5358        "overlay.work",
5359        "path-lookup",
5360        "pid-namespace",
5361        "resolv-conf",
5362        "rlimit",
5363        "rlimit.cpu-time",
5364        "rlimit.cpu-time.hard",
5365        "rlimit.cpu-time.soft",
5366        "rlimit.processes",
5367        "rootfs",
5368        "run-as",
5369        "run-as.gid",
5370        "run-as.groups",
5371        "run-as.uid",
5372        "stderr",
5373        "stdin",
5374        "stdout",
5375        "stop-with-caller",
5376        "workdir",
5377    ];
5378
5379    /// Every key a serialized profile can carry, as a sorted list of dotted
5380    /// paths, with an array index rendered as `[]`.
5381    ///
5382    /// The profile format is a compatibility commitment, and a key's spelling
5383    /// comes from `rename_all` on the container rather than from a `rename` on
5384    /// every field — so a Rust-side field rename, a changed `rename_all`, a
5385    /// typo in a container attribute, and a newly added field all move a key
5386    /// silently. This is what notices. A deliberate change to the format
5387    /// updates this list, and the diff is the review.
5388    #[cfg(feature = "hardening")]
5389    #[test]
5390    fn the_profile_format_keys_are_what_they_are() {
5391        fn walk(value: &toml::Value, prefix: &str, into: &mut Vec<String>) {
5392            match value {
5393                toml::Value::Table(table) => {
5394                    for (key, nested) in table {
5395                        let path = if prefix.is_empty() {
5396                            key.clone()
5397                        } else {
5398                            format!("{prefix}.{key}")
5399                        };
5400                        into.push(path.clone());
5401                        walk(nested, &path, into);
5402                    }
5403                }
5404                toml::Value::Array(items) => {
5405                    for item in items {
5406                        walk(item, &format!("{prefix}[]"), into);
5407                    }
5408                }
5409                _ => {}
5410            }
5411        }
5412
5413        let builder: CageBuilder =
5414            toml::from_str(EVERY_KEY_PROFILE).expect("the exhaustive profile parses");
5415        let rendered = toml::to_string(&builder).expect("the builder serializes");
5416        let rendered: toml::Table =
5417            toml::from_str(&rendered).expect("the rendering parses back as TOML");
5418
5419        let mut keys = Vec::new();
5420        walk(&toml::Value::Table(rendered), "", &mut keys);
5421        keys.sort();
5422        keys.dedup();
5423
5424        assert_eq!(
5425            keys, EXPECTED_PROFILE_KEYS,
5426            "the profile format's keys moved"
5427        );
5428    }
5429
5430    const FULL_PROFILE: &str = r#"
5431rootfs = "/srv/rootfs/alpine"
5432command = "/usr/bin/make"
5433args = ["-j4", "all"]
5434network = "host"
5435hostname = "builder"
5436workdir = "/build"
5437stdin = "null"
5438pid-namespace = false
5439mount-proc = true
5440mount-dev = false
5441mount-tmp = true
5442resolv-conf = false
5443stop-with-caller = true
5444
5445[rlimit]
5446processes = 64
5447open-files = "unlimited"
5448cpu-time = { soft = 10, hard = "unlimited" }
5449
5450[env]
5451CARGO_HOME = "/cache/cargo"
5452RUST_LOG = "debug"
5453
5454[[mount]]
5455kind = "bind"
5456source = "/tmp"
5457target = "/build"
5458
5459[[mount]]
5460kind = "raw"
5461target = "/raw"
5462fstype = "tmpfs"
5463flags = 6
5464data = "mode=0777"
5465
5466[[mount]]
5467kind = "bind"
5468source = "/etc"
5469target = "/host-etc"
5470read-only = true
5471"#;
5472
5473    #[test]
5474    fn a_full_profile_deserializes_into_the_builder() {
5475        let builder: CageBuilder = toml::from_str(FULL_PROFILE).unwrap();
5476        assert_eq!(
5477            builder.rootfs.as_deref(),
5478            Some(Path::new("/srv/rootfs/alpine"))
5479        );
5480        assert_eq!(builder.command.as_deref(), Some(Path::new("/usr/bin/make")));
5481        assert_eq!(builder.args, ["-j4", "all"]);
5482        assert_eq!(builder.network, Network::Host);
5483        assert_eq!(builder.hostname.as_deref(), Some(OsStr::new("builder")));
5484        assert_eq!(builder.workdir.as_deref(), Some(Path::new("/build")));
5485        assert!(matches!(builder.stdin, Stdio::Null));
5486        assert!(!builder.pid_namespace);
5487        // The profile states all four explicitly, so each reads back as an
5488        // explicit choice rather than as the default.
5489        assert_eq!(builder.mount_proc, Some(true));
5490        assert_eq!(builder.mount_dev, Some(false));
5491        assert_eq!(builder.mount_tmp, Some(true));
5492        assert_eq!(builder.resolv_conf, Some(false));
5493        assert!(builder.stop_with_caller);
5494        assert_eq!(builder.env.len(), 2);
5495        // Three mounts in one sequence, and the profile's declaration order is
5496        // preserved: the raw mount sits between the two binds.
5497        assert_eq!(builder.mounts.len(), 3);
5498        assert!(matches!(builder.mounts[0], Mount::Bind(_)));
5499        assert!(matches!(&builder.mounts[1], Mount::Raw(raw) if raw.flags == 6));
5500        assert!(matches!(&builder.mounts[2], Mount::Bind(bind) if bind.read_only));
5501    }
5502
5503    #[test]
5504    fn an_overlay_profile_round_trips() {
5505        // The overlay root is a table with an ordered `lower` array, so a
5506        // stacked profile survives a load-and-save cycle unchanged.
5507        let profile = "command = \"/bin/sh\"\n\n\
5508                       [overlay]\n\
5509                       lower = [\"/srv/base\", \"/srv/patches\"]\n\
5510                       upper = \"/var/tmp/scratch\"\n\
5511                       work = \"/var/tmp/work\"\n";
5512        let builder: CageBuilder = toml::from_str(profile).expect("the profile parses");
5513        let overlay = builder.overlay.as_ref().expect("the overlay is set");
5514        assert_eq!(
5515            overlay.lower,
5516            [Path::new("/srv/base"), Path::new("/srv/patches")],
5517        );
5518        assert_eq!(
5519            overlay.upper.as_deref(),
5520            Some(Path::new("/var/tmp/scratch"))
5521        );
5522        assert_eq!(overlay.work.as_deref(), Some(Path::new("/var/tmp/work")));
5523        // A plain rootfs and an overlay are alternatives; the profile named
5524        // only the overlay.
5525        assert!(builder.rootfs.is_none());
5526
5527        let reparsed: CageBuilder = toml::from_str(&toml::to_string(&builder).unwrap())
5528            .expect("the serialized profile parses back");
5529        assert_eq!(reparsed.overlay, builder.overlay);
5530    }
5531
5532    #[test]
5533    fn an_unknown_overlay_key_is_rejected() {
5534        let profile = "command = \"/bin/sh\"\n\n[overlay]\nlower = [\"/srv/base\"]\n\
5535                       upperdir = \"/var/tmp/scratch\"\n";
5536        let err = toml::from_str::<CageBuilder>(profile).unwrap_err();
5537        assert!(err.to_string().contains("upperdir"), "{err}");
5538    }
5539
5540    #[test]
5541    fn a_profile_omitting_everything_is_the_default_builder() {
5542        let builder: CageBuilder = toml::from_str("").unwrap();
5543        assert!(builder.rootfs.is_none());
5544        assert!(builder.command.is_none());
5545        assert!(builder.pid_namespace);
5546        // Unstated in the profile, so unset on the builder — and each still
5547        // resolves to its documented default of `true`.
5548        assert!(builder.mount_proc.is_none() && builder.mount_dev.is_none());
5549        assert!(builder.mount_tmp.is_none() && builder.managed_mounts.is_none());
5550        assert!(builder.get_mount_proc() && builder.get_mount_dev() && builder.get_mount_tmp());
5551        assert!(builder.get_managed_mounts() && builder.get_base_env());
5552        assert_eq!(builder.network, Network::Isolated);
5553        assert!(matches!(builder.stdin, Stdio::Inherit));
5554        assert!(!builder.stop_with_caller);
5555    }
5556
5557    /// `get_resolv_conf` is a consumer-facing opt-out signal, not only an
5558    /// internal one: `fcage` reads it to decide whether to compose a
5559    /// `resolv.conf` for a native-stack run, where the library's own bind does
5560    /// not apply. Either toggle has to turn it off, from a profile as surely as
5561    /// from a setter.
5562    #[test]
5563    fn either_toggle_turns_the_resolv_conf_bind_off() {
5564        let default: CageBuilder = toml::from_str("").unwrap();
5565        assert!(default.get_resolv_conf());
5566
5567        assert!(!Cage::builder().resolv_conf(false).get_resolv_conf());
5568        assert!(!Cage::builder().managed_mounts(false).get_resolv_conf());
5569
5570        let from_profile: CageBuilder = toml::from_str("resolv-conf = false").unwrap();
5571        assert!(!from_profile.get_resolv_conf());
5572        let managed_off: CageBuilder = toml::from_str("managed-mounts = false").unwrap();
5573        assert!(!managed_off.get_resolv_conf());
5574    }
5575
5576    #[test]
5577    fn the_builder_round_trips_through_toml() {
5578        let original: CageBuilder = toml::from_str(FULL_PROFILE).unwrap();
5579        let serialized = toml::to_string(&original).unwrap();
5580        let restored: CageBuilder = toml::from_str(&serialized).unwrap();
5581        assert_eq!(format!("{original:?}"), format!("{restored:?}"));
5582    }
5583
5584    #[test]
5585    fn a_profile_carries_resource_limits_in_both_forms() {
5586        let builder: CageBuilder = toml::from_str(FULL_PROFILE).unwrap();
5587        assert_eq!(
5588            builder.get_rlimits().collect::<Vec<_>>(),
5589            [
5590                (Resource::CpuTime, Limit::of(10), Limit::UNLIMITED),
5591                (Resource::OpenFiles, Limit::UNLIMITED, Limit::UNLIMITED),
5592                (Resource::Processes, Limit::of(64), Limit::of(64)),
5593            ],
5594        );
5595    }
5596
5597    #[test]
5598    fn a_limit_serializes_in_the_shorter_form_that_fits() {
5599        // The shorthand whenever the two agree, the table only when they
5600        // differ, so a rendered profile is no wordier than the setting needs.
5601        let builder = Cage::builder()
5602            .rlimit(Resource::Processes, 64, 64)
5603            .rlimit(Resource::OpenFiles, Limit::UNLIMITED, Limit::UNLIMITED)
5604            .rlimit(Resource::CpuTime, 10, Limit::UNLIMITED);
5605        let rendered: toml::Table = toml::to_string(&builder).unwrap().parse().unwrap();
5606        let rlimit = rendered["rlimit"].as_table().expect("an rlimit table");
5607        assert_eq!(rlimit["processes"].as_integer(), Some(64));
5608        assert_eq!(rlimit["open-files"].as_str(), Some("unlimited"));
5609        assert_eq!(
5610            rlimit["cpu-time"]
5611                .as_table()
5612                .map(|pair| (pair["soft"].as_integer(), pair["hard"].as_str())),
5613            Some((Some(10), Some("unlimited"))),
5614        );
5615    }
5616
5617    #[test]
5618    fn a_profile_with_no_limits_renders_no_rlimit_table() {
5619        let rendered = toml::to_string(&Cage::builder().rootfs("/r")).unwrap();
5620        assert!(!rendered.contains("rlimit"), "{rendered}");
5621    }
5622
5623    #[test]
5624    fn an_explicit_pair_may_agree_and_a_shorthand_may_be_unlimited() {
5625        // The two forms are alternatives everywhere, not each other's only
5626        // spelling: the table is accepted for equal limits too.
5627        let builder: CageBuilder =
5628            toml::from_str("[rlimit]\nprocesses = { soft = 8, hard = 8 }\nstack = \"unlimited\"\n")
5629                .unwrap();
5630        assert_eq!(
5631            builder.get_rlimits().collect::<Vec<_>>(),
5632            [
5633                (Resource::Processes, Limit::of(8), Limit::of(8)),
5634                (Resource::Stack, Limit::UNLIMITED, Limit::UNLIMITED),
5635            ],
5636        );
5637    }
5638
5639    #[test]
5640    fn a_malformed_limit_is_rejected_by_what_it_got_wrong() {
5641        for (profile, expected) in [
5642            ("[rlimit]\nnope = 5\n", "unknown variant `nope`"),
5643            (
5644                "[rlimit]\ncpu-time = { soft = 1, hrad = 2 }\n",
5645                "unknown field `hrad`",
5646            ),
5647            (
5648                "[rlimit]\ncpu-time = { soft = 1 }\n",
5649                "missing field `hard`",
5650            ),
5651            ("[rlimit]\ncpu-time = -1\n", "cannot be negative"),
5652            ("[rlimit]\ncpu-time = \"lots\"\n", "not \"lots\""),
5653            ("[rlimit]\ncpu-time = true\n", "invalid type: boolean"),
5654        ] {
5655            let err = toml::from_str::<CageBuilder>(profile).unwrap_err();
5656            assert!(err.to_string().contains(expected), "{profile}\ngot: {err}");
5657        }
5658    }
5659
5660    #[test]
5661    fn a_restricted_profile_accepts_resource_limits() {
5662        // A limit is set on the command process and can only narrow what the
5663        // caller already had, so it reaches nothing an untrusted profile is
5664        // kept away from.
5665        let restricted: RestrictedProfile =
5666            toml::from_str("rootfs = \"/srv/r\"\n[rlimit]\nprocesses = 64\n").unwrap();
5667        assert_eq!(
5668            restricted.into_builder().get_rlimits().collect::<Vec<_>>(),
5669            [(Resource::Processes, Limit::of(64), Limit::of(64))],
5670        );
5671    }
5672
5673    #[test]
5674    fn an_unknown_profile_key_is_rejected() {
5675        let err =
5676            toml::from_str::<CageBuilder>("rootfs = \"/r\"\nmispelled-key = true\n").unwrap_err();
5677        assert!(err.to_string().contains("mispelled-key"), "{err}");
5678    }
5679
5680    #[test]
5681    fn an_unknown_bind_key_is_rejected() {
5682        let profile =
5683            "[[mount]]\nkind = \"bind\"\nsource = \"/a\"\ntarget = \"/b\"\nwritable = true\n";
5684        let err = toml::from_str::<CageBuilder>(profile).unwrap_err();
5685        assert!(err.to_string().contains("writable"), "{err}");
5686    }
5687
5688    #[test]
5689    fn a_mount_without_a_kind_is_rejected() {
5690        // The tag is what selects the variant, so a mount table that omits it
5691        // is refused rather than guessed at from the fields present.
5692        let profile = "[[mount]]\nsource = \"/a\"\ntarget = \"/b\"\n";
5693        let err = toml::from_str::<CageBuilder>(profile).unwrap_err();
5694        assert!(err.to_string().contains("kind"), "{err}");
5695    }
5696
5697    #[test]
5698    fn an_unknown_mount_kind_is_rejected() {
5699        let profile = "[[mount]]\nkind = \"sideways\"\ntarget = \"/b\"\n";
5700        let err = toml::from_str::<CageBuilder>(profile).unwrap_err();
5701        assert!(err.to_string().contains("sideways"), "{err}");
5702    }
5703
5704    #[test]
5705    fn a_field_from_the_other_mount_kind_is_rejected() {
5706        // The tag narrows which fields are legal: `read-only` belongs to a
5707        // bind, and naming it under `kind = "raw"` is a mistake worth
5708        // reporting rather than silently dropping. (`source` and `target` are
5709        // common to both kinds, so only the distinctive field tests this.)
5710        let profile = "[[mount]]\nkind = \"raw\"\ntarget = \"/b\"\nread-only = true\n";
5711        let err = toml::from_str::<CageBuilder>(profile).unwrap_err();
5712        assert!(err.to_string().contains("read-only"), "{err}");
5713    }
5714
5715    #[test]
5716    fn the_mount_order_a_profile_declares_is_preserved() {
5717        // The whole point of one sequence: a raw mount written between two
5718        // binds is applied between them, which two kind-segregated arrays
5719        // could not express.
5720        let profile = "[[mount]]\nkind = \"raw\"\ntarget = \"/dev\"\nfstype = \"tmpfs\"\n\n\
5721                       [[mount]]\nkind = \"bind\"\nsource = \"/dev/null\"\ntarget = \"/dev/null\"\n";
5722        let builder: CageBuilder = toml::from_str(profile).unwrap();
5723        assert!(matches!(builder.mounts[0], Mount::Raw(_)));
5724        assert!(matches!(builder.mounts[1], Mount::Bind(_)));
5725
5726        // And it survives a render-and-reload cycle, so a rendered profile
5727        // stays a complete record of what will run.
5728        let rendered = toml::to_string(&builder).unwrap();
5729        let reloaded: CageBuilder = toml::from_str(&rendered).unwrap();
5730        assert!(matches!(reloaded.mounts[0], Mount::Raw(_)));
5731        assert!(matches!(reloaded.mounts[1], Mount::Bind(_)));
5732    }
5733
5734    #[test]
5735    fn a_restricted_profile_accepts_a_host_mapping_free_profile() {
5736        // The sandbox-shaping fields are all permitted; only host-mapping
5737        // operations are forbidden.
5738        let profile = "rootfs = \"/srv/rootfs/alpine\"\ncommand = \"/bin/sh\"\n\
5739                       network = \"isolated\"\nworkdir = \"/work\"\n";
5740        let restricted: RestrictedProfile = toml::from_str(profile).unwrap();
5741        let builder = restricted.into_builder();
5742        assert_eq!(
5743            builder.rootfs.as_deref(),
5744            Some(Path::new("/srv/rootfs/alpine"))
5745        );
5746        assert_eq!(builder.command.as_deref(), Some(Path::new("/bin/sh")));
5747    }
5748
5749    #[test]
5750    fn a_restricted_profile_rejects_a_bind_mount() {
5751        let profile = "[[mount]]\nkind = \"bind\"\nsource = \"/etc\"\ntarget = \"/host-etc\"\n";
5752        let err = toml::from_str::<RestrictedProfile>(profile).unwrap_err();
5753        assert!(err.to_string().contains("bind mount"), "{err}");
5754    }
5755
5756    #[test]
5757    fn a_restricted_profile_rejects_a_raw_mount() {
5758        let profile = "[[mount]]\nkind = \"raw\"\ntarget = \"/raw\"\nfstype = \"tmpfs\"\n";
5759        let err = toml::from_str::<RestrictedProfile>(profile).unwrap_err();
5760        assert!(err.to_string().contains("raw mount"), "{err}");
5761    }
5762
5763    #[test]
5764    fn the_profile_key_set_is_pinned() {
5765        // A deliberate second answer to `the_profile_format_keys_are_what_they_are`,
5766        // which pins the same interface from the other end. That one reads an
5767        // exhaustive profile *document* and walks what it renders back as dotted
5768        // paths; this one drives the *builder's setters* and reads the top level
5769        // and each nested table. Three things follow from the difference, and
5770        // each is why both are here:
5771        //
5772        // - The document-driven test needs `hardening` for its profile to
5773        //   parse, so it does not run at all in a build without that feature.
5774        //   This one does, and the keys it pins are the ones such a build has.
5775        // - A key reachable from the setters but not from a document, or the
5776        //   reverse, is a format that does not round-trip. Pinning each
5777        //   direction separately is what would notice.
5778        // - A dotted path unions an array's entries, so `mount[].fstype` says
5779        //   only that *some* mount carries it. Here each `[[mount]]` variant's
5780        //   key set is asserted on its own, which is what catches a bind entry
5781        //   that started rendering a raw mount's field.
5782        //
5783        // `rootfs` and `overlay` are alternatives — setting either clears the
5784        // other — so the full key set is the union of a plain-rooted and an
5785        // overlay-rooted profile.
5786        let builder = Cage::builder()
5787            .rootfs("/srv/rootfs")
5788            .command("/bin/sh")
5789            .args(["-c", "true"])
5790            .network(Network::Host)
5791            .hostname("box")
5792            .current_dir("/build")
5793            .stdin(Stdio::Null)
5794            .pid_namespace(false)
5795            .mount_proc(false)
5796            .mount_dev(false)
5797            .mount_tmp(false)
5798            .resolv_conf(false)
5799            .managed_mounts(true)
5800            .base_env(false)
5801            .stop_with_caller(true)
5802            .path_lookup(true)
5803            .rlimit(Resource::Processes, 64, 64)
5804            .rlimit(Resource::CpuTime, 10, Limit::UNLIMITED)
5805            .identity_map(IdentityMap::Subordinate)
5806            .run_as(Identity::new(250, 250).groups([250]))
5807            .env("K", "V")
5808            .bind("/host", "/guest")
5809            .bind_ro("/host-ro", "/guest-ro")
5810            .raw_mount(
5811                RawMount::new("/dev/shm")
5812                    .source("/host-shm")
5813                    .fstype("tmpfs")
5814                    .flags(1)
5815                    .data("mode=1777"),
5816            );
5817        let overlaid = builder.clone().overlay(
5818            Overlay::new()
5819                .lower("/srv/base")
5820                .upper("/tmp/upper")
5821                .work("/tmp/work"),
5822        );
5823
5824        let render = |builder: &CageBuilder| -> toml::Table {
5825            toml::to_string(builder)
5826                .expect("the builder serializes")
5827                .parse()
5828                .expect("the profile is valid TOML")
5829        };
5830        let table = render(&builder);
5831        let overlay_table = render(&overlaid);
5832
5833        let mut keys: Vec<&str> = table
5834            .keys()
5835            .chain(overlay_table.keys())
5836            .map(String::as_str)
5837            .collect();
5838        keys.sort_unstable();
5839        keys.dedup();
5840        assert_eq!(
5841            keys,
5842            [
5843                "args",
5844                "base-env",
5845                "command",
5846                "env",
5847                "hostname",
5848                "identity-map",
5849                "managed-mounts",
5850                "mount",
5851                "mount-dev",
5852                "mount-proc",
5853                "mount-tmp",
5854                "network",
5855                "overlay",
5856                "path-lookup",
5857                "pid-namespace",
5858                "resolv-conf",
5859                "rlimit",
5860                "rootfs",
5861                "run-as",
5862                "stderr",
5863                "stdin",
5864                "stdout",
5865                "stop-with-caller",
5866                "workdir",
5867            ],
5868        );
5869
5870        // The nested tables are part of the same interface.
5871        let nested = |key: &str| -> Vec<String> {
5872            let source = if key == "overlay" {
5873                &overlay_table
5874            } else {
5875                &table
5876            };
5877            let mut keys: Vec<String> = match &source[key] {
5878                toml::Value::Table(t) => t.keys().cloned().collect(),
5879                toml::Value::Array(a) => match &a[0] {
5880                    toml::Value::Table(t) => t.keys().cloned().collect(),
5881                    other => panic!("{key} holds {other:?}"),
5882                },
5883                other => panic!("{key} is {other:?}"),
5884            };
5885            keys.sort();
5886            keys
5887        };
5888        assert_eq!(nested("overlay"), ["lower", "upper", "work"]);
5889        assert_eq!(nested("run-as"), ["gid", "groups", "uid"]);
5890        // The `[rlimit]` keys are resource names rather than a fixed set, so
5891        // what is pinned here is the pair of value forms: the shorthand for a
5892        // resource whose limits agree, the table for one whose limits differ.
5893        assert_eq!(nested("rlimit"), ["cpu-time", "processes"]);
5894        // The `[[mount]]` array is heterogeneous, so each variant's key set is
5895        // pinned separately, `kind` tag included: it is as much part of the
5896        // interface as the fields it selects between.
5897        let mount_entry = |index: usize| -> Vec<String> {
5898            let toml::Value::Array(mounts) = &table["mount"] else {
5899                panic!("mount is not an array");
5900            };
5901            let toml::Value::Table(entry) = &mounts[index] else {
5902                panic!("mount entry {index} is not a table");
5903            };
5904            let mut keys: Vec<String> = entry.keys().cloned().collect();
5905            keys.sort();
5906            keys
5907        };
5908        assert_eq!(mount_entry(0), ["kind", "read-only", "source", "target"]);
5909        assert_eq!(mount_entry(1), ["kind", "read-only", "source", "target"]);
5910        assert_eq!(
5911            mount_entry(2),
5912            ["data", "flags", "fstype", "kind", "source", "target"],
5913        );
5914    }
5915
5916    #[test]
5917    fn a_restricted_profile_rejects_host_networking() {
5918        let err = toml::from_str::<RestrictedProfile>("network = \"host\"\n").unwrap_err();
5919        assert!(err.to_string().contains("host networking"), "{err}");
5920    }
5921
5922    #[test]
5923    fn a_restricted_profile_rejects_an_overlay_root() {
5924        // An overlay upper is created, not required to exist, so honoring one
5925        // would have the profile choose a host path for the library to make
5926        // directories at — and to run the overlay preflight probe in.
5927        let profile = "command = \"/bin/sh\"\n\
5928                       [overlay]\nlower = [\"/srv/base\"]\n\
5929                       upper = \"/tmp/profile-chosen-upper\"\n";
5930        let err = toml::from_str::<RestrictedProfile>(profile).unwrap_err();
5931        assert!(err.to_string().contains("an overlay root"), "{err}");
5932    }
5933
5934    #[test]
5935    fn a_profile_naming_both_roots_is_refused() {
5936        // The setters keep the two exclusive, but nothing stops a profile from
5937        // carrying both keys. Taking the rootfs would drop the overlay's whole
5938        // lower stack; taking the overlay would ignore the stated rootfs. The
5939        // contradiction is the author's to resolve.
5940        let profile = "rootfs = \"/srv/alpine\"\ncommand = \"/bin/sh\"\n\n\
5941                       [overlay]\n\
5942                       lower = [\"/srv/base\", \"/srv/patches\"]\n\
5943                       upper = \"/tmp/upper\"\n";
5944        let builder: CageBuilder = toml::from_str(profile).expect("the profile parses");
5945        let err = builder.build().unwrap_err();
5946        assert!(
5947            matches!(err, Error::Config(ConfigError::RootContradiction)),
5948            "{err}"
5949        );
5950    }
5951
5952    #[test]
5953    fn an_overlay_without_a_lower_names_what_is_missing() {
5954        // "no rootfs was provided" reads as an oversight to an author who
5955        // provided an overlay table and forgot its base layer.
5956        let err = Cage::builder()
5957            .overlay(Overlay::new().upper("/tmp/upper"))
5958            .command("/bin/sh")
5959            .build()
5960            .unwrap_err();
5961        assert!(
5962            matches!(err, Error::Config(ConfigError::OverlayLowerMissing)),
5963            "{err}"
5964        );
5965    }
5966
5967    #[test]
5968    fn an_overlay_layer_directory_is_created_fresh_and_owned() {
5969        use std::os::unix::fs::PermissionsExt;
5970
5971        let scratch = Scratch::new("overlay-layer-dir");
5972
5973        // A layer directory the library creates: made here, at the stated mode.
5974        // (The umask is not set here to prove the chmod that follows the
5975        // `mkdir`: it is process-global, and this suite runs its tests in
5976        // parallel threads, so narrowing it would reach every directory another
5977        // test created meanwhile.)
5978        let fresh = scratch.join("nested/fresh");
5979        let prepared = prepare_layer_dir(OverlayLayer::Upper, &fresh).unwrap();
5980        assert_eq!(prepared, std::fs::canonicalize(&fresh).unwrap());
5981        let mode = |path: &Path| std::fs::metadata(path).unwrap().permissions().mode() & 0o7777;
5982        assert_eq!(mode(&fresh), OVERLAY_DIR_MODE);
5983
5984        // Preparing it a second time adopts it: it is a directory this user
5985        // owns, which is exactly what the first call left. An adopted directory
5986        // keeps the mode its owner gave it — only a directory this created is
5987        // chmod-ed, so a caller reusing a layer of its own does not find the
5988        // mode changed underneath it.
5989        std::fs::set_permissions(&fresh, std::fs::Permissions::from_mode(0o700)).unwrap();
5990        assert_eq!(
5991            prepare_layer_dir(OverlayLayer::Upper, &fresh).unwrap(),
5992            prepared,
5993        );
5994        assert_eq!(mode(&fresh), 0o700, "an adopted layer keeps its own mode");
5995
5996        // A symbolic link at the path is refused rather than followed. Left
5997        // followed, `upperdir=` would name the destination and every write the
5998        // sandbox made would land there instead.
5999        let destination = scratch.join("elsewhere");
6000        std::fs::create_dir(&destination).unwrap();
6001        let planted = scratch.join("planted");
6002        std::os::unix::fs::symlink(&destination, &planted).unwrap();
6003        let err = prepare_layer_dir(OverlayLayer::Upper, &planted).unwrap_err();
6004        assert!(
6005            matches!(
6006                err,
6007                Error::Config(ConfigError::OverlayDirUnowned {
6008                    layer: OverlayLayer::Upper,
6009                    ..
6010                })
6011            ),
6012            "{err}"
6013        );
6014
6015        // So is an entry that is not a directory at all.
6016        let file = scratch.join("file");
6017        std::fs::write(&file, b"").unwrap();
6018        let err = prepare_layer_dir(OverlayLayer::Work, &file).unwrap_err();
6019        assert!(
6020            matches!(
6021                err,
6022                Error::Config(ConfigError::OverlayDirUnowned {
6023                    layer: OverlayLayer::Work,
6024                    ..
6025                })
6026            ),
6027            "{err}"
6028        );
6029    }
6030
6031    #[test]
6032    fn an_overlay_upper_at_the_filesystem_root_is_refused() {
6033        // `/` has no parent, so there is nowhere for the work directory beside
6034        // the upper or for the host preflight's scratch entries — and an upper
6035        // there would put the sandbox's writes over the whole host root.
6036        let lower = Scratch::new("overlay-upper-root");
6037        let err = Cage::builder()
6038            .overlay_rootfs(&*lower, "/")
6039            .command("/bin/sh")
6040            .build()
6041            .unwrap_err();
6042        assert!(
6043            matches!(err, Error::Config(ConfigError::OverlayUpperIsRoot { .. })),
6044            "{err}"
6045        );
6046    }
6047
6048    #[test]
6049    fn overriding_the_rootfs_clears_an_overlay() {
6050        // A consumer overriding an untrusted profile's rootfs must not be left
6051        // with the overlay that profile configured; the last root call wins.
6052        let builder = Cage::builder()
6053            .overlay_rootfs("/srv/base", "/tmp/upper")
6054            .rootfs("/srv/rootfs/alpine");
6055        assert_eq!(builder.overlay, None);
6056        assert_eq!(
6057            builder.rootfs.as_deref(),
6058            Some(Path::new("/srv/rootfs/alpine"))
6059        );
6060    }
6061
6062    #[test]
6063    fn a_restricted_profile_rejects_the_host_pid_namespace() {
6064        // `pid-namespace = false` binds the host's /proc read-write into the
6065        // sandbox, the same host-resource mapping an explicit `[[mount]]` bind
6066        // of /proc would perform; the restricted policy reserves it.
6067        let profile = "rootfs = \"/srv/rootfs/alpine\"\ncommand = \"/bin/sh\"\n\
6068                       pid-namespace = false\n";
6069        let err = toml::from_str::<RestrictedProfile>(profile).unwrap_err();
6070        assert!(err.to_string().contains("host PID namespace"), "{err}");
6071    }
6072
6073    #[test]
6074    fn profile_env_composes_with_builder_env_calls() {
6075        let builder: CageBuilder =
6076            toml::from_str("[env]\nA = \"profile\"\nB = \"profile\"\n").unwrap();
6077        let cage = builder
6078            .rootfs("/tmp")
6079            .command("/bin/sh")
6080            .env("B", "override")
6081            .build()
6082            .unwrap();
6083        let env: Vec<&str> = cage
6084            .plan
6085            .env
6086            .iter()
6087            .map(|entry| entry.to_str().unwrap())
6088            .collect();
6089        assert!(env.contains(&"A=profile"));
6090        assert!(env.contains(&"B=override"));
6091    }
6092
6093    #[test]
6094    fn a_deserialized_profile_builds_a_runnable_plan() {
6095        let profile = "rootfs = \"/tmp\"\ncommand = \"/bin/sh\"\nstdin = \"null\"\n";
6096        let builder: CageBuilder = toml::from_str(profile).unwrap();
6097        let cage = builder.build().unwrap();
6098        assert!(matches!(cage.plan.stdin, StdioPlan::Null));
6099    }
6100
6101    #[test]
6102    fn an_identity_map_profile_round_trips() {
6103        let profile = r#"
6104rootfs = "/tmp"
6105command = "/bin/sh"
6106
6107[identity-map.ranges]
6108uid = [{ inside = 0, outside = 1000, count = 1 }, { inside = 1, outside = 100000, count = 65536 }]
6109gid = [{ inside = 0, outside = 1000, count = 1 }]
6110
6111[run-as]
6112uid = 250
6113gid = 250
6114groups = [250, 100]
6115"#;
6116        let original: CageBuilder = toml::from_str(profile).unwrap();
6117        match &original.identity_map {
6118            IdentityMap::Ranges { uid, gid, .. } => {
6119                assert_eq!(uid.len(), 2);
6120                assert_eq!(uid[1].outside, 100000);
6121                assert_eq!(gid.len(), 1);
6122            }
6123            other => panic!("expected ranges, got {other:?}"),
6124        }
6125        let run_as = original.run_as.as_ref().unwrap();
6126        assert_eq!(run_as.uid, 250);
6127        assert_eq!(run_as.groups, [250, 100]);
6128
6129        let serialized = toml::to_string(&original).unwrap();
6130        let restored: CageBuilder = toml::from_str(&serialized).unwrap();
6131        assert_eq!(format!("{original:?}"), format!("{restored:?}"));
6132        assert!(serialized.contains("identity-map"), "{serialized}");
6133        assert!(serialized.contains("run-as"), "{serialized}");
6134    }
6135
6136    #[cfg(feature = "subid")]
6137    #[test]
6138    fn a_subordinate_identity_map_profile_round_trips() {
6139        let profile = "rootfs = \"/tmp\"\ncommand = \"/bin/sh\"\nidentity-map = \"subordinate\"\n";
6140        let original: CageBuilder = toml::from_str(profile).unwrap();
6141        assert_eq!(original.identity_map, IdentityMap::Subordinate);
6142        let serialized = toml::to_string(&original).unwrap();
6143        assert!(
6144            serialized.contains("identity-map = \"subordinate\""),
6145            "{serialized}"
6146        );
6147        let restored: CageBuilder = toml::from_str(&serialized).unwrap();
6148        assert_eq!(format!("{original:?}"), format!("{restored:?}"));
6149    }
6150
6151    /// A build without the `subid` feature refuses a subordinate profile at
6152    /// load time: no delegate could establish it, and an identity posture is
6153    /// never silently downgraded.
6154    #[cfg(not(feature = "subid"))]
6155    #[test]
6156    fn a_subordinate_profile_is_refused_without_the_subid_feature() {
6157        let profile = "rootfs = \"/tmp\"\ncommand = \"/bin/sh\"\nidentity-map = \"subordinate\"\n";
6158        let err = toml::from_str::<CageBuilder>(profile).unwrap_err();
6159        assert!(err.to_string().contains("subid"), "{err}");
6160    }
6161
6162    #[test]
6163    fn the_default_identity_map_stays_out_of_the_profile() {
6164        let builder: CageBuilder = toml::from_str("rootfs = \"/r\"\ncommand = \"/c\"\n").unwrap();
6165        let serialized = toml::to_string(&builder).unwrap();
6166        assert!(!serialized.contains("identity-map"), "{serialized}");
6167        assert!(!serialized.contains("run-as"), "{serialized}");
6168    }
6169
6170    #[test]
6171    fn an_unknown_identity_map_form_is_rejected() {
6172        let profile = "rootfs = \"/r\"\ncommand = \"/c\"\nidentity-map = \"everything\"\n";
6173        let err = toml::from_str::<CageBuilder>(profile).unwrap_err();
6174        assert!(err.to_string().contains("everything"), "{err}");
6175    }
6176
6177    #[test]
6178    fn an_unknown_run_as_key_is_rejected() {
6179        let profile =
6180            "rootfs = \"/r\"\ncommand = \"/c\"\n[run-as]\nuid = 1\ngid = 1\nshell = \"/bin/sh\"\n";
6181        let err = toml::from_str::<CageBuilder>(profile).unwrap_err();
6182        assert!(err.to_string().contains("shell"), "{err}");
6183    }
6184
6185    #[test]
6186    fn an_unknown_id_range_key_is_rejected() {
6187        let profile = "rootfs = \"/r\"\ncommand = \"/c\"\n\
6188                       [identity-map.ranges]\n\
6189                       uid = [{ inside = 0, outside = 1000, count = 1, extra = 1 }]\n\
6190                       gid = [{ inside = 0, outside = 1000, count = 1 }]\n";
6191        let err = toml::from_str::<CageBuilder>(profile).unwrap_err();
6192        assert!(err.to_string().contains("extra"), "{err}");
6193    }
6194
6195    /// A buildable profile carrying a full hardening posture: a capability
6196    /// keep-list, a seccomp denylist named by syscall, and Landlock filesystem
6197    /// and network grants.
6198    #[cfg(feature = "hardening")]
6199    const HARDENED_PROFILE: &str = r#"
6200rootfs = "/tmp"
6201command = "/bin/sh"
6202
6203[hardening]
6204keep-caps = ["net-bind-service", "sys-chroot"]
6205
6206[hardening.seccomp]
6207deny = ["mount", "ptrace"]
6208
6209[[hardening.landlock-fs]]
6210access = "rx"
6211path = "/usr"
6212
6213[[hardening.landlock-fs]]
6214access = "rwx"
6215path = "/work"
6216
6217[[hardening.landlock-net]]
6218access = "c"
6219port = 443
6220
6221[[hardening.landlock-net]]
6222access = "bc"
6223port = 8080
6224"#;
6225
6226    #[cfg(feature = "hardening")]
6227    #[test]
6228    fn a_hardening_profile_lowers_into_the_plan() {
6229        let builder: CageBuilder = toml::from_str(HARDENED_PROFILE).unwrap();
6230        let cage = builder.build().unwrap();
6231        let hardening = &cage.plan.hardening;
6232        // The filesystem and network grants each lower into rules and a ruleset
6233        // that governs the corresponding modeled access set.
6234        assert_eq!(hardening.landlock.len(), 2);
6235        assert_ne!(hardening.landlock_handled, 0);
6236        assert_eq!(hardening.landlock_net.len(), 2);
6237        assert_ne!(hardening.landlock_net_handled, 0);
6238        // The denylist compiled to a program, and the keep-list lowered to a
6239        // non-empty capability mask.
6240        assert!(hardening.seccomp.is_some());
6241        assert!(matches!(hardening.keep_caps, Some(bits) if bits != 0));
6242    }
6243
6244    #[cfg(feature = "hardening")]
6245    #[test]
6246    fn a_hardening_profile_round_trips_through_toml() {
6247        let original: CageBuilder = toml::from_str(HARDENED_PROFILE).unwrap();
6248        let serialized = toml::to_string(&original).unwrap();
6249        let restored: CageBuilder = toml::from_str(&serialized).unwrap();
6250        assert_eq!(format!("{original:?}"), format!("{restored:?}"));
6251        // The seccomp rules table, the capability list, and both Landlock grant
6252        // arrays survive the trip.
6253        assert!(serialized.contains("[hardening.seccomp]"), "{serialized}");
6254        assert!(serialized.contains("keep-caps"), "{serialized}");
6255        assert!(serialized.contains("mount"), "{serialized}");
6256        assert!(
6257            serialized.contains("[[hardening.landlock-fs]]"),
6258            "{serialized}"
6259        );
6260        assert!(
6261            serialized.contains("[[hardening.landlock-net]]"),
6262            "{serialized}"
6263        );
6264        assert!(serialized.contains("port = 443"), "{serialized}");
6265    }
6266
6267    #[cfg(feature = "hardening")]
6268    #[test]
6269    fn the_curated_and_drop_caps_forms_round_trip() {
6270        let profile = "rootfs = \"/tmp\"\ncommand = \"/bin/sh\"\n\
6271                       [hardening]\ndrop-caps = true\nseccomp = \"curated\"\n";
6272        let original: CageBuilder = toml::from_str(profile).unwrap();
6273        let serialized = toml::to_string(&original).unwrap();
6274        assert!(serialized.contains("drop-caps = true"), "{serialized}");
6275        assert!(serialized.contains("seccomp = \"curated\""), "{serialized}");
6276        let restored: CageBuilder = toml::from_str(&serialized).unwrap();
6277        assert_eq!(format!("{original:?}"), format!("{restored:?}"));
6278    }
6279
6280    #[cfg(feature = "hardening")]
6281    #[test]
6282    fn an_allowlist_seccomp_profile_round_trips() {
6283        let profile = "rootfs = \"/tmp\"\ncommand = \"/bin/sh\"\n\
6284                       [hardening.seccomp]\nallow = [\"read\", \"write\", \"exit_group\"]\n";
6285        let original: CageBuilder = toml::from_str(profile).unwrap();
6286        let serialized = toml::to_string(&original).unwrap();
6287        let restored: CageBuilder = toml::from_str(&serialized).unwrap();
6288        assert_eq!(format!("{original:?}"), format!("{restored:?}"));
6289        assert!(serialized.contains("allow"), "{serialized}");
6290        assert!(serialized.contains("exit_group"), "{serialized}");
6291    }
6292
6293    #[cfg(feature = "hardening")]
6294    #[test]
6295    fn an_unknown_syscall_name_is_rejected() {
6296        let profile = "rootfs = \"/tmp\"\ncommand = \"/bin/sh\"\n\
6297                       [hardening.seccomp]\ndeny = [\"not_a_syscall\"]\n";
6298        let err = toml::from_str::<CageBuilder>(profile).unwrap_err();
6299        assert!(err.to_string().contains("not_a_syscall"), "{err}");
6300    }
6301
6302    #[cfg(feature = "hardening")]
6303    #[test]
6304    fn an_invalid_access_string_is_rejected() {
6305        let profile = "rootfs = \"/tmp\"\ncommand = \"/bin/sh\"\n\
6306                       [[hardening.landlock-fs]]\naccess = \"rq\"\npath = \"/usr\"\n";
6307        let err = toml::from_str::<CageBuilder>(profile).unwrap_err();
6308        assert!(err.to_string().contains("access"), "{err}");
6309    }
6310
6311    #[cfg(feature = "hardening")]
6312    #[test]
6313    fn an_invalid_net_access_string_is_rejected() {
6314        let profile = "rootfs = \"/tmp\"\ncommand = \"/bin/sh\"\n\
6315                       [[hardening.landlock-net]]\naccess = \"z\"\nport = 443\n";
6316        let err = toml::from_str::<CageBuilder>(profile).unwrap_err();
6317        assert!(err.to_string().contains("access"), "{err}");
6318    }
6319
6320    #[cfg(feature = "hardening")]
6321    #[test]
6322    fn a_net_port_above_the_u16_range_is_rejected() {
6323        // The profile's port is a u16, so a value the kernel would reject is
6324        // refused at deserialization instead.
6325        let profile = "rootfs = \"/tmp\"\ncommand = \"/bin/sh\"\n\
6326                       [[hardening.landlock-net]]\naccess = \"c\"\nport = 70000\n";
6327        let err = toml::from_str::<CageBuilder>(profile).unwrap_err();
6328        assert!(err.to_string().contains("70000"), "{err}");
6329    }
6330
6331    #[cfg(feature = "hardening")]
6332    #[test]
6333    fn drop_caps_and_keep_caps_together_are_rejected() {
6334        let profile = "rootfs = \"/tmp\"\ncommand = \"/bin/sh\"\n\
6335                       [hardening]\ndrop-caps = true\nkeep-caps = [\"chown\"]\n";
6336        let err = toml::from_str::<CageBuilder>(profile).unwrap_err();
6337        assert!(err.to_string().contains("keep-caps"), "{err}");
6338    }
6339
6340    #[cfg(feature = "hardening")]
6341    #[test]
6342    fn an_empty_keep_caps_list_is_rejected() {
6343        // An empty keep-list is ambiguous: it must not silently mean "keep
6344        // everything" when a reader might intend "drop everything".
6345        let profile = "rootfs = \"/tmp\"\ncommand = \"/bin/sh\"\n[hardening]\nkeep-caps = []\n";
6346        let err = toml::from_str::<CageBuilder>(profile).unwrap_err();
6347        assert!(err.to_string().contains("drop-caps"), "{err}");
6348    }
6349
6350    #[cfg(feature = "hardening")]
6351    #[test]
6352    fn an_unknown_hardening_key_is_rejected() {
6353        let profile = "rootfs = \"/tmp\"\ncommand = \"/bin/sh\"\n[hardening]\nbogus = true\n";
6354        let err = toml::from_str::<CageBuilder>(profile).unwrap_err();
6355        assert!(err.to_string().contains("bogus"), "{err}");
6356    }
6357
6358    #[cfg(feature = "hardening")]
6359    #[test]
6360    fn a_precompiled_seccomp_program_cannot_be_serialized() {
6361        use crate::{SeccompPolicy, SockFilter};
6362        let builder =
6363            Cage::builder()
6364                .rootfs("/tmp")
6365                .command("/bin/sh")
6366                .seccomp(SeccompPolicy::Program(vec![SockFilter {
6367                    code: 6,
6368                    jt: 0,
6369                    jf: 0,
6370                    k: 0,
6371                }]));
6372        let err = toml::to_string(&builder).unwrap_err();
6373        assert!(err.to_string().contains("precompiled"), "{err}");
6374    }
6375
6376    #[cfg(feature = "hardening")]
6377    #[test]
6378    fn a_seccomp_policy_listing_no_syscall_cannot_be_serialized() {
6379        use crate::{SeccompPolicy, SeccompRules};
6380        // The table names its side by listing one, so a policy with neither
6381        // side populated renders as a bare `[hardening.seccomp]` -- a document
6382        // the reader then refuses. Reported on the way out instead, which is
6383        // what "a serialization error, never a silent omission" means.
6384        for policy in [
6385            SeccompPolicy::Rules(SeccompRules::allowing([])),
6386            SeccompPolicy::Rules(SeccompRules::denying([])),
6387        ] {
6388            let builder = Cage::builder()
6389                .rootfs("/tmp")
6390                .command("/bin/sh")
6391                .seccomp(policy);
6392            let err = toml::to_string(&builder).expect_err("an empty rule set is refused");
6393            assert!(err.to_string().contains("listing no syscall"), "{err}");
6394        }
6395    }
6396
6397    #[cfg(feature = "hardening")]
6398    #[test]
6399    fn a_custom_action_seccomp_rule_cannot_be_serialized() {
6400        use crate::{SeccompAction, SeccompPolicy, SeccompRules};
6401        let builder =
6402            Cage::builder()
6403                .rootfs("/tmp")
6404                .command("/bin/sh")
6405                .seccomp(SeccompPolicy::Rules(
6406                    SeccompRules::denying([1i64]).listed_action(SeccompAction::KillProcess),
6407                ));
6408        let err = toml::to_string(&builder).unwrap_err();
6409        assert!(err.to_string().contains("custom action"), "{err}");
6410    }
6411
6412    #[cfg(feature = "hardening")]
6413    #[test]
6414    fn an_argument_conditioned_seccomp_profile_round_trips() {
6415        // An allowlist that names bare syscalls and one argument-conditioned
6416        // entry: ioctl is allowed only when its request argument equals a
6417        // value.
6418        let profile = "rootfs = \"/tmp\"\ncommand = \"/bin/sh\"\n\
6419                       [hardening.seccomp]\n\
6420                       allow = [\"read\", \"write\"]\n\
6421                       [[hardening.seccomp.allow-rule]]\n\
6422                       syscall = \"ioctl\"\n\
6423                       [[hardening.seccomp.allow-rule.arg]]\n\
6424                       index = 1\nlen = \"dword\"\nop = \"eq\"\nvalue = 21523\n";
6425        let original: CageBuilder = toml::from_str(profile).unwrap();
6426        let serialized = toml::to_string(&original).unwrap();
6427        let restored: CageBuilder = toml::from_str(&serialized).unwrap();
6428        assert_eq!(format!("{original:?}"), format!("{restored:?}"));
6429        assert!(serialized.contains("allow-rule"), "{serialized}");
6430        assert!(serialized.contains("ioctl"), "{serialized}");
6431        assert!(serialized.contains("21523"), "{serialized}");
6432    }
6433
6434    #[cfg(feature = "hardening")]
6435    #[test]
6436    fn a_masked_eq_seccomp_profile_round_trips() {
6437        // clone is allowed only when the CLONE_NEWUSER bit is clear in its
6438        // flags argument: masked-eq against a value of 0.
6439        let profile = "rootfs = \"/tmp\"\ncommand = \"/bin/sh\"\n\
6440                       [[hardening.seccomp.allow-rule]]\n\
6441                       syscall = \"clone\"\n\
6442                       [[hardening.seccomp.allow-rule.arg]]\n\
6443                       index = 0\nlen = \"qword\"\nop = \"masked-eq\"\nvalue = 0\nmask = 268435456\n";
6444        let original: CageBuilder = toml::from_str(profile).unwrap();
6445        let serialized = toml::to_string(&original).unwrap();
6446        let restored: CageBuilder = toml::from_str(&serialized).unwrap();
6447        assert_eq!(format!("{original:?}"), format!("{restored:?}"));
6448        assert!(serialized.contains("masked-eq"), "{serialized}");
6449        assert!(serialized.contains("mask = 268435456"), "{serialized}");
6450    }
6451
6452    #[cfg(feature = "hardening")]
6453    #[test]
6454    fn a_seccomp_profile_mixing_allow_and_deny_is_rejected() {
6455        let profile = "rootfs = \"/tmp\"\ncommand = \"/bin/sh\"\n\
6456                       [hardening.seccomp]\nallow = [\"read\"]\ndeny = [\"write\"]\n";
6457        let err = toml::from_str::<CageBuilder>(profile).unwrap_err();
6458        assert!(err.to_string().contains("allow and a deny"), "{err}");
6459    }
6460
6461    #[cfg(feature = "hardening")]
6462    #[test]
6463    fn an_empty_seccomp_table_is_rejected() {
6464        let profile = "rootfs = \"/tmp\"\ncommand = \"/bin/sh\"\n[hardening.seccomp]\n";
6465        let err = toml::from_str::<CageBuilder>(profile).unwrap_err();
6466        assert!(err.to_string().contains("lists no syscalls"), "{err}");
6467    }
6468
6469    #[cfg(feature = "hardening")]
6470    #[test]
6471    fn a_masked_eq_argument_without_a_mask_is_rejected() {
6472        let profile = "rootfs = \"/tmp\"\ncommand = \"/bin/sh\"\n\
6473                       [[hardening.seccomp.deny-rule]]\n\
6474                       syscall = \"socket\"\n\
6475                       [[hardening.seccomp.deny-rule.arg]]\n\
6476                       index = 0\nlen = \"dword\"\nop = \"masked-eq\"\nvalue = 0\n";
6477        let err = toml::from_str::<CageBuilder>(profile).unwrap_err();
6478        assert!(err.to_string().contains("requires a mask"), "{err}");
6479    }
6480
6481    #[cfg(feature = "hardening")]
6482    #[test]
6483    fn a_mask_on_a_non_masked_argument_is_rejected() {
6484        let profile = "rootfs = \"/tmp\"\ncommand = \"/bin/sh\"\n\
6485                       [[hardening.seccomp.deny-rule]]\n\
6486                       syscall = \"socket\"\n\
6487                       [[hardening.seccomp.deny-rule.arg]]\n\
6488                       index = 0\nlen = \"dword\"\nop = \"eq\"\nvalue = 2\nmask = 15\n";
6489        let err = toml::from_str::<CageBuilder>(profile).unwrap_err();
6490        assert!(err.to_string().contains("masked-eq"), "{err}");
6491    }
6492
6493    #[cfg(feature = "hardening")]
6494    #[test]
6495    fn a_seccomp_argument_without_a_width_is_rejected() {
6496        // The width has no safe default, so a profile must state it.
6497        let profile = "rootfs = \"/tmp\"\ncommand = \"/bin/sh\"\n\
6498                       [[hardening.seccomp.deny-rule]]\n\
6499                       syscall = \"socket\"\n\
6500                       [[hardening.seccomp.deny-rule.arg]]\n\
6501                       index = 0\nop = \"eq\"\nvalue = 2\n";
6502        let err = toml::from_str::<CageBuilder>(profile).unwrap_err();
6503        assert!(err.to_string().contains("len"), "{err}");
6504    }
6505
6506    /// A build with `serde` but not `hardening` must refuse a profile that
6507    /// configures hardening rather than silently discard the posture.
6508    #[cfg(not(feature = "hardening"))]
6509    #[test]
6510    fn a_hardening_profile_is_rejected_without_the_feature() {
6511        let profile =
6512            "rootfs = \"/tmp\"\ncommand = \"/bin/sh\"\n[hardening]\nseccomp = \"curated\"\n";
6513        let err = toml::from_str::<CageBuilder>(profile).unwrap_err();
6514        assert!(err.to_string().contains("hardening"), "{err}");
6515        // A profile with no hardening table still loads.
6516        let ok: CageBuilder = toml::from_str("rootfs = \"/tmp\"\ncommand = \"/bin/sh\"\n").unwrap();
6517        assert_eq!(ok.command.as_deref(), Some(Path::new("/bin/sh")));
6518    }
6519}