Skip to main content

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}