ferroday_cage/resolved.rs
1//! The inputs a built sandbox will actually apply.
2//!
3//! A [`CageBuilder`](crate::CageBuilder)'s `get_` accessors report what the
4//! caller asked for. This module reports what that request resolved to: the
5//! root filesystem the command will see, the environment it will carry, base
6//! included, the mounts the sandbox will establish, managed profile included,
7//! in the order they will be applied, and the posture the launch will put it
8//! under.
9//!
10//! The distinction matters to a consumer recording provenance. What a build
11//! produces depends on the environment and the filesystem the command sees, on
12//! the identity it holds, on whether it can reach a network, and on which
13//! syscalls will succeed — and neither the caller's own inputs nor a library
14//! version number states those directly. [`ResolvedInputs`] does, as data a
15//! consumer can stamp into an artifact beside its own version pin.
16
17use std::ffi::{CString, OsString};
18use std::os::unix::ffi::OsStringExt;
19use std::path::PathBuf;
20
21use crate::Network;
22use crate::idmap::IdRange;
23use crate::limits::{Limit, Resource};
24use crate::mechanism::{Confinement, IdentityPlan, LaunchPlan, MountAction};
25
26/// The root, environment, mounts, and posture a built sandbox will apply.
27///
28/// Obtained from [`Cage::resolved_inputs`](crate::Cage::resolved_inputs).
29///
30/// # Stability
31///
32/// This type's *shape* follows the compatibility promise like any other public
33/// item. Its *contents* do not: they report the defaults in force, and those
34/// are explicitly outside the promise — the mount profile and the base
35/// environment may both change in a patch release. That is the reason to
36/// record the value rather than assume it, and a consumer that needs the
37/// contents themselves to be stable should set
38/// [`managed_mounts(false)`](crate::CageBuilder::managed_mounts) and
39/// [`base_env(false)`](crate::CageBuilder::base_env) and declare its own.
40///
41/// # Example
42///
43/// ```no_run
44/// # fn main() -> ferroday_cage::Result<()> {
45/// let cage = ferroday_cage::Cage::builder()
46/// .rootfs("/srv/rootfs/alpine")
47/// .command("/usr/bin/make")
48/// .build()?;
49///
50/// let inputs = cage.resolved_inputs();
51/// for (name, value) in &inputs.env {
52/// println!("{}={}", name.to_string_lossy(), value.to_string_lossy());
53/// }
54/// for mount in &inputs.mounts {
55/// println!("{}", mount.get_target().display());
56/// }
57/// # Ok(())
58/// # }
59/// ```
60#[derive(Debug, Clone, PartialEq, Eq)]
61#[non_exhaustive]
62#[cfg_attr(feature = "serde", derive(serde::Serialize))]
63pub struct ResolvedInputs {
64 /// What the command's root filesystem is.
65 ///
66 /// Its own field rather than a mount: the root is not something the profile
67 /// lays over the sandbox, it is what the profile is laid over.
68 pub root: ResolvedRoot,
69 /// The command's complete environment, sorted by variable name.
70 ///
71 /// The caller's variables composed over the library's base, or the
72 /// caller's alone where [`base_env(false)`](crate::CageBuilder::base_env)
73 /// is set.
74 #[cfg_attr(feature = "serde", serde(with = "crate::spec::serde_os::string_map"))]
75 pub env: Vec<(OsString, OsString)>,
76 /// Every mount the sandbox establishes over its root, in the order it
77 /// establishes them: the managed profile, then the caller's own, and last
78 /// the `resolv.conf` bind that [`Network::Host`] adds.
79 ///
80 /// Empty for a restriction, which mounts nothing.
81 pub mounts: Vec<ResolvedMount>,
82 /// The identity the command holds inside the sandbox.
83 pub identity: ResolvedIdentity,
84 /// The network the command can reach.
85 ///
86 // The sentence links a feature-gated item, so its doc text is gated on the
87 // feature: present in the canonical all-features build, absent — link and
88 // all — from a no-feature `cargo doc`.
89 #[cfg_attr(
90 feature = "netstack",
91 doc = "A [`NetStack`](crate::NetStack) is not visible here: a stack attaches to"
92 )]
93 #[cfg_attr(
94 feature = "netstack",
95 doc = "
96a pending launch after the cage is built, so what this reports is the
97namespace the sandbox creates, not what may later be plugged into it.
98"
99 )]
100 pub network: Network,
101 /// Where the command's three standard streams are wired.
102 pub streams: ResolvedStreams,
103 /// The resource limits applied to the command process, in the order they
104 /// are applied.
105 pub rlimits: Vec<ResolvedRlimit>,
106 /// The hardening controls the command runs under.
107 ///
108 /// Present whether or not the `hardening` feature is compiled in, reporting
109 /// [`Unavailable`](ResolvedHardening::Unavailable) when it is not. A record
110 /// that simply omitted the key could not be told apart from one written
111 /// before the key existed, and a provenance record has to be readable
112 /// without knowing which build wrote it.
113 pub hardening: ResolvedHardening,
114}
115
116/// What a built sandbox's root filesystem is.
117///
118/// The root is a first-order build input on the same argument this module
119/// exists on — it is the filesystem the command sees — and it is not a mount:
120/// a plain rootfs is pivoted into, and an overlay is assembled before every
121/// mount the profile lays over it.
122#[derive(Debug, Clone, PartialEq, Eq)]
123#[non_exhaustive]
124#[cfg_attr(
125 feature = "serde",
126 derive(serde::Serialize),
127 serde(
128 tag = "kind",
129 rename_all = "kebab-case",
130 rename_all_fields = "kebab-case"
131 )
132)]
133pub enum ResolvedRoot {
134 /// No root swap: the command runs against the host's own filesystem.
135 // The sentence links a feature-gated item, so its doc text is gated on the
136 // feature: present in the canonical all-features build, absent — link and
137 // all — from a no-feature `cargo doc`.
138 #[cfg_attr(
139 feature = "hardening",
140 doc = "That is what a [`Restriction`](crate::Restriction)'s command sees."
141 )]
142 Host,
143 /// A plain rootfs, pivoted into.
144 Plain {
145 /// The rootfs as the build resolved it: an absolute, canonicalized host
146 /// path.
147 path: PathBuf,
148 },
149 /// An overlay assembled over the rootfs and pivoted into.
150 Overlay {
151 /// The lower layers, base first — the reverse of the kernel's own
152 /// highest-precedence-first option order.
153 lower: Vec<PathBuf>,
154 /// The upper layer, where the sandbox's writes land.
155 upper: PathBuf,
156 /// The work directory the overlay requires beside the upper.
157 work: PathBuf,
158 },
159}
160
161/// The identity a built sandbox's command holds.
162///
163/// Whether a build sees uid 0 or a mapped id changes what it produces: a
164/// Debian build's `Rules-Requires-Root` handling turns on exactly this, and a
165/// file's recorded ownership follows from it.
166#[derive(Debug, Clone, PartialEq, Eq)]
167#[non_exhaustive]
168#[cfg_attr(
169 feature = "serde",
170 derive(serde::Serialize),
171 serde(
172 tag = "kind",
173 rename_all = "kebab-case",
174 rename_all_fields = "kebab-case"
175 )
176)]
177pub enum ResolvedIdentity {
178 /// No user namespace, so no map: the command runs as the calling user.
179 // Gated for the reason `ResolvedRoot::Host`'s second sentence is.
180 #[cfg_attr(
181 feature = "hardening",
182 doc = "That is what a [`Restriction`](crate::Restriction)'s command holds."
183 )]
184 Caller,
185 /// The single-identity map: the calling user is root inside the sandbox
186 /// and no other id is mapped.
187 Single,
188 /// A range map, written from outside the namespace by a delegate.
189 Ranged {
190 /// The uid extents, in the order they are written.
191 uid: Vec<IdRange>,
192 /// The gid extents, in the order they are written.
193 gid: Vec<IdRange>,
194 },
195}
196
197/// Where a built sandbox wires the command's three standard streams.
198///
199/// What a build produces depends on what the command sees, and these are that:
200/// `isatty` on the standard streams steers debconf's frontend choice, a
201/// compiler's color diagnostics, and every progress display, and the
202/// standard-input disposition additionally decides whether the command runs in
203/// the caller's session.
204///
205/// # What this reports
206///
207/// The plan — what every launch of the sandbox shares. A launch that attaches a
208/// conduit of its own supersedes it at that one call site, in two ways:
209///
210/// - a capturing launch ([`Cage::run_with`](crate::Cage::run_with),
211/// [`Cage::output`](crate::Cage::output)) puts a pipe on the output pair;
212/// - a terminal launch
213/// ([`Cage::spawn_terminal`](crate::Cage::spawn_terminal),
214/// [`Cage::spawn_pending_terminal`](crate::Cage::spawn_pending_terminal))
215/// wires a pseudoterminal replica onto all three, because a terminal is one
216/// stream rather than three.
217///
218/// That act belongs to the caller that made it, and recording it does too. The
219/// dispositions here are refused against a launch that would contradict them,
220/// so a record and a launch cannot disagree about a stream the caller directed
221/// somewhere. [`Inherit`](ResolvedStdio::Inherit) states no destination and so
222/// contradicts nothing: a record reading `Inherit` on all three is what a cage
223/// launched with a terminal reports, and it is a true statement about the plan
224/// rather than about what that launch's command saw.
225#[derive(Debug, Clone, Copy, PartialEq, Eq)]
226#[non_exhaustive]
227#[cfg_attr(feature = "serde", derive(serde::Serialize))]
228pub struct ResolvedStreams {
229 /// Where the command's standard input reads from.
230 pub stdin: ResolvedStdio,
231 /// Where the command's standard output is written.
232 pub stdout: ResolvedStdio,
233 /// Where the command's standard error is written.
234 pub stderr: ResolvedStdio,
235}
236
237/// One standard stream's disposition, by kind.
238///
239/// The counterpart of [`Stdio`](crate::Stdio), reduced to the kind of wiring: a
240/// descriptor is reported as [`Fd`](Self::Fd) and not otherwise described,
241/// because it names a live resource of the caller's rather than anything a
242/// record could carry forward.
243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244#[non_exhaustive]
245#[cfg_attr(
246 feature = "serde",
247 derive(serde::Serialize),
248 serde(rename_all = "lowercase")
249)]
250pub enum ResolvedStdio {
251 /// The stream is left as the launch inherited it.
252 Inherit,
253 /// The stream is wired to `/dev/null`.
254 Null,
255 /// The stream is wired to a descriptor the caller supplied.
256 Fd,
257}
258
259/// One resource limit a built sandbox applies to the command process.
260///
261/// A build that adapts its parallelism to `RLIMIT_NOFILE`, or that fails a link
262/// under `RLIMIT_AS`, produces different output, so the limits in force belong
263/// in the record beside the environment.
264#[derive(Debug, Clone, Copy, PartialEq, Eq)]
265#[non_exhaustive]
266#[cfg_attr(feature = "serde", derive(serde::Serialize))]
267pub struct ResolvedRlimit {
268 /// The kernel resource the limit governs.
269 pub resource: Resource,
270 /// The soft limit: what the kernel enforces.
271 pub soft: Limit,
272 /// The hard limit: the ceiling the command may raise its soft limit to.
273 pub hard: Limit,
274}
275
276/// The hardening controls a built sandbox applies before `execve`.
277///
278/// The subtlest of the postures to record and the one most worth recording: a
279/// seccomp policy changes which syscalls succeed, and a configure test that
280/// probes a syscall reads the refusal as an absent feature, so two builds under
281/// two policies can differ with nothing else to show for it.
282#[derive(Debug, Clone, PartialEq, Eq)]
283#[non_exhaustive]
284#[cfg_attr(
285 feature = "serde",
286 derive(serde::Serialize),
287 serde(
288 tag = "kind",
289 rename_all = "kebab-case",
290 rename_all_fields = "kebab-case"
291 )
292)]
293pub enum ResolvedHardening {
294 /// The hardening layer is not compiled into this build, so no Landlock
295 /// ruleset, seccomp filter, or capability drop can apply.
296 ///
297 /// Distinct from an [`Applied`](Self::Applied) posture whose controls are
298 /// all empty, which is a build that *could* have hardened and did not.
299 Unavailable,
300 /// The hardening layer is compiled in; these are the controls in force.
301 /// Every field empty is a sandbox that applies no hardening.
302 Applied {
303 /// The Landlock filesystem grants, in the order they were declared.
304 landlock_fs: Vec<ResolvedLandlockFs>,
305 /// The Landlock network grants, in the order they were declared.
306 landlock_net: Vec<ResolvedLandlockNet>,
307 /// The installed seccomp filter's length in BPF instructions; `None`
308 /// when no filter is installed.
309 ///
310 /// The program itself is not reported: it is thousands of instructions
311 /// and means nothing to a reader. The length is what distinguishes one
312 /// policy from another at a glance, and a consumer that needs to pin
313 /// the exact program has the policy it supplied.
314 seccomp_instructions: Option<usize>,
315 /// The capability bits retained across the drop; `None` leaves the
316 /// namespaced capability set untouched.
317 keep_capabilities: Option<u64>,
318 },
319}
320
321/// One Landlock filesystem grant a built sandbox enrols.
322#[derive(Debug, Clone, PartialEq, Eq)]
323#[non_exhaustive]
324#[cfg_attr(feature = "serde", derive(serde::Serialize))]
325pub struct ResolvedLandlockFs {
326 /// The granted path, as the command sees it after the root swap.
327 pub path: PathBuf,
328 /// The `LANDLOCK_ACCESS_FS_*` rights allowed beneath the path, as the
329 /// kernel spells them.
330 ///
331 /// This is what the ruleset receives, and it is reported unmasked, before
332 /// the command stage narrows it to the running kernel's ABI.
333 // Gated for the reason `ResolvedRoot::Host`'s second sentence is: the type
334 // is reported whether or not the feature is compiled in, but the type it
335 // contrasts with is not.
336 #[cfg_attr(
337 feature = "hardening",
338 doc = "They are the kernel's own bits rather than [`FsAccess`](crate::FsAccess)'s."
339 )]
340 pub access: u64,
341}
342
343/// One Landlock network grant a built sandbox enrols.
344#[derive(Debug, Clone, Copy, PartialEq, Eq)]
345#[non_exhaustive]
346#[cfg_attr(feature = "serde", derive(serde::Serialize))]
347pub struct ResolvedLandlockNet {
348 /// The TCP port the grant governs.
349 pub port: u16,
350 /// The `LANDLOCK_ACCESS_NET_*` rights allowed on the port, as the kernel
351 /// spells them and before the command stage masks them to its ABI.
352 pub access: u64,
353}
354
355/// One mount a built sandbox will establish.
356///
357/// The resolved counterpart of [`Mount`](crate::Mount): where that records
358/// what a caller declared, this records every mount the sandbox performs,
359/// including the ones the managed profile contributes.
360#[derive(Debug, Clone, PartialEq, Eq)]
361#[non_exhaustive]
362#[cfg_attr(
363 feature = "serde",
364 derive(serde::Serialize),
365 serde(
366 tag = "kind",
367 rename_all = "kebab-case",
368 rename_all_fields = "kebab-case"
369 )
370)]
371pub enum ResolvedMount {
372 /// A tmpfs.
373 Tmpfs {
374 /// The mount point inside the sandbox.
375 target: PathBuf,
376 /// The kernel's raw `MS_*` flags.
377 flags: u64,
378 /// The filesystem data string.
379 data: String,
380 },
381 /// A procfs instance.
382 Procfs {
383 /// The mount point inside the sandbox.
384 target: PathBuf,
385 },
386 /// A devpts instance.
387 Devpts {
388 /// The mount point inside the sandbox.
389 target: PathBuf,
390 /// The filesystem data string.
391 data: String,
392 },
393 /// A bind of a host path.
394 Bind {
395 /// The host path bound in.
396 source: PathBuf,
397 /// The mount point inside the sandbox.
398 target: PathBuf,
399 /// Whether the bind is remounted read-only.
400 read_only: bool,
401 },
402 /// A mount whose parameters go to the kernel verbatim.
403 ///
404 /// The optional fields report `None` both for a parameter that was never
405 /// set and for one set to the empty string: the two are frozen alike, and
406 /// the kernel receives the same nothing from either.
407 Raw {
408 /// The mount source, when one was given.
409 source: Option<PathBuf>,
410 /// The mount point inside the sandbox.
411 target: PathBuf,
412 /// The filesystem type, when one was given.
413 fstype: Option<String>,
414 /// The kernel's raw `MS_*` flags.
415 flags: u64,
416 /// The filesystem data string, when one was given.
417 data: Option<String>,
418 },
419 /// A symlink created inside the rootfs.
420 ///
421 /// Not a mount in the kernel's sense, but part of what the managed profile
422 /// establishes — the five `/dev` symlinks arrive this way — and so part of
423 /// the filesystem the command sees.
424 Symlink {
425 /// The link's own path inside the sandbox.
426 path: PathBuf,
427 /// The path the link points at, as written.
428 target: PathBuf,
429 },
430}
431
432impl ResolvedMount {
433 /// The path inside the sandbox this mount is established at.
434 ///
435 /// For a [`Symlink`](Self::Symlink) this is the link itself, not the path
436 /// it points at.
437 pub fn get_target(&self) -> &std::path::Path {
438 match self {
439 ResolvedMount::Tmpfs { target, .. }
440 | ResolvedMount::Procfs { target }
441 | ResolvedMount::Devpts { target, .. }
442 | ResolvedMount::Bind { target, .. }
443 | ResolvedMount::Raw { target, .. } => target,
444 ResolvedMount::Symlink { path, .. } => path,
445 }
446 }
447}
448
449impl ResolvedInputs {
450 /// Projects a frozen launch plan onto the public shape.
451 ///
452 /// Runs on the caller's own thread, on demand: nothing here is computed
453 /// during a launch, and a [`Cage`](crate::Cage) does not carry the result.
454 pub(crate) fn project(plan: &LaunchPlan) -> ResolvedInputs {
455 ResolvedInputs {
456 root: project_root(plan),
457 env: plan.env.iter().map(split_env).collect(),
458 mounts: plan
459 .ops
460 .iter()
461 .map(|op| project_mount(&op.action))
462 .collect(),
463 identity: project_identity(plan),
464 network: plan.network,
465 streams: ResolvedStreams {
466 stdin: project_stdio(&plan.stdin),
467 stdout: project_stdio(&plan.stdout),
468 stderr: project_stdio(&plan.stderr),
469 },
470 rlimits: plan.rlimits.iter().map(project_rlimit).collect(),
471 hardening: project_hardening(plan),
472 }
473 }
474}
475
476/// Projects the sandbox's root filesystem.
477///
478/// The overlay is reported from the plan's own overlay rather than from `ops`
479/// because it is not in them: the setup establishes it before the profile's
480/// mounts, over the rootfs the plan names, which is the base lower.
481fn project_root(plan: &LaunchPlan) -> ResolvedRoot {
482 match plan.confinement {
483 Confinement::Container => match &plan.overlay {
484 Some(overlay) => ResolvedRoot::Overlay {
485 lower: overlay.lower.clone(),
486 upper: overlay.upper.clone(),
487 work: overlay.work.clone(),
488 },
489 None => ResolvedRoot::Plain {
490 path: PathBuf::from(OsString::from_vec(plan.rootfs_path.as_bytes().to_vec())),
491 },
492 },
493 #[cfg(feature = "hardening")]
494 Confinement::Restriction => ResolvedRoot::Host,
495 }
496}
497
498/// Projects one stream's disposition onto its kind.
499fn project_stdio(plan: &crate::mechanism::StdioPlan) -> ResolvedStdio {
500 match plan {
501 crate::mechanism::StdioPlan::Inherit => ResolvedStdio::Inherit,
502 crate::mechanism::StdioPlan::Null => ResolvedStdio::Null,
503 crate::mechanism::StdioPlan::Fd(_) => ResolvedStdio::Fd,
504 }
505}
506
507/// Projects the identity the command holds.
508///
509/// A restriction creates no user namespace, so its plan's `Single` map is a
510/// placeholder rather than a map that is applied; the confinement is what
511/// distinguishes the two.
512fn project_identity(plan: &LaunchPlan) -> ResolvedIdentity {
513 match plan.confinement {
514 #[cfg(feature = "hardening")]
515 Confinement::Restriction => ResolvedIdentity::Caller,
516 Confinement::Container => match &plan.identity {
517 IdentityPlan::Single => ResolvedIdentity::Single,
518 IdentityPlan::Ranged { map, .. } => ResolvedIdentity::Ranged {
519 uid: map.uid().to_vec(),
520 gid: map.gid().to_vec(),
521 },
522 },
523 }
524}
525
526/// Projects one frozen resource limit.
527///
528/// The plan carries the resource the caller named alongside the kernel number
529/// it was lowered to, so this reads it back rather than searching for a
530/// resource that translates to the same number. A provenance record is the one
531/// value that cannot afford to answer approximately, and a reverse lookup has
532/// no honest answer when it finds nothing.
533fn project_rlimit(plan: &crate::mechanism::RlimitPlan) -> ResolvedRlimit {
534 ResolvedRlimit {
535 resource: plan.resource,
536 soft: plan.limit.current.map_or(Limit::UNLIMITED, Limit::of),
537 hard: plan.limit.maximum.map_or(Limit::UNLIMITED, Limit::of),
538 }
539}
540
541/// Projects the hardening posture.
542///
543/// Always reports something: without the feature there is nothing that could
544/// have been applied, which is itself the fact a provenance record needs.
545fn project_hardening(plan: &LaunchPlan) -> ResolvedHardening {
546 #[cfg(not(feature = "hardening"))]
547 {
548 let _ = plan;
549 ResolvedHardening::Unavailable
550 }
551 #[cfg(feature = "hardening")]
552 {
553 ResolvedHardening::Applied {
554 landlock_fs: plan
555 .hardening
556 .landlock
557 .iter()
558 .map(|rule| ResolvedLandlockFs {
559 path: PathBuf::from(OsString::from_vec(rule.path.as_bytes().to_vec())),
560 access: rule.access,
561 })
562 .collect(),
563 landlock_net: plan
564 .hardening
565 .landlock_net
566 .iter()
567 .map(|rule| ResolvedLandlockNet {
568 port: rule.port,
569 access: rule.access,
570 })
571 .collect(),
572 seccomp_instructions: plan.hardening.seccomp.as_ref().map(Vec::len),
573 keep_capabilities: plan.hardening.keep_caps,
574 }
575 }
576}
577
578/// Splits a frozen `NAME=value` entry back into its pair.
579///
580/// The environment is lowered to `NAME=value` byte strings at build time
581/// because that is what `execve` takes. Splitting at the first `=` inverts
582/// that exactly: a name may not contain `=` — the builder rejects one that
583/// does — so the first separator is always the right one, and a value
584/// containing `=` is preserved whole.
585fn split_env(entry: &CString) -> (OsString, OsString) {
586 let bytes = entry.as_bytes();
587 match bytes.iter().position(|byte| *byte == b'=') {
588 Some(index) => (
589 OsString::from_vec(bytes[..index].to_vec()),
590 OsString::from_vec(bytes[index + 1..].to_vec()),
591 ),
592 // Unreachable through the builder, which composes every entry with a
593 // separator. Reporting the whole entry as a name beats panicking in an
594 // inspection method.
595 None => (OsString::from_vec(bytes.to_vec()), OsString::new()),
596 }
597}
598
599/// Projects one internal mount action onto the public shape.
600fn project_mount(action: &MountAction) -> ResolvedMount {
601 match action {
602 MountAction::Tmpfs {
603 target,
604 flags,
605 data,
606 } => ResolvedMount::Tmpfs {
607 target: absolute(target),
608 // Widened to match `RawMount::get_flags`, which is `u64` so the
609 // public surface does not depend on the kernel type's width.
610 flags: u64::from(flags.bits()),
611 data: lossy(data),
612 },
613 MountAction::Procfs { target } => ResolvedMount::Procfs {
614 target: absolute(target),
615 },
616 MountAction::Devpts { target, data } => ResolvedMount::Devpts {
617 target: absolute(target),
618 data: lossy(data),
619 },
620 MountAction::Bind {
621 source,
622 target,
623 read_only,
624 } => ResolvedMount::Bind {
625 source: PathBuf::from(OsString::from_vec(source.as_bytes().to_vec())),
626 target: absolute(target),
627 read_only: *read_only,
628 },
629 MountAction::Raw {
630 source,
631 target,
632 fstype,
633 flags,
634 data,
635 } => ResolvedMount::Raw {
636 // An unset source, fstype, or data is frozen as an empty string,
637 // which is what the kernel receives either way, so the projection
638 // reads empty back as absent.
639 source: optional(source).map(PathBuf::from),
640 target: absolute(target),
641 fstype: optional(fstype).map(|value| value.to_string_lossy().into_owned()),
642 // Widened to match `RawMount::get_flags`, which is `u64` so the
643 // public surface does not depend on the kernel type's width.
644 flags: u64::from(flags.bits()),
645 data: optional(data).map(|value| value.to_string_lossy().into_owned()),
646 },
647 MountAction::Symlink {
648 parent,
649 leaf,
650 content,
651 } => {
652 let mut path = absolute(parent);
653 path.push(OsString::from_vec(leaf.as_bytes().to_vec()));
654 ResolvedMount::Symlink {
655 path,
656 target: PathBuf::from(OsString::from_vec(content.as_bytes().to_vec())),
657 }
658 }
659 }
660}
661
662/// Turns a rootfs-relative frozen target into the absolute path it occupies
663/// inside the sandbox, which is how a consumer reading the record thinks of
664/// it.
665fn absolute(target: &CString) -> PathBuf {
666 let mut path = Vec::with_capacity(target.as_bytes().len() + 1);
667 path.push(b'/');
668 path.extend_from_slice(target.as_bytes());
669 PathBuf::from(OsString::from_vec(path))
670}
671
672/// Reads a frozen optional field: empty means it was never set.
673fn optional(value: &CString) -> Option<OsString> {
674 if value.as_bytes().is_empty() {
675 None
676 } else {
677 Some(OsString::from_vec(value.as_bytes().to_vec()))
678 }
679}
680
681/// Reads a frozen data string. These are composed by the crate itself from
682/// ASCII literals, so the conversion never actually loses anything.
683fn lossy(value: &CString) -> String {
684 String::from_utf8_lossy(value.as_bytes()).into_owned()
685}
686
687#[cfg(test)]
688mod tests {
689 use super::*;
690
691 /// A `NAME=value` entry, as the builder freezes them.
692 fn entry(text: &str) -> CString {
693 CString::new(text).unwrap()
694 }
695
696 #[test]
697 fn a_value_containing_an_equals_sign_survives_the_split() {
698 // Only the first separator is the separator: a name cannot contain
699 // `=` — the builder refuses one that does — so everything after the
700 // first is value.
701 let (name, value) = split_env(&entry("CFLAGS=-DA=1 -DB=2"));
702 assert_eq!(name, OsString::from("CFLAGS"));
703 assert_eq!(value, OsString::from("-DA=1 -DB=2"));
704 }
705
706 #[test]
707 fn an_empty_value_round_trips() {
708 let (name, value) = split_env(&entry("EMPTY="));
709 assert_eq!(name, OsString::from("EMPTY"));
710 assert_eq!(value, OsString::new());
711 }
712
713 #[test]
714 fn a_rootfs_relative_target_is_reported_as_an_absolute_path() {
715 // Targets are frozen relative to the rootfs, because that is what the
716 // sandbox stage resolves them against; a consumer reading the record
717 // wants the path as the command sees it.
718 assert_eq!(absolute(&entry("dev/pts")), PathBuf::from("/dev/pts"));
719 assert_eq!(absolute(&entry("tmp")), PathBuf::from("/tmp"));
720 }
721
722 #[test]
723 fn an_unset_raw_field_reads_back_as_absent() {
724 assert_eq!(optional(&entry("")), None);
725 assert_eq!(optional(&entry("tmpfs")), Some(OsString::from("tmpfs")));
726 }
727}
728
729#[cfg(all(test, feature = "serde"))]
730mod serde_tests {
731 use crate::scratch::Scratch;
732 use crate::{Cage, RawMount};
733
734 /// The record a consumer stamps into provenance is a TOML document, and
735 /// its shape is what that consumer's own format has to accommodate.
736 #[test]
737 fn the_record_renders_as_a_provenance_document() {
738 // Both opt-outs, which is the combination a reproducibility-focused
739 // consumer runs and which keeps the document short enough to assert
740 // whole. The managed profile's own contribution is covered below. The
741 // raw mount is the procfs the nested user namespace's identity map is
742 // established through, which an opted-out profile declares for itself.
743 let cage = Cage::builder()
744 .rootfs("/tmp")
745 .managed_mounts(false)
746 .base_env(false)
747 .command("/bin/sh")
748 .env("SOURCE_DATE_EPOCH", "1700000000")
749 .bind_ro("/etc", "/usr/src")
750 .raw_mount(RawMount::new("/proc").fstype("proc").flags(2))
751 .build()
752 .expect("a valid sandbox configuration");
753 let rendered = toml::to_string(&cage.resolved_inputs()).expect("the record serializes");
754 // The hardening block is the one part that depends on the build rather
755 // than on the configuration, which is the whole point of the field
756 // being present either way.
757 #[cfg(feature = "hardening")]
758 let hardening = "[hardening]\nkind = \"applied\"\nlandlock-fs = []\nlandlock-net = []\n";
759 #[cfg(not(feature = "hardening"))]
760 let hardening = "[hardening]\nkind = \"unavailable\"\n";
761 // TOML puts bare keys ahead of tables, so `network` and `rlimits` lead
762 // whatever order the struct declares; the rest follow in declaration
763 // order. A `None` renders as an absent key, which is what TOML has.
764 assert_eq!(
765 rendered,
766 "network = \"isolated\"\n\
767 rlimits = []\n\
768 \n\
769 [root]\n\
770 kind = \"plain\"\n\
771 path = \"/tmp\"\n\
772 \n\
773 [env]\n\
774 SOURCE_DATE_EPOCH = \"1700000000\"\n\
775 \n\
776 [[mounts]]\n\
777 kind = \"bind\"\n\
778 source = \"/etc\"\n\
779 target = \"/usr/src\"\n\
780 read-only = true\n\
781 \n\
782 [[mounts]]\n\
783 kind = \"raw\"\n\
784 target = \"/proc\"\n\
785 fstype = \"proc\"\n\
786 flags = 2\n\
787 \n\
788 [identity]\n\
789 kind = \"single\"\n\
790 \n\
791 [streams]\n\
792 stdin = \"inherit\"\n\
793 stdout = \"inherit\"\n\
794 stderr = \"inherit\"\n\
795 \n"
796 .to_string()
797 + hardening,
798 );
799 }
800
801 #[test]
802 fn a_plain_root_and_an_overlay_root_produce_different_records() {
803 // The whole complaint this field answers: `mounts` is projected from
804 // the plan's mount actions, and the root is not one of them, so before
805 // this a plain root and an overlay over the same base recorded byte for
806 // byte the same thing while describing two different builds.
807 let scratch = Scratch::new("resolved-overlay");
808 let base = scratch.join("base");
809 let patches = scratch.join("patches");
810 std::fs::create_dir_all(&base).expect("the base is creatable");
811 std::fs::create_dir_all(&patches).expect("the patch layer is creatable");
812
813 let plain = Cage::builder()
814 .rootfs(&base)
815 .managed_mounts(false)
816 // The procfs the nested user namespace's identity map is
817 // established through, which an opted-out profile declares itself.
818 .bind("/proc", "/proc")
819 .command("/bin/sh")
820 .build()
821 .expect("a plain root over the same base builds")
822 .resolved_inputs();
823 let built = Cage::builder()
824 .overlay(
825 crate::Overlay::new()
826 .lower(&base)
827 .lower(&patches)
828 .upper(scratch.join("upper")),
829 )
830 .managed_mounts(false)
831 // The procfs the nested user namespace's identity map is
832 // established through, which an opted-out profile declares itself.
833 .bind("/proc", "/proc")
834 .command("/bin/sh")
835 .build();
836 let Ok(cage) = built else {
837 // A host that cannot establish an unprivileged overlay refuses the
838 // build; there is nothing to record and nothing to assert.
839 eprintln!("skipping: an unprivileged overlay is unavailable here");
840 return;
841 };
842 let layered = cage.resolved_inputs();
843
844 assert_ne!(plain.root, layered.root, "the two roots record differently");
845 assert_eq!(plain.mounts, layered.mounts, "and it is not the mounts");
846
847 let crate::ResolvedRoot::Plain { path } = &plain.root else {
848 panic!("a rootfs records as a plain root: {:?}", plain.root);
849 };
850 assert!(path.ends_with("base"), "{path:?}");
851
852 let crate::ResolvedRoot::Overlay { lower, upper, .. } = &layered.root else {
853 panic!("an overlay records as one: {:?}", layered.root);
854 };
855 assert_eq!(lower.len(), 2, "both lowers are recorded: {lower:?}");
856 assert!(lower[0].ends_with("base"), "{lower:?}");
857 assert!(lower[1].ends_with("patches"), "{lower:?}");
858 assert!(upper.ends_with("upper"), "{upper:?}");
859 }
860
861 #[test]
862 fn every_posture_the_launch_applies_reaches_the_record() {
863 // One cage carrying all four postures at once, so a field that stopped
864 // being projected shows up here rather than in a consumer's provenance.
865 let cage = Cage::builder()
866 .rootfs("/tmp")
867 .command("/bin/sh")
868 .managed_mounts(false)
869 // The procfs the nested user namespace's identity map is
870 // established through, which an opted-out profile declares itself.
871 .bind("/proc", "/proc")
872 .base_env(false)
873 .network(crate::Network::Host)
874 .rlimit(crate::limits::Resource::OpenFiles, 1024, 4096)
875 .rlimit(
876 crate::limits::Resource::Processes,
877 64,
878 crate::limits::Limit::UNLIMITED,
879 )
880 .build()
881 .expect("a valid sandbox configuration");
882 let inputs = cage.resolved_inputs();
883
884 assert_eq!(inputs.network, crate::Network::Host);
885 assert_eq!(inputs.identity, crate::ResolvedIdentity::Single);
886 assert_eq!(
887 inputs.rlimits,
888 [
889 crate::ResolvedRlimit {
890 resource: crate::limits::Resource::OpenFiles,
891 soft: crate::limits::Limit::of(1024),
892 hard: crate::limits::Limit::of(4096),
893 },
894 crate::ResolvedRlimit {
895 resource: crate::limits::Resource::Processes,
896 soft: crate::limits::Limit::of(64),
897 hard: crate::limits::Limit::UNLIMITED,
898 },
899 ],
900 );
901 }
902
903 /// A hardening posture reaches the record: the grants by path and port, and
904 /// the seccomp filter by the one property that distinguishes two policies
905 /// without dumping thousands of instructions into a provenance document.
906 #[cfg(feature = "hardening")]
907 #[test]
908 fn a_hardening_posture_records_its_grants_and_its_filter_size() {
909 use crate::{FsAccess, NetAccess, SeccompPolicy};
910
911 let cage = Cage::builder()
912 .rootfs("/tmp")
913 .command("/bin/sh")
914 .managed_mounts(false)
915 // The procfs the nested user namespace's identity map is
916 // established through, which an opted-out profile declares itself.
917 .bind("/proc", "/proc")
918 .landlock_fs(FsAccess::READ, "/usr")
919 .landlock_net(NetAccess::CONNECT, 443)
920 .seccomp(SeccompPolicy::Curated)
921 .build()
922 .expect("a valid sandbox configuration");
923 let crate::ResolvedHardening::Applied {
924 landlock_fs,
925 landlock_net,
926 seccomp_instructions,
927 ..
928 } = cage.resolved_inputs().hardening
929 else {
930 panic!("a build with the feature reports an applied posture");
931 };
932
933 assert_eq!(landlock_fs.len(), 1);
934 assert_eq!(landlock_fs[0].path, std::path::Path::new("/usr"));
935 assert_ne!(landlock_fs[0].access, 0, "the granted rights are recorded");
936 assert_eq!(landlock_net.len(), 1);
937 assert_eq!(landlock_net[0].port, 443);
938 assert!(
939 seccomp_instructions.is_some_and(|count| count > 0),
940 "the curated policy compiles to a program: {seccomp_instructions:?}",
941 );
942 }
943
944 /// A sandbox that hardens nothing still reports the layer as available.
945 ///
946 /// The distinction the always-present field exists for: this is a build
947 /// that could have hardened and did not, which is not the same fact as a
948 /// build that could not.
949 #[cfg(feature = "hardening")]
950 #[test]
951 fn a_sandbox_that_hardens_nothing_is_not_a_build_that_cannot() {
952 let inputs = Cage::builder()
953 .rootfs("/tmp")
954 .command("/bin/sh")
955 .managed_mounts(false)
956 // The procfs the nested user namespace's identity map is
957 // established through, which an opted-out profile declares itself.
958 .bind("/proc", "/proc")
959 .build()
960 .expect("a valid sandbox configuration")
961 .resolved_inputs();
962 assert!(
963 matches!(inputs.hardening, crate::ResolvedHardening::Applied { .. }),
964 "{:?}",
965 inputs.hardening,
966 );
967 }
968
969 /// Without the feature the record says so, rather than saying nothing.
970 #[cfg(not(feature = "hardening"))]
971 #[test]
972 fn a_build_without_the_hardening_layer_records_that_it_has_none() {
973 let inputs = Cage::builder()
974 .rootfs("/tmp")
975 .command("/bin/sh")
976 .managed_mounts(false)
977 // The procfs the nested user namespace's identity map is
978 // established through, which an opted-out profile declares itself.
979 .bind("/proc", "/proc")
980 .build()
981 .expect("a valid sandbox configuration")
982 .resolved_inputs();
983 assert_eq!(inputs.hardening, crate::ResolvedHardening::Unavailable);
984 }
985
986 #[test]
987 fn the_managed_profile_records_its_symlinks() {
988 // The five /dev symlinks are the part of the default profile no other
989 // accessor can report, and the reason `get_mount_dev()` alone does not
990 // tell a consumer what `/dev` will contain.
991 let inputs = Cage::builder()
992 .rootfs("/tmp")
993 .command("/bin/sh")
994 .build()
995 .expect("a valid sandbox configuration")
996 .resolved_inputs();
997 let rendered = toml::to_string(&inputs).expect("the record serializes");
998 for link in ["stdin", "stdout", "stderr", "fd", "ptmx"] {
999 assert!(
1000 rendered.contains(&format!("path = \"/dev/{link}\"")),
1001 "missing /dev/{link} in:\n{rendered}",
1002 );
1003 }
1004 }
1005}