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}