Skip to main content

capsule_runtime/
sealed.rs

1//! The `sealed` containment mechanism: a private mount namespace pivoted onto a composed root.
2//!
3//! `scoped` (Landlock + seccomp, see [`crate::sandbox`]) can deliver "nothing outside the workdir
4//! is readable or writable". It can never deliver "nothing outside the workdir *exists*", because
5//! Landlock is an access filter — it does not change what a process can see, name or traverse. A
6//! mount namespace can, and that is the whole of what this module adds.
7//!
8//! ## What the mechanism is
9//!
10//! Inside the forked child's `pre_exec` window — before fd hygiene, before the capability drop,
11//! before the seccomp filter, and before the Landlock ruleset — the child:
12//!
13//!   1. `unshare(CLONE_NEWUSER | CLONE_NEWNS)`, then maps its own uid/gid one-to-one into the new
14//!      user namespace (`/proc/self/uid_map`), which is what gives it `CAP_SYS_ADMIN` *inside that
15//!      namespace* and nowhere else. Nothing here needs the host's root.
16//!
17//!      On every kernel tier this now runs *after* [`crate::network_namespace`] has already
18//!      created a user+network namespace, so the one built here is **nested** inside it. That is
19//!      load-bearing rather than incidental: a task in a descendant user namespace holds no
20//!      capability in the ancestor, so a `sealed` capsule cannot reconfigure the network namespace
21//!      it is confined to.
22//!   2. Makes mount propagation private (`MS_REC | MS_PRIVATE` on `/`), so nothing it does leaks
23//!      back into the host's mount table.
24//!   3. Mounts a fresh `tmpfs` over a base directory ([`choose_root_base`]) and composes the new
25//!      root inside it: the host runtime directories ([`SEALED_RUNTIME_PATHS`]) and a fixed,
26//!      narrow `/etc` set ([`SEALED_ETC_PATHS`]) bind-mounted **read-only**, the session workdir
27//!      bind-mounted **read-write at its own absolute path**, a private `/dev` tmpfs carrying the
28//!      OCI default device set ([`SEALED_DEVICE_NODES`]), and a `/proc` — masked with `hidepid`
29//!      where the kernel permits it, bound from the host where it does not
30//!      ([`PROC_HIDEPID_OPTIONS`] documents which is which, and why).
31//!   4. `pivot_root`s onto it, detaches the old root, remounts the new root read-only, and
32//!      `chdir`s back into the workdir.
33//!
34//! Everything outside that root is then not merely access-denied — it is *absent*. There is no
35//! pathname for `/etc/shadow`, for a Docker socket, or for `/dev/sda`, so the escape classes that
36//! `scoped` has to enumerate and refuse (pathname unix sockets, device nodes, metadata visibility,
37//! stdlib directory enumeration) stop being reachable rather than being individually denied.
38//!
39//! `/proc` is the one exception to that sentence: mounting a private `procfs` unprivileged needs a
40//! PID namespace this module deliberately does not create, so on a bare host the composed root
41//! carries a bind of the host's `/proc` and
42//! host process metadata stays *visible* there (opens through it are still Landlock's to refuse).
43//! See [`PROC_HIDEPID_OPTIONS`].
44//!
45//! ## What it does *not* replace
46//!
47//! Landlock and seccomp still install afterwards, now scoped to the private root. `sealed` is
48//! defence in depth over `scoped`'s mechanism, not a substitute for it: the syscall allowlist is
49//! still the only thing standing between a capsule and `io_uring`, `bpf` or `ptrace`, none of
50//! which a mount namespace has an opinion about.
51//!
52//! ## Why the plan is precomputed
53//!
54//! Everything the child does is driven by [`SealedRootSpec`], a fully-resolved list of
55//! NUL-terminated paths built in the **parent**, before `fork()`. The `pre_exec` window permits
56//! only async-signal-safe work: allocating there (a `format!`, a `PathBuf::join`) can deadlock on
57//! the allocator lock a *different* thread of the parent held at fork time. Splitting the decision
58//! ([`plan_composed_root`], pure and unit-testable against a fake host layout) from the execution
59//! ([`construct_composed_root`], raw syscalls over already-built `CString`s) is what keeps the
60//! child side allocation-free on its success path — and it is what makes the composed-root layout
61//! testable at all on a host that cannot create a namespace.
62
63// The composed-root planner and the synthetic `/etc` content are pure so they stay unit-tested on
64// every OS, but outside tests only the Linux `linux` submodule and `sandbox`'s Linux enforcement
65// call them, so off Linux they are dead by construction.
66#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
67
68use std::path::{Path, PathBuf};
69
70// ---------------------------------------------------------------- AppArmor
71
72/// Name of the AppArmor profile this runtime ships, as it appears in
73/// `/sys/kernel/security/apparmor/profiles` and in `/proc/self/attr/current`.
74///
75/// On a host where AppArmor's `restrict_unprivileged_userns` knob is on (Ubuntu 23.10+ and
76/// derivatives), an unconfined process's `unshare(CLONE_NEWUSER)` is transitioned into the
77/// restricted `unprivileged_userns` profile, which denies `CAP_SYS_ADMIN` and therefore every
78/// `mount(2)` that follows. Loading this profile — which attaches to the `mur` binary and carries
79/// `userns,` — is what lifts that, exactly as Ubuntu's own shipped profiles do for Chrome and
80/// Firefox.
81pub const SEALED_APPARMOR_PROFILE_NAME: &str = "mur-sealed";
82
83/// Where [`scripts/install.sh`](../../../scripts/install.sh) installs the shipped profile, and the
84/// path the refusal message tells an operator to load.
85pub const SEALED_APPARMOR_PROFILE_PATH: &str = "/etc/apparmor.d/mur-sealed";
86
87/// SHA-256 (lowercase hex) of `packaging/apparmor/mur-sealed` as this build ships it.
88///
89/// A literal rather than an `include_str!` digest: `capsule-runtime` is published to crates.io,
90/// `cargo package` copies only files under the crate's own directory, and a path escaping the
91/// crate root compiles in the workspace and then fails the packaging verification build. The unit
92/// test `shipped_profile_digest_constant_matches_the_file` reads the real file through
93/// `CARGO_MANIFEST_DIR` and fails with the digest to paste in here — that test does not run during
94/// `cargo package`, so publishing stays green while a profile edit that forgot this constant is
95/// caught by `cargo test`.
96///
97/// Compares *file bytes*. It says nothing about what `apparmor_parser` has actually loaded — see
98/// [`classify_installed_profile`].
99pub const SEALED_APPARMOR_PROFILE_SHA256: &str =
100    "1669f6c0038dddea393cfacd95b078ac99ec718a538c8ebb52701f6ba686a892";
101
102/// Where this host's permission to create an unprivileged user namespace comes from.
103///
104/// Three of the four variants permit the namespace, and they are not interchangeable: AppArmor
105/// being absent is a distribution fact nobody chose, the shipped profile confining `mur` is the
106/// configuration murmur ships, and the restriction being switched off host-wide removes the
107/// hardening for *every* binary on the machine. The three reach the same achieved class, so
108/// `mur doctor`, `--explain-scope` and the session trace each report the variant: without it a
109/// `sealed` result obtained on a weakened host cannot be told apart from one obtained through the
110/// profile.
111///
112/// The grant never decides anything: whether a namespace, a private `/tmp` or `sealed` is
113/// available is decided by [`crate::namespace_probe`] creating them. The grant only words a
114/// refusal once an attempt has failed.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize)]
116#[serde(rename_all = "snake_case")]
117pub enum UsernsGrant {
118    /// AppArmor is not enabled on this host (Fedora, Arch, a kernel built without it, …). There is
119    /// no restriction to lift and no profile to install.
120    ApparmorAbsent,
121    /// AppArmor is enabled but `kernel.apparmor_restrict_unprivileged_userns` is `0`, so no binary
122    /// on this host is transitioned into the restricted `unprivileged_userns` profile. Legitimate,
123    /// and on some hosts the only option — but it is not the configuration murmur ships, and it is
124    /// what `W-SEC-013` reports.
125    RestrictionDisabledHostWide,
126    /// The restriction is on and this process is confined by a profile whose name begins with
127    /// [`SEALED_APPARMOR_PROFILE_NAME`] — the shipped profile, or the checkout profile
128    /// `scripts/install-dev-apparmor.sh` generates. The grant is narrow: it applies to this binary
129    /// and nothing else.
130    ProfileConfining,
131    /// The restriction is on and nothing grants this binary anything. A container whose own
132    /// profile permits user namespaces reads this too, and is not refused for it. The default, so
133    /// an unprobed host claims nothing.
134    #[default]
135    Withheld,
136}
137
138impl UsernsGrant {
139    /// Every variant, so a caller reasoning about "which grant did this host give" cannot silently
140    /// miss one. Same convention as [`SealedBlocker::ALL`].
141    pub const ALL: &'static [UsernsGrant] = &[
142        UsernsGrant::ApparmorAbsent,
143        UsernsGrant::RestrictionDisabledHostWide,
144        UsernsGrant::ProfileConfining,
145        UsernsGrant::Withheld,
146    ];
147
148    /// Stable wire name, as it appears in `--explain-scope --json`, in `session_start` and in
149    /// `mur doctor`. Distinct per variant, and not a `Debug` rendering, which is free to change.
150    #[must_use]
151    pub fn wire_name(self) -> &'static str {
152        match self {
153            UsernsGrant::ApparmorAbsent => "apparmor_absent",
154            UsernsGrant::RestrictionDisabledHostWide => "restriction_disabled_host_wide",
155            UsernsGrant::ProfileConfining => "profile_confining",
156            UsernsGrant::Withheld => "withheld",
157        }
158    }
159
160    /// One line naming the mechanism in words, for the `mur doctor` block. States what the grant
161    /// covers — one binary or the whole host — because that is the difference the wire name alone
162    /// does not spell out.
163    #[must_use]
164    pub fn summary(self) -> &'static str {
165        match self {
166            UsernsGrant::ApparmorAbsent => {
167                "AppArmor is not enabled on this host, so nothing restricts unprivileged user \
168                 namespaces and no profile is needed"
169            }
170            UsernsGrant::RestrictionDisabledHostWide => {
171                "kernel.apparmor_restrict_unprivileged_userns is off, so unprivileged user \
172                 namespaces are unrestricted for every binary on this host, not just for mur"
173            }
174            UsernsGrant::ProfileConfining => {
175                "the restriction is on and the mur-sealed AppArmor profile is confining this \
176                 binary, so the grant covers mur alone — the configuration murmur ships"
177            }
178            UsernsGrant::Withheld => {
179                "the restriction is on and no mur-sealed AppArmor profile is confining this \
180                 binary, so nothing murmur ships grants it a user namespace"
181            }
182        }
183    }
184
185    /// The whole decision, over the three host readings that produce it: whether AppArmor is
186    /// enabled, whether its `restrict_unprivileged_userns` knob is on, and what profile confines
187    /// this process. Each argument is the file's trimmed contents, or `None` when it is missing or
188    /// empty.
189    ///
190    /// Pure, and separate from the reading for the same reason [`classify_installed_profile`] is:
191    /// every combination is then testable on any OS, with no `/sys` and no `/proc`.
192    /// `linux::userns_grant` is the reader and holds no logic of its own.
193    ///
194    /// A missing or unreadable restriction knob counts as *not restricted*, not as withheld — a
195    /// kernel that does not implement the knob does not restrict anything.
196    #[must_use]
197    pub fn from_readings(
198        enabled: Option<&str>,
199        restricted: Option<&str>,
200        current_profile: Option<&str>,
201    ) -> UsernsGrant {
202        if !matches!(enabled, Some("Y") | Some("1")) {
203            return UsernsGrant::ApparmorAbsent;
204        }
205        if !matches!(restricted, Some("Y") | Some("1")) {
206            return UsernsGrant::RestrictionDisabledHostWide;
207        }
208        match current_profile {
209            Some(profile) if profile.starts_with(SEALED_APPARMOR_PROFILE_NAME) => {
210                UsernsGrant::ProfileConfining
211            }
212            _ => UsernsGrant::Withheld,
213        }
214    }
215}
216
217/// How `/etc/apparmor.d/mur-sealed` on this host compares to the profile this build ships.
218///
219/// A *file-level* finding and deliberately nothing more. AppArmor loads profiles from the kernel's
220/// own policy cache, not from this path at call time, so a file can be edited without
221/// `apparmor_parser -r` ever running and a profile can be loaded from a file that has since been
222/// deleted. [`UsernsGrant`] is the behavioural answer and stays the source of truth; this only
223/// tells an operator whether the bytes on disk are the bytes murmur ships.
224#[derive(Debug, Clone, PartialEq, Eq)]
225pub enum InstalledProfileState {
226    /// The installed file hashes to [`SEALED_APPARMOR_PROFILE_SHA256`].
227    Matches,
228    /// The file is present and hashes to something else — an older revision, or an edited copy.
229    /// Operator customisation belongs in `/etc/apparmor.d/local/mur-sealed`, which both shipped
230    /// profiles `include if exists` and which is not hashed here.
231    Drifted {
232        /// Lowercase hex SHA-256 of the bytes actually on disk.
233        installed_sha256: String,
234    },
235    /// No file at [`SEALED_APPARMOR_PROFILE_PATH`]. Expected on a host without AppArmor, and on a
236    /// checkout build using `scripts/install-dev-apparmor.sh`, which writes its own separate file.
237    Absent,
238    /// The path exists and could not be read — a permission or I/O error, kept apart from
239    /// [`Self::Absent`] so "I could not look" never reads as "it is not there".
240    Unreadable {
241        /// The `std::io::Error`, rendered.
242        error: String,
243    },
244}
245
246/// Classifies a read of [`SEALED_APPARMOR_PROFILE_PATH`] against the shipped digest.
247///
248/// Takes the already-performed read rather than the path, so all four outcomes are unit-testable
249/// with no filesystem mutation and no privilege.
250#[must_use]
251pub fn classify_installed_profile(read: Result<Vec<u8>, std::io::Error>) -> InstalledProfileState {
252    match read {
253        Ok(bytes) => {
254            let installed_sha256 = murmur_artifact::sha256_hex(&bytes);
255            if installed_sha256 == SEALED_APPARMOR_PROFILE_SHA256 {
256                InstalledProfileState::Matches
257            } else {
258                InstalledProfileState::Drifted { installed_sha256 }
259            }
260        }
261        Err(error) if error.kind() == std::io::ErrorKind::NotFound => InstalledProfileState::Absent,
262        Err(error) => InstalledProfileState::Unreadable {
263            error: error.to_string(),
264        },
265    }
266}
267
268/// [`classify_installed_profile`] over the real [`SEALED_APPARMOR_PROFILE_PATH`]. Reads one file
269/// and needs no privilege.
270#[must_use]
271pub fn inspect_installed_profile() -> InstalledProfileState {
272    classify_installed_profile(std::fs::read(SEALED_APPARMOR_PROFILE_PATH))
273}
274
275/// The paths the two shipped profiles in `packaging/apparmor/mur-sealed` attach to, as their
276/// AppArmor path globs.
277///
278/// **Remediation text only.** Nothing decides anything by matching the running binary's path
279/// against this list: matching a path is inference, while reading `/proc/self/attr/current` is
280/// what the kernel actually did, and [`classify_profile_attachment`] uses only the latter. This
281/// exists so `mur doctor` can tell an operator running a binary from an unusual location where the
282/// shipped profile *does* attach, without pretending to have measured anything.
283pub const SEALED_APPARMOR_ATTACHMENT_PATHS: &[&str] = &[
284    "/{usr/local/,usr/,opt/mur/}bin/mur",
285    "@{HOME}/{.local,.cargo}/bin/mur",
286];
287
288/// Which AppArmor profile, if any, the kernel reports as confining *this* process.
289///
290/// AppArmor attaches profiles by executable path, so a `mur` at a bind mount, a build output or a
291/// test fixture gets no profile however correctly the shipped one is installed. That is invisible
292/// in [`UsernsGrant`] alone — it reports [`UsernsGrant::Withheld`] identically for "the profile is
293/// not installed" and "the profile is installed and does not attach here" — which is why this is
294/// reported beside it rather than folded into it.
295#[derive(Debug, Clone, PartialEq, Eq)]
296pub enum ProfileAttachment {
297    /// A profile whose name begins with [`SEALED_APPARMOR_PROFILE_NAME`] confines this process —
298    /// the shipped profile, or the checkout profile `scripts/install-dev-apparmor.sh` generates.
299    /// The same condition [`UsernsGrant::ProfileConfining`] is decided on.
300    Sealed {
301        /// The profile name as the kernel reports it, without the trailing enforcement mode.
302        profile: String,
303    },
304    /// Some other profile confines this process. It grants nothing murmur asked for, and it may
305    /// be *narrower* than unconfined — a snap or a container runtime profile, for instance.
306    Other {
307        /// The profile name as the kernel reports it, without the trailing enforcement mode.
308        profile: String,
309    },
310    /// The kernel reports `unconfined`: AppArmor is present and no profile attaches to this
311    /// binary's path.
312    Unconfined,
313    /// `/proc/self/attr/current` is absent or unreadable — no AppArmor on this host, or not
314    /// Linux. Never collapsed into [`Self::Unconfined`], on the same terms
315    /// [`InstalledProfileState::Unreadable`] is kept apart from
316    /// [`InstalledProfileState::Absent`]: "nothing attaches" and "I could not look" are different
317    /// findings.
318    NotReported,
319}
320
321/// Classifies the contents of `/proc/self/attr/current` into a [`ProfileAttachment`].
322///
323/// Takes the already-performed read rather than the path, so all four outcomes are unit-testable
324/// on any OS — the same split [`classify_installed_profile`] uses.
325///
326/// The kernel writes the profile name followed by its enforcement mode in parentheses
327/// (`mur-sealed (unconfined)`, `unconfined`), so the mode is stripped before the name is compared.
328#[must_use]
329pub fn classify_profile_attachment(current_profile: Option<&str>) -> ProfileAttachment {
330    let Some(raw) = current_profile
331        .map(str::trim)
332        .filter(|value| !value.is_empty())
333    else {
334        return ProfileAttachment::NotReported;
335    };
336    let name = raw
337        .split_once(" (")
338        .map_or(raw, |(name, _mode)| name)
339        .trim();
340    if name.is_empty() || name == "unconfined" {
341        return ProfileAttachment::Unconfined;
342    }
343    if name.starts_with(SEALED_APPARMOR_PROFILE_NAME) {
344        return ProfileAttachment::Sealed {
345            profile: name.to_string(),
346        };
347    }
348    ProfileAttachment::Other {
349        profile: name.to_string(),
350    }
351}
352
353/// [`classify_profile_attachment`] over the real `/proc/self/attr/current`. Holds no logic: the
354/// file is missing on every non-Linux host and on a Linux host without AppArmor, and both read as
355/// [`ProfileAttachment::NotReported`].
356#[must_use]
357pub fn inspect_profile_attachment() -> ProfileAttachment {
358    classify_profile_attachment(
359        std::fs::read_to_string("/proc/self/attr/current")
360            .ok()
361            .as_deref(),
362    )
363}
364
365// ---------------------------------------------------------------- blockers
366
367/// The one mechanism that stands between this host and `sealed`, named specifically enough that
368/// the refusal carries a command the operator can run.
369///
370/// Deliberately a small closed enum rather than a free-form string: `containment.rs` renders it,
371/// `sandbox.rs` derives it from the probe, and the manual-verification document asserts the exact
372/// text of each rendering.
373#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)]
374#[serde(rename_all = "snake_case")]
375pub enum SealedBlocker {
376    /// Not Linux. Mount namespaces do not exist here and never will — the same permanence
377    /// `EnforcementTier::EnvironmentOnly` carries.
378    NotLinux,
379    /// AppArmor's unprivileged-user-namespace restriction is active and this binary is not
380    /// confined by the shipped [`SEALED_APPARMOR_PROFILE_NAME`] profile.
381    AppArmorProfileMissing,
382    /// `unshare(CLONE_NEWUSER | CLONE_NEWNS)` was refused outright — the container case: no
383    /// `CAP_SYS_ADMIN`, or the container's own seccomp filter blocking the syscall.
384    NamespaceCreationDenied,
385    /// The namespace was created, but writing the identity `uid_map`/`gid_map` was refused, so the
386    /// process cannot own it. Nothing to do with `CAP_SYS_ADMIN` or containers.
387    IdMapDenied,
388    /// The namespace was created, but `mount(2)` inside it was refused. The signature of a
389    /// confinement that permits `userns_create` and then denies `CAP_SYS_ADMIN`.
390    MountDenied,
391    /// The namespace was created and `mount(2)` inside it worked, but the `pivot_root(2)`
392    /// sequence the composed root's step 6 performs did not. A separate finding from
393    /// [`Self::MountDenied`] because the two have separate remediations: `mount` is what an
394    /// AppArmor profile grants, while `pivot_root` is a distinct rule in that profile and a
395    /// distinct entry in a container runtime's seccomp allowlist, so a host can grant one and
396    /// refuse the other.
397    PivotRootDenied,
398    /// The kernel has no unprivileged user namespace support at all (`CONFIG_USER_NS=n`, or
399    /// `user.max_user_namespaces=0`).
400    KernelUnsupported,
401    /// Namespaces are available but Landlock is not, so the defence-in-depth layer `sealed`
402    /// is required to keep would be missing. `sealed` is strictly stronger than `scoped`; a host
403    /// that cannot back `scoped` cannot back `sealed` either.
404    LandlockUnavailable,
405    /// The composed root is built inside the capsule's network namespace, and this host created
406    /// the user namespace but could not finish the network namespace or enter it.
407    NetworkNamespaceUnavailable,
408    /// The probe itself could not run — a child could not be forked, waited for, or reported
409    /// nothing — so whether this host can back `sealed` is unknown.
410    ProbeUnavailable,
411}
412
413impl SealedBlocker {
414    /// Every variant, so a caller that has to reason about "which refusal did this host give"
415    /// cannot silently miss one. Added because a hand-maintained list of the expected refusal
416    /// texts in `murmur-cli`'s CLI test drifted the moment a variant was added, and the drift
417    /// showed up as a failing assertion about the *host* rather than about the list.
418    pub const ALL: &'static [SealedBlocker] = &[
419        SealedBlocker::NotLinux,
420        SealedBlocker::AppArmorProfileMissing,
421        SealedBlocker::NamespaceCreationDenied,
422        SealedBlocker::IdMapDenied,
423        SealedBlocker::MountDenied,
424        SealedBlocker::PivotRootDenied,
425        SealedBlocker::KernelUnsupported,
426        SealedBlocker::LandlockUnavailable,
427        SealedBlocker::NetworkNamespaceUnavailable,
428        SealedBlocker::ProbeUnavailable,
429    ];
430
431    /// One sentence naming the missing mechanism, plus the exact remediation. Rendered into
432    /// `RuntimeError::ContainmentFloorUnmet`'s `reason`, so this is the text an operator sees
433    /// under `E-CAP-003`.
434    #[must_use]
435    pub fn reason(self) -> String {
436        match self {
437            SealedBlocker::NotLinux => {
438                "sealed requires a Linux mount namespace + pivot_root; this \
439                 platform has no such primitive and never will (macOS and every non-Linux target \
440                 stay at advisory permanently)"
441                    .to_string()
442            }
443            SealedBlocker::AppArmorProfileMissing => format!(
444                "sealed requires an unprivileged user+mount namespace, and AppArmor's \
445                 unprivileged-userns restriction is active on this host while the '{name}' profile \
446                 is not confining this binary. Install and load the profile shipped with mur: \
447                 `sudo install -m 644 packaging/apparmor/{name} {path} && sudo apparmor_parser -r \
448                 {path}` (or re-run the mur installer as root), then re-run. Building out of a \
449                 checkout, where the binary sits at ./target/{{debug,release}}/mur and no shipped \
450                 profile attaches to it: run `scripts/install-dev-apparmor.sh`, which generates \
451                 and loads the same grant for those two paths. LAST RESORT, only where a profile \
452                 genuinely cannot be loaded: `sudo sysctl -w \
453                 kernel.apparmor_restrict_unprivileged_userns=0` — this removes \
454                 unprivileged-userns hardening from every program on the machine, not just from \
455                 mur, and is not the configuration murmur ships.",
456                name = SEALED_APPARMOR_PROFILE_NAME,
457                path = SEALED_APPARMOR_PROFILE_PATH,
458            ),
459            SealedBlocker::NamespaceCreationDenied => {
460                "sealed requires an unprivileged user namespace, which this host refused to create. \
461                 This is the usual answer inside a container: the container runtime's seccomp \
462                 profile refuses unshare(CLONE_NEWUSER), and refuses setns, mount, umount2 and \
463                 pivot_root, which sealed needs next. Run the container with a seccomp profile \
464                 that permits those calls (Docker: `--security-opt seccomp=<file>`; Kubernetes: \
465                 `seccompProfile: Localhost`), or create the namespaces outside the container and \
466                 run mur inside them. Adding capabilities does not lift a seccomp refusal. The \
467                 runtime will not fall back to a weaker class."
468                    .to_string()
469            }
470            SealedBlocker::IdMapDenied => {
471                "sealed created a user namespace, but writing the identity uid_map/gid_map into \
472                 it was refused, so the process cannot own the namespace it just made. This \
473                 is an id-mapping problem, not a missing capability: check that \
474                 /proc/sys/user/max_user_namespaces is non-zero, that no LSM policy blocks \
475                 uid_map writes for this binary, and that mur is not already running inside an \
476                 unmapped user namespace. The runtime will not fall back to a weaker class."
477                    .to_string()
478            }
479            SealedBlocker::MountDenied => format!(
480                "sealed created a user+mount namespace but mount(2) inside it was refused, which \
481                 is what a confinement that permits userns_create and then denies CAP_SYS_ADMIN \
482                 looks like. On an AppArmor host, load the shipped profile: `sudo apparmor_parser \
483                 -r {path}`. Inside a container, the container runtime's seccomp profile refuses \
484                 mount, umount2 and pivot_root whatever capabilities the container holds: run it \
485                 with a seccomp profile that permits them (Docker: `--security-opt \
486                 seccomp=<file>`; Kubernetes: `seccompProfile: Localhost`), or establish the \
487                 mount namespace outside the container.",
488                path = SEALED_APPARMOR_PROFILE_PATH,
489            ),
490            SealedBlocker::PivotRootDenied => format!(
491                "sealed created a user+mount namespace and mounted inside it, but pivot_root(2) \
492                 was refused, so the composed root cannot become this capsule's root. mount(2) \
493                 working while pivot_root(2) does not is what a policy that grants one and not \
494                 the other looks like: on an AppArmor host, check that the loaded profile carries \
495                 a `pivot_root,` rule and reload it with `sudo apparmor_parser -r {path}`; inside \
496                 a container, the container runtime's seccomp profile refuses pivot_root whatever \
497                 capabilities the container holds, so run it with a seccomp profile that permits \
498                 pivot_root (Docker: `--security-opt seccomp=<file>`; Kubernetes: \
499                 `seccompProfile: Localhost`), or establish the mount namespace outside the \
500                 container instead. The runtime will not fall back to a weaker class.",
501                path = SEALED_APPARMOR_PROFILE_PATH,
502            ),
503            SealedBlocker::KernelUnsupported => {
504                "sealed requires unprivileged user namespaces, which this kernel does not provide \
505                 (CONFIG_USER_NS=n, or user.max_user_namespaces=0). Raise \
506                 `sudo sysctl -w user.max_user_namespaces=10000` if the sysctl is merely zeroed, \
507                 otherwise run on a kernel built with CONFIG_USER_NS=y."
508                    .to_string()
509            }
510            SealedBlocker::LandlockUnavailable => {
511                "sealed keeps Landlock and seccomp inside the composed root as defence in depth, \
512                 and this host provides no usable Landlock ABI (Linux 5.13+ required). A host that \
513                 cannot back scoped cannot back sealed either."
514                    .to_string()
515            }
516            SealedBlocker::NetworkNamespaceUnavailable => {
517                "sealed is built inside the capsule's network namespace and this host could not \
518                 create or enter it, so there is no namespace to build the composed root in. \
519                 Every capsule that can spawn a subprocess needs that namespace: see E-CAP-005 \
520                 for the step that failed and its remedy, then re-run. The runtime will not fall \
521                 back to a weaker class."
522                    .to_string()
523            }
524            SealedBlocker::ProbeUnavailable => {
525                "sealed could not be probed: the probe could not run, so whether this host can \
526                 back sealed is unknown; refused rather than assumed. A process or descriptor \
527                 limit, or memory, ran out, or a policy killed the probe's child: re-run once \
528                 the limit is relieved, or allow the probe's syscalls."
529                    .to_string()
530            }
531        }
532    }
533}
534
535/// Why this host cannot back `sealed`: the blocker, and, when a probe attempt failed, the call it
536/// saw fail and the readings the refusal is worded with.
537///
538/// [`Self::reason`] is the text under `E-CAP-003` for `sealed`, so the floor refusal,
539/// `--explain-scope` and `mur doctor` cannot explain one host two ways.
540#[derive(Debug, Clone, PartialEq, Eq)]
541pub struct SealedShortfall {
542    /// Decided by the attempts and Landlock alone; the readings below never change whether there
543    /// is a shortfall.
544    pub blocker: SealedBlocker,
545    /// The probe's call that failed, in its step's words (`unshare(CLONE_NEWUSER | CLONE_NEWNET)`,
546    /// `pivot_root(2)`, …), or, for [`SealedBlocker::ProbeUnavailable`], the runtime's account of
547    /// why the probe could not run. `None` when no attempt failed: [`SealedBlocker::NotLinux`] and
548    /// [`SealedBlocker::LandlockUnavailable`].
549    pub failed_call: Option<String>,
550    /// The `errno` the failed call left. `None` when no call failed, and for
551    /// [`SealedBlocker::ProbeUnavailable`].
552    pub errno: Option<i32>,
553    /// AppArmor's unprivileged-userns restriction as read with the attempts.
554    pub userns_grant: UsernsGrant,
555    /// The profile the kernel reports as confining this process, read with the attempts.
556    pub attachment: ProfileAttachment,
557}
558
559impl SealedShortfall {
560    /// [`SealedBlocker::reason`]; then, when an attempt failed, `The probe's <call> failed with
561    /// <ERRNO> (<words>).` (or `The probe could not run: <account>.`); then, last, the same `Host
562    /// readings: …` line `E-CAP-005` ends with.
563    #[must_use]
564    pub fn reason(&self) -> String {
565        let mut reason = self.blocker.reason();
566        let Some(call) = &self.failed_call else {
567            return reason;
568        };
569        let what = match self.errno {
570            Some(errno) => crate::namespace_probe::probe_failure_sentence(call, errno),
571            None => format!("The probe could not run: {call}."),
572        };
573        reason.push(' ');
574        reason.push_str(&what);
575        reason.push(' ');
576        reason.push_str(&crate::network_namespace::host_readings(
577            self.userns_grant,
578            &self.attachment,
579        ));
580        reason
581    }
582}
583
584/// Which single mechanism keeps this host below `sealed`, decided from the probe's attempts and
585/// Landlock. Pure; `None` means the composed root was built and Landlock is fully enforced.
586///
587/// The readings decide nothing but which words an attempt that failed with `EPERM`/`EACCES` is
588/// named in: a network namespace refused under the restriction with `mur` unconfined is
589/// [`SealedBlocker::AppArmorProfileMissing`], the same refusal under a container's profile is the
590/// container case. The `/tmp` attempt never affects `sealed`: the composed root binds `/tmp`
591/// itself.
592pub(crate) fn sealed_shortfall(
593    is_linux: bool,
594    landlock_fully_enforced: bool,
595    reading: &crate::namespace_probe::NamespaceReading,
596) -> Option<SealedShortfall> {
597    use crate::namespace_probe::{MountAttempt, MountAttemptError, MountStep};
598    use crate::network_namespace::{CreationStep, NamespaceAttemptError};
599
600    let shortfall = |blocker, failed_call: Option<String>, errno| SealedShortfall {
601        blocker,
602        failed_call,
603        errno,
604        userns_grant: reading.userns_grant,
605        attachment: reading.attachment.clone(),
606    };
607    if !is_linux {
608        return Some(shortfall(SealedBlocker::NotLinux, None, None));
609    }
610    const KERNEL_ERRNOS: [i32; 4] = [libc::ENOSPC, libc::EINVAL, libc::ENOSYS, libc::EUSERS];
611    match &reading.network {
612        Ok(()) => {}
613        Err(NamespaceAttemptError::Creator(detail)) => {
614            return Some(shortfall(
615                SealedBlocker::ProbeUnavailable,
616                Some(detail.clone()),
617                None,
618            ))
619        }
620        Err(NamespaceAttemptError::Step { step, errno }) => {
621            let errno = *errno;
622            let blocker = match step {
623                CreationStep::Unshare if KERNEL_ERRNOS.contains(&errno) => {
624                    SealedBlocker::KernelUnsupported
625                }
626                CreationStep::Unshare | CreationStep::IdentityMaps
627                    if matches!(errno, libc::EPERM | libc::EACCES)
628                        && reading.userns_grant == UsernsGrant::Withheld
629                        && reading.attachment == ProfileAttachment::Unconfined =>
630                {
631                    SealedBlocker::AppArmorProfileMissing
632                }
633                CreationStep::Unshare => SealedBlocker::NamespaceCreationDenied,
634                CreationStep::IdentityMaps => SealedBlocker::IdMapDenied,
635                CreationStep::Loopback
636                | CreationStep::LocalRoute
637                | CreationStep::TcpListener
638                | CreationStep::Resolver
639                | CreationStep::NamespaceHandles
640                | CreationStep::HandOff => SealedBlocker::NetworkNamespaceUnavailable,
641            };
642            return Some(shortfall(
643                blocker,
644                Some(step.describe().to_string()),
645                Some(errno),
646            ));
647        }
648    }
649    match &reading.sealed_root {
650        MountAttempt::Created => {}
651        // Not attempted with the network namespace created is no state the probe produces; it is
652        // refused as unknown rather than read as built.
653        MountAttempt::NotAttempted => {
654            return Some(shortfall(
655                SealedBlocker::ProbeUnavailable,
656                Some("the sealed-root rehearsal did not run".to_string()),
657                None,
658            ))
659        }
660        MountAttempt::Failed(MountAttemptError::Rehearsal(detail)) => {
661            return Some(shortfall(
662                SealedBlocker::ProbeUnavailable,
663                Some(detail.clone()),
664                None,
665            ))
666        }
667        MountAttempt::Failed(MountAttemptError::Step { step, errno }) => {
668            let errno = *errno;
669            let blocker = match step {
670                MountStep::JoinSession => SealedBlocker::NetworkNamespaceUnavailable,
671                MountStep::UnshareUserMount if KERNEL_ERRNOS.contains(&errno) => {
672                    SealedBlocker::KernelUnsupported
673                }
674                MountStep::UnshareUserMount => SealedBlocker::NamespaceCreationDenied,
675                MountStep::IdentityMaps => SealedBlocker::IdMapDenied,
676                MountStep::MakePrivateNested | MountStep::Tmpfs => SealedBlocker::MountDenied,
677                MountStep::PivotPrepare | MountStep::PivotRoot | MountStep::DetachOldRoot => {
678                    SealedBlocker::PivotRootDenied
679                }
680                // The `/tmp` rehearsal's steps; the sealed-root rehearsal never reports them.
681                MountStep::UnshareMount | MountStep::MakePrivate | MountStep::BindTmp => {
682                    SealedBlocker::MountDenied
683                }
684            };
685            return Some(shortfall(
686                blocker,
687                Some(step.describe().to_string()),
688                Some(errno),
689            ));
690        }
691    }
692    if landlock_fully_enforced {
693        None
694    } else {
695        Some(shortfall(SealedBlocker::LandlockUnavailable, None, None))
696    }
697}
698
699// ---------------------------------------------------------------- the composed root, declared
700
701/// Host directories bind-mounted read-only into every composed root, in order.
702///
703/// Fixed, not derived: a whole-tree bind of `/usr` plus the non-usrmerge spellings of the same
704/// tree. On a usrmerge distro `/bin`, `/sbin`, `/lib`,
705/// `/lib64`… are *symlinks* into `/usr`; [`plan_composed_root`] recreates them as symlinks rather
706/// than bind-mounting through them, so the composed root has the same shape as the host and a
707/// hashbang like `#!/bin/sh` resolves.
708///
709/// Deliberately a whole-directory bind rather than a curated per-file set: the point of `sealed`
710/// is that it *deletes* the ELF-closure derivation `scoped` needs, along with its lock-time
711/// pinning problem. `capabilities.shell.staged_runtime` is **additive** to this list, not a
712/// replacement for it: a declared runtime tree arrives through
713/// [`plan_composed_root`]'s own `staged_runtime_read_only` parameter, as a *required* bind, while
714/// this fixed list stays exactly as it is. Anything a `shell.allow` binary needs at runtime must
715/// therefore be under one of these, under a staged runtime tree, or under a directory derived
716/// from the manifest — see `extra_read_only` in [`plan_composed_root`].
717pub const SEALED_RUNTIME_PATHS: &[&str] = &[
718    "/usr", "/bin", "/sbin", "/lib", "/lib32", "/lib64", "/libx32",
719];
720
721/// One entry of the fixed `/etc` allowlist: the host path to bind, plus whether it is a directory.
722///
723/// The `directory` bit exists because the same list drives two mechanisms with different needs.
724/// [`plan_composed_root`] does not need it — it asks the host what each path is via
725/// [`HostLayout::kind`] and mirrors accordingly. `sandbox::resolve_sealed_etc_landlock_grants` does:
726/// it must decide `ReadDir` per entry, and it is required to be pure and syscall-free (it runs on
727/// any platform, for paths that need not exist). Carrying the classification *in the list* rather
728/// than in a second parallel constant is what makes the bound set and the granted set structurally
729/// incapable of drifting apart — the same reason [`SealedDevice`] carries its own `writable` bit
730/// rather than deriving it from a separate table.
731///
732/// It is a static claim about mainstream Linux, not a fact read off this host: `/etc/ssl` and
733/// friends are directories on Debian, Ubuntu, Fedora, RHEL, Arch and Alpine alike. A host that
734/// disagrees costs the entry its enumerability and nothing else — `open_landlock_fds` narrows
735/// `list_dir` back to `false` for anything that does not `fstat` as a directory, because the kernel
736/// rejects (`EINVAL`) a `ReadDir` rule on a non-directory and that would otherwise refuse the whole
737/// launch.
738#[derive(Debug, Clone, Copy, PartialEq, Eq)]
739pub struct SealedEtcPath {
740    /// Absolute path inside the composed root. Also the host path bind-mounted there, except for
741    /// an entry carrying [`Self::synthetic`], whose content the parent writes itself.
742    pub path: &'static str,
743    /// Whether this path is a directory on a mainstream Linux host, and therefore whether its
744    /// Landlock rule carries `ReadDir`. `false` for regular files and for symlinks (the symlink is
745    /// followed when the rule's fd is opened, so `/etc/localtime`'s rule lands on the zoneinfo file
746    /// it points at).
747    pub directory: bool,
748    /// `Some` when [`Self::path`] is backed by a file the *parent* synthesises rather than by the
749    /// host's own file at that path — the account databases, and nothing else.
750    ///
751    /// Carried on the entry for the same reason as `directory`: the alternative is a second
752    /// parallel list of "the ones we do not mirror", which is free to drift out of step with this
753    /// one. Typed rather than a `bool` so the content each synthetic entry gets is decided by an
754    /// exhaustive `match` — adding a third one is then a compile error until it is given content,
755    /// instead of a launch-time failure to bind a file nobody wrote.
756    pub synthetic: Option<SyntheticEtcFile>,
757}
758
759/// An `/etc` file a composed root gets in synthesised form instead of the host's.
760///
761/// Both are account databases, and both are world-readable on every distribution — so binding the
762/// host's copy hands the capsule the machine's full account list for no benefit. What a capsule
763/// actually needs from them is that `getpwuid(3)`/`getgrgid(3)` resolve *its own* id: a shell's `~`
764/// expansion, `os.path.expanduser`, `whoami`, and anything reading `$HOME` through the password
765/// database. Two entries — `root` and the id the capsule runs as — satisfy all of that.
766#[derive(Debug, Clone, Copy, PartialEq, Eq)]
767pub enum SyntheticEtcFile {
768    /// `/etc/passwd`, in the seven colon-separated fields of `passwd(5)`.
769    Passwd,
770    /// `/etc/group`, in the four colon-separated fields of `group(5)`.
771    Group,
772}
773
774/// The uid/gid a capsule's subprocesses run as, and the `HOME` they are given.
775///
776/// Read off the session workdir the parent created rather than from `getuid(2)`: this crate is
777/// `#![deny(unsafe_code)]` and the workdir's owner *is* the spawning process's identity, so
778/// `std::fs::metadata(workdir).uid()` answers the question in safe std — the same pattern
779/// `crate::resources` already uses.
780#[derive(Debug, Clone, Copy, PartialEq, Eq)]
781pub(crate) struct SealedAccountIdentity<'a> {
782    pub(crate) uid: u32,
783    pub(crate) gid: u32,
784    /// The synthetic `HOME`, exactly as `crate::shell::build_shell_env` sets it in the subprocess
785    /// environment. Byte-identical or the whole exercise is pointless: a `pw_dir` that disagrees
786    /// with `$HOME` is worse than no entry at all, because tools silently pick one of the two.
787    pub(crate) home: &'a str,
788}
789
790/// Login shell recorded for both synthetic accounts. Present in the composed root
791/// (`/bin` is on [`SEALED_RUNTIME_PATHS`]) and *not* thereby made runnable — the exec allowlist is
792/// Landlock's, and a shell field is a string in a database, not a grant.
793const SYNTHETIC_LOGIN_SHELL: &str = "/bin/sh";
794
795/// Account and group name for the capsule's own entry. Deliberately not the host's login name:
796/// that name is itself part of the account list this file exists to stop leaking.
797const SYNTHETIC_ACCOUNT_NAME: &str = "capsule";
798
799impl SyntheticEtcFile {
800    /// File name under the session's `sealed-etc/`. The basename of the target path, so that
801    /// directory reads like the `/etc` it stands in for.
802    pub(crate) fn file_name(self) -> &'static str {
803        match self {
804            Self::Passwd => "passwd",
805            Self::Group => "group",
806        }
807    }
808
809    /// The exact bytes written to the staging file, for one capsule identity.
810    ///
811    /// At most two lines: `root`, plus the capsule's own id when that is not already 0. A capsule
812    /// running as root gets **one** line — the `root` line, with `$HOME` as its home directory —
813    /// rather than a duplicate entry for uid 0, which `getpwuid(3)` would resolve to whichever came
814    /// first.
815    ///
816    /// Fails rather than emits a corrupt database when the home path contains a field separator or
817    /// a newline: `passwd(5)` has no escaping, so a workdir named with a `:` would silently shift
818    /// every field after it. Parent-side and synchronous, so it surfaces as a named launch failure.
819    pub(crate) fn render(self, identity: &SealedAccountIdentity) -> Result<String, String> {
820        let SealedAccountIdentity { uid, gid, home } = *identity;
821        match self {
822            Self::Passwd => {
823                if home.contains(':') || home.contains('\n') {
824                    return Err(format!(
825                        "sealed: the synthetic HOME {home} contains a character /etc/passwd cannot \
826                         quote (':' or a newline), so no valid passwd entry can name it"
827                    ));
828                }
829                // name:password:uid:gid:gecos:home:shell — `x` in the password field is the
830                // universal "look in shadow", and there is no shadow file in a composed root.
831                if uid == 0 {
832                    return Ok(format!(
833                        "root:x:0:{gid}:root:{home}:{SYNTHETIC_LOGIN_SHELL}\n"
834                    ));
835                }
836                Ok(format!(
837                    "root:x:0:0:root:/root:{SYNTHETIC_LOGIN_SHELL}\n\
838                     {SYNTHETIC_ACCOUNT_NAME}:x:{uid}:{gid}:Murmur capsule:{home}:\
839                     {SYNTHETIC_LOGIN_SHELL}\n"
840                ))
841            }
842            // name:password:gid:members — the member list is empty because membership of the
843            // capsule's own group comes from its passwd entry's gid field.
844            Self::Group => {
845                if gid == 0 {
846                    return Ok("root:x:0:\n".to_string());
847                }
848                Ok(format!("root:x:0:\n{SYNTHETIC_ACCOUNT_NAME}:x:{gid}:\n"))
849            }
850        }
851    }
852}
853
854/// The only `/etc` entries that exist inside a composed root, each bind-mounted read-only and each
855/// silently skipped when the host does not have it.
856///
857/// An allowlist, never `/etc` wholesale, and never a denylist of sensitive names: `/etc/shadow`,
858/// `/etc/sudoers`, `/etc/ssh`, cloud-init credentials and every future addition to `/etc` are
859/// absent by construction rather than by enumeration. The entries here are the ones without which
860/// ordinary tooling misreports rather than fails cleanly — the dynamic loader's cache, the TLS
861/// trust store, DNS configuration, the timezone, and the passwd/group databases `getpwuid(3)`
862/// needs.
863///
864/// `/etc/passwd` and `/etc/group` are the two entries the host does *not* supply. They are
865/// world-readable on every distribution, so binding the host's copies would hand the capsule the
866/// machine's full account list; instead the parent writes a two-line database naming only `root`
867/// and the capsule's own id, and the composed root binds *that* at the same path, read-only. See
868/// [`SyntheticEtcFile`]. It stays on this list because the list is also the grant list — an entry
869/// removed from here loses its Landlock rule and becomes unreadable, which is the opposite of what
870/// this narrowing is for.
871///
872/// Binding these is only half the job: Landlock still mediates *inside* the composed root, so an
873/// entry bound here but absent from the ruleset exists and is unopenable — a trust store present
874/// at `/etc/ssl/certs/ca-certificates.crt` but `EACCES` on open fails TLS verification.
875/// `sandbox::resolve_sealed_etc_landlock_grants` turns this list into the matching read grants.
876/// Keep the two sets aligned. A Landlock rule names the *inode* an fd resolved to, not the path
877/// string it was opened by, so an entry whose bind source is not the host path of the same name
878/// needs its rule taken on the file actually bound — see `sandbox::LandlockChildFds`'s
879/// `sealed_identity_fds`, which does that for the two synthetic entries.
880pub const SEALED_ETC_PATHS: &[SealedEtcPath] = &[
881    SealedEtcPath {
882        path: "/etc/ld.so.cache",
883        directory: false,
884        synthetic: None,
885    },
886    SealedEtcPath {
887        path: "/etc/ld.so.conf",
888        directory: false,
889        synthetic: None,
890    },
891    SealedEtcPath {
892        path: "/etc/ld.so.conf.d",
893        directory: true,
894        synthetic: None,
895    },
896    SealedEtcPath {
897        path: "/etc/alternatives",
898        directory: true,
899        synthetic: None,
900    },
901    SealedEtcPath {
902        path: "/etc/ssl",
903        directory: true,
904        synthetic: None,
905    },
906    SealedEtcPath {
907        path: "/etc/pki",
908        directory: true,
909        synthetic: None,
910    },
911    SealedEtcPath {
912        path: "/etc/ca-certificates",
913        directory: true,
914        synthetic: None,
915    },
916    SealedEtcPath {
917        path: "/etc/ca-certificates.conf",
918        directory: false,
919        synthetic: None,
920    },
921    SealedEtcPath {
922        path: "/etc/resolv.conf",
923        directory: false,
924        synthetic: None,
925    },
926    SealedEtcPath {
927        path: "/etc/hosts",
928        directory: false,
929        synthetic: None,
930    },
931    SealedEtcPath {
932        path: "/etc/nsswitch.conf",
933        directory: false,
934        synthetic: None,
935    },
936    SealedEtcPath {
937        path: "/etc/localtime",
938        directory: false,
939        synthetic: None,
940    },
941    SealedEtcPath {
942        path: "/etc/timezone",
943        directory: false,
944        synthetic: None,
945    },
946    SealedEtcPath {
947        path: "/etc/terminfo",
948        directory: true,
949        synthetic: None,
950    },
951    SealedEtcPath {
952        path: "/etc/passwd",
953        directory: false,
954        synthetic: Some(SyntheticEtcFile::Passwd),
955    },
956    SealedEtcPath {
957        path: "/etc/group",
958        directory: false,
959        synthetic: Some(SyntheticEtcFile::Group),
960    },
961];
962
963/// One entry of the private `/dev` tmpfs.
964///
965/// `major`/`minor`/`mode` are recorded for the record and for the manual-verification document to
966/// assert against — they are **not** used to create the node. Device nodes cannot be `mknod`ed
967/// from inside a non-initial user namespace at all (the kernel refuses regardless of
968/// `CAP_MKNOD`), so each entry is created as an empty file in the tmpfs and the host's node is
969/// bind-mounted over it. This is what bubblewrap and rootless podman do, and it means the numbers
970/// a capsule actually sees are the host's — which on every mainstream distribution are exactly the
971/// ones below.
972#[derive(Debug, Clone, Copy, PartialEq, Eq)]
973pub struct SealedDevice {
974    /// Name under `/dev`, without the directory.
975    pub name: &'static str,
976    /// The host path bind-mounted over the tmpfs entry — `/dev/` + [`Self::name`], spelled out
977    /// because it is also what the composed root's Landlock rule set is built from.
978    pub path: &'static str,
979    pub major: u32,
980    pub minor: u32,
981    pub mode: u32,
982    /// Whether the composed root's Landlock rule for this device carries `WriteFile` on top of
983    /// `ReadFile`.
984    ///
985    /// Landlock still mediates inside a composed root — that is the whole point of keeping it as
986    /// defence in depth — so a device present in `/dev` but absent from the rule set would exist
987    /// and be unopenable, which is worse than not being there. The read-only pair is the two
988    /// entropy sources, where nothing legitimate writes; the rest carry write because their write
989    /// side is defined to discard (`null`, `zero`), to report `ENOSPC` (`full`), or to be a
990    /// terminal (`tty`).
991    pub writable: bool,
992}
993
994/// The OCI runtime-spec default device set, which is what a `sealed` capsule's `/dev` contains and
995/// all it contains.
996///
997/// This is deliberately *independent* of `sandbox::CAPSULE_DEVICE_GRANTS`, the three-device
998/// Landlock grant set that services `scoped`. That set is a rule list layered over the host's real
999/// `/dev`; this one is the entire contents of a private tmpfs. They answer different questions and
1000/// neither should be derived from the other.
1001///
1002/// `/dev/console` is omitted (the OCI spec creates it only for a terminal-attached container, and
1003/// a capsule subprocess has pipes, not a controlling terminal). `/dev/ptmx` and `/dev/pts` come
1004/// from a `devpts` mount rather than from this list, and `/dev/shm` is omitted deliberately: it is
1005/// writable, and the session workdir, `/tmp` and the home folder are the only writable paths in a
1006/// composed root.
1007pub const SEALED_DEVICE_NODES: &[SealedDevice] = &[
1008    SealedDevice {
1009        name: "null",
1010        path: "/dev/null",
1011        major: 1,
1012        minor: 3,
1013        mode: 0o666,
1014        writable: true,
1015    },
1016    SealedDevice {
1017        name: "zero",
1018        path: "/dev/zero",
1019        major: 1,
1020        minor: 5,
1021        mode: 0o666,
1022        writable: true,
1023    },
1024    SealedDevice {
1025        name: "full",
1026        path: "/dev/full",
1027        major: 1,
1028        minor: 7,
1029        mode: 0o666,
1030        writable: true,
1031    },
1032    SealedDevice {
1033        name: "random",
1034        path: "/dev/random",
1035        major: 1,
1036        minor: 8,
1037        mode: 0o666,
1038        writable: false,
1039    },
1040    SealedDevice {
1041        name: "urandom",
1042        path: "/dev/urandom",
1043        major: 1,
1044        minor: 9,
1045        mode: 0o666,
1046        writable: false,
1047    },
1048    SealedDevice {
1049        name: "tty",
1050        path: "/dev/tty",
1051        major: 5,
1052        minor: 0,
1053        mode: 0o666,
1054        writable: true,
1055    },
1056];
1057
1058/// The OCI default `/dev` symlinks, `(link name under /dev, target)`.
1059pub const SEALED_DEVICE_SYMLINKS: &[(&str, &str)] = &[
1060    ("fd", "/proc/self/fd"),
1061    ("stdin", "/proc/self/fd/0"),
1062    ("stdout", "/proc/self/fd/1"),
1063    ("stderr", "/proc/self/fd/2"),
1064    ("ptmx", "pts/ptmx"),
1065];
1066
1067/// Host path of the file backing one synthetic `/etc` entry, in the session's `sealed-etc/`
1068/// directory beside its runtime directory, which no capsule command can write.
1069///
1070/// The single place this path is composed. Three callers need to agree on it byte for byte — the
1071/// planner that binds it, the parent that writes it, and the Landlock fd that grants it — and two
1072/// of them run in different phases of the launch.
1073pub(crate) fn synthetic_etc_source(sealed_etc_dir: &Path, file: SyntheticEtcFile) -> PathBuf {
1074    sealed_etc_dir.join(file.file_name())
1075}
1076
1077/// The host-side sources a composed root binds that the runtime itself provides, each resolved
1078/// by the parent before the fork.
1079#[derive(Debug, Clone, Copy)]
1080pub(crate) struct SealedBindSources<'a> {
1081    /// The session's temporary folder ([`crate::temp_dir`]), bound at `/tmp`, with the
1082    /// `(st_dev, st_ino)` of the descriptor the session's records root holds on it — the identity
1083    /// the bind verifies, so a link swapped onto its path moves nothing ([`RootOp::BindVerified`]).
1084    pub(crate) tmp_store: (&'a Path, (u64, u64)),
1085    /// The session's `sealed-etc/` directory, holding the synthetic `/etc/passwd` and
1086    /// `/etc/group`.
1087    pub(crate) etc_dir: &'a Path,
1088    /// The session's runtime directory and the identity of the runtime root's own descriptor,
1089    /// bound read-only and required at its own absolute path.
1090    pub(crate) runtime_dir: Option<(&'a Path, (u64, u64))>,
1091    /// The session's records directory and the identity of the records root's own descriptor,
1092    /// bound read-only and required at its own absolute path, on [`Self::runtime_dir`]'s terms.
1093    pub(crate) records_dir: Option<(&'a Path, (u64, u64))>,
1094    /// The session's home folder, `home/` in its records directory, and the identity of the
1095    /// descriptor its home root holds on it, bound read-write and required at its own absolute
1096    /// path, after [`Self::records_dir`], so `$HOME` names it inside the root.
1097    pub(crate) home_dir: Option<(&'a Path, (u64, u64))>,
1098    /// A native tool's durable store, `~/.murmur/state/<store>/`, and the identity of the
1099    /// descriptor its spawn took on it, bound read-write and required at its own absolute path,
1100    /// after [`Self::home_dir`], so `$MURMUR_STATE_DIR` names it inside the root. `None` for every
1101    /// spawn but the declaring tool's.
1102    pub(crate) state_dir: Option<(&'a Path, (u64, u64))>,
1103    /// The host paths `~/.murmur/config.yaml` lends the session, in config order, each bound
1104    /// verified and required at its own absolute path before everything else.
1105    pub(crate) host_paths: &'a [HostPathBind],
1106    /// Every execute root inside a `read-write` host path, bound read-only and required on top of
1107    /// it so the capsule cannot rewrite what it may run.
1108    pub(crate) host_path_read_only: &'a [PathBuf],
1109}
1110
1111/// One host path's bind: the directory the entry resolved to, the `(st_dev, st_ino)` of the
1112/// descriptor the parent took on it, and whether it is bound read-only (`access: read`).
1113#[derive(Debug, Clone, PartialEq, Eq)]
1114pub(crate) struct HostPathBind {
1115    pub(crate) path: PathBuf,
1116    pub(crate) identity: (u64, u64),
1117    pub(crate) read_only: bool,
1118}
1119
1120/// Directory the old root is parked in between `pivot_root(2)` and `umount2(MNT_DETACH)`.
1121/// Removed immediately afterwards, so it does not exist for any process the capsule can run.
1122const OLD_ROOT_NAME: &str = ".mur-oldroot";
1123
1124/// [`OLD_ROOT_NAME`] relative, and rooted at `/` after the pivot, as NUL-terminated literals.
1125///
1126/// The probe's sealed-root rehearsal pivots after `fork()`, where composing a `CString` is not
1127/// allowed. `old_root_names_agree` keeps the three spellings in step.
1128pub(crate) const OLD_ROOT_NAME_C: &std::ffi::CStr = c".mur-oldroot";
1129
1130/// [`OLD_ROOT_NAME`] as it is named from inside the new root, once `pivot_root(2)` has parked the
1131/// old one there.
1132pub(crate) const OLD_ROOT_PIVOTED_C: &std::ffi::CStr = c"/.mur-oldroot";
1133
1134/// Base directories the new-root `tmpfs` may be mounted over, most preferred first.
1135///
1136/// The base is overmounted inside the private namespace, so whatever the host has there becomes
1137/// unreachable for the rest of the setup — which is why the chosen base must not be an ancestor of
1138/// the session workdir (a workdir under `/tmp` is entirely normal) and must not be one of the
1139/// directories the composed root goes on to bind-mount from.
1140pub const SEALED_ROOT_BASE_CANDIDATES: &[&str] = &["/tmp", "/run", "/var/tmp", "/mnt", "/media"];
1141
1142/// `hidepid` spellings tried, in order, when mounting the composed root's `/proc` with
1143/// `mount -t proc`. The numeric form is the legacy parser's; `invisible` is the Linux 5.8+
1144/// spelling; the empty string is a plain private `procfs` with no masking.
1145///
1146/// **On a bare host none of the three succeeds, and that is a kernel rule.**
1147/// `proc_fill_super` requires `CAP_SYS_ADMIN` over the user namespace *owning the PID namespace*.
1148/// `unshare(CLONE_NEWUSER | CLONE_NEWNS)` leaves the process in the host's initial PID namespace,
1149/// whose owner is the initial user namespace — where an unprivileged process has no capabilities
1150/// at all — so every `mount -t proc` returns `EPERM`. Adding `CLONE_NEWPID` does not help either:
1151/// `unshare` moves only *future children* into the new PID namespace, so the mounting process is
1152/// still judged against the old one. Mounting a private `procfs` unprivileged genuinely requires
1153/// forking so the child becomes PID 1, which changes reaping and signal semantics for the whole
1154/// capsule subprocess tree and is out of scope here (see the PID-namespace note in
1155/// `docs/content/reference/sealed-containment-manual-verification.md`).
1156///
1157/// They are still tried first, and in this order, because they *do* succeed where the process has
1158/// that privilege — as root, and inside a container granted `CAP_SYS_ADMIN` — and because
1159/// a later PID-namespace slice makes them succeed everywhere without touching this list.
1160///
1161/// When they all fail the executor binds the host's `/proc` instead. The capsule can then
1162/// enumerate host PIDs and read the `/proc/<pid>/root` and `/proc/<pid>/cwd` symlinks, so `/proc`
1163/// is the one part of the composed root where "outside does not exist" degrades to `scoped`'s
1164/// "outside is denied" — Landlock's ruleset covers no path under `/proc`, so opens through it are
1165/// refused, and `ptrace_may_access` gates the rest. Every other axis of the root stays absolute. The alternative is a capsule with no `/proc` at all, which
1166/// breaks `/dev/fd`, process substitution and every runtime that reads `/proc/self/*`.
1167const PROC_HIDEPID_OPTIONS: &[&str] = &["hidepid=2", "hidepid=invisible", ""];
1168
1169// ---------------------------------------------------------------- host layout (test seam)
1170
1171/// What a path is on the host, as far as composing a root cares.
1172#[derive(Debug, Clone, PartialEq, Eq)]
1173pub(crate) enum PathKind {
1174    Dir,
1175    /// Any non-directory: a regular file, a device node, a socket. All are bind-mount targets that
1176    /// need an empty *file* created for them rather than a directory.
1177    File,
1178    /// A symlink, with its literal (un-resolved) target.
1179    Symlink(PathBuf),
1180}
1181
1182/// The host filesystem facts [`plan_composed_root`] reads. A trait purely so the planner can be
1183/// unit-tested against a synthetic usrmerge / non-usrmerge layout on any machine — production has
1184/// exactly one implementation, [`RealHostLayout`].
1185pub(crate) trait HostLayout {
1186    fn kind(&self, path: &Path) -> Option<PathKind>;
1187}
1188
1189/// The real filesystem, read with `symlink_metadata` so a usrmerge `/bin -> usr/bin` reports as a
1190/// symlink rather than as the directory it points at.
1191pub(crate) struct RealHostLayout;
1192
1193impl HostLayout for RealHostLayout {
1194    fn kind(&self, path: &Path) -> Option<PathKind> {
1195        let meta = std::fs::symlink_metadata(path).ok()?;
1196        if meta.file_type().is_symlink() {
1197            return Some(PathKind::Symlink(std::fs::read_link(path).ok()?));
1198        }
1199        if meta.is_dir() {
1200            return Some(PathKind::Dir);
1201        }
1202        Some(PathKind::File)
1203    }
1204}
1205
1206// ---------------------------------------------------------------- the plan
1207
1208/// One filesystem operation in the composed-root plan.
1209#[derive(Debug, Clone, PartialEq, Eq)]
1210pub(crate) enum RootOp {
1211    /// `mkdir(path, 0755)`. `EEXIST` is success.
1212    MkDir(PathBuf),
1213    /// Create an empty regular file, to be bind-mounted over.
1214    MkFile(PathBuf),
1215    /// `symlink(target, link)` — used to reproduce the host's usrmerge shape and the OCI `/dev`
1216    /// symlinks.
1217    Symlink { target: PathBuf, link: PathBuf },
1218    /// `mount(source, target, MS_BIND | MS_REC)`, followed by a second
1219    /// `MS_REMOUNT | MS_BIND | MS_RDONLY` call when `read_only`. The second call is required: a
1220    /// single `MS_BIND | MS_RDONLY` mount does *not* produce a read-only bind.
1221    Bind {
1222        source: PathBuf,
1223        target: PathBuf,
1224        read_only: bool,
1225    },
1226    /// [`Self::Bind`] of a directory the parent holds a descriptor on: the child opens `source`
1227    /// after it has entered its own mount namespace, refuses unless the directory it reached has
1228    /// `identity` — the `(st_dev, st_ino)` of the parent's descriptor — and binds through the
1229    /// descriptor it opened. A link swapped onto `source` after the parent took its descriptor
1230    /// fails the step rather than redirecting the bind.
1231    ///
1232    /// The parent's descriptor itself cannot be the source: a bind's source must be a mount in
1233    /// the caller's own namespace, and one opened before `unshare(CLONE_NEWNS)` is not (`EINVAL`).
1234    BindVerified {
1235        source: PathBuf,
1236        target: PathBuf,
1237        read_only: bool,
1238        identity: (u64, u64),
1239    },
1240    /// A fresh `tmpfs`.
1241    Tmpfs {
1242        target: PathBuf,
1243        options: &'static str,
1244    },
1245    /// A `/proc`: a fresh `procfs` masked with `hidepid` where the kernel permits one, a bind of
1246    /// the host's where it does not. Which of the two a host gets is decided at execution time, not
1247    /// here — see [`PROC_HIDEPID_OPTIONS`].
1248    Proc { target: PathBuf },
1249    /// A fresh `devpts` instance, so the capsule can allocate its own ptys without seeing the
1250    /// host's.
1251    DevPts { target: PathBuf },
1252    /// `mount(NULL, target, NULL, MS_REMOUNT | MS_BIND | MS_RDONLY)` — used to seal `/dev` after
1253    /// its device nodes have been bound into it.
1254    RemountReadOnly(PathBuf),
1255}
1256
1257/// One planned operation plus whether failing it aborts the launch.
1258///
1259/// `required: false` is the "shrink, do not fail" convention this crate already uses for optional
1260/// device grants in `sandbox::open_landlock_fds`: losing an optional bind makes the composed root
1261/// *narrower*, never wider, so refusing the launch over it would trade a working capsule for no
1262/// security gain.
1263#[derive(Debug, Clone, PartialEq, Eq)]
1264pub(crate) struct RootStep {
1265    pub(crate) op: RootOp,
1266    pub(crate) required: bool,
1267}
1268
1269/// The full, host-resolved recipe for one composed root.
1270#[derive(Debug, Clone, PartialEq, Eq)]
1271pub(crate) struct ComposedRootPlan {
1272    /// Directory the new-root `tmpfs` is mounted over, and the directory `pivot_root` is handed.
1273    pub(crate) base: PathBuf,
1274    pub(crate) steps: Vec<RootStep>,
1275    /// The workdir's path *inside* the composed root. Identical to its host path by design: env
1276    /// vars, `PWD`, log paths and anything a previous turn recorded keep working, and the capsule
1277    /// never has to be told it moved.
1278    pub(crate) workdir_in_root: PathBuf,
1279}
1280
1281/// Picks the directory the new-root `tmpfs` is mounted over.
1282///
1283/// Rejects any candidate that is the workdir or an ancestor of it — overmounting an ancestor of
1284/// the workdir would hide the very directory the plan then has to bind-mount — on the same terms
1285/// any candidate that is a host path or an ancestor of one, and any candidate the host does not
1286/// have. Pure apart from the injected `exists` predicate.
1287pub(crate) fn choose_root_base(
1288    workdir: &Path,
1289    host_paths: &[PathBuf],
1290    candidates: &[&str],
1291    exists: impl Fn(&Path) -> bool,
1292) -> Option<PathBuf> {
1293    candidates
1294        .iter()
1295        .map(Path::new)
1296        .find(|candidate| {
1297            !workdir.starts_with(candidate)
1298                && !host_paths
1299                    .iter()
1300                    .any(|host_path| host_path.starts_with(candidate))
1301                && exists(candidate)
1302        })
1303        .map(Path::to_path_buf)
1304}
1305
1306/// Builds the ordered operation list for one composed root. Pure with respect to `host`.
1307///
1308/// `extra_read_only` carries the host directories this *particular* capsule needs beyond
1309/// [`SEALED_RUNTIME_PATHS`] — the containing directory of each resolved `shell.allow` binary and
1310/// each `capabilities.shell.interpreter_runtime` directory. They are whole-directory binds, not a
1311/// per-file grant set: a bind carries a directory's whole contents, so this must not grow into a
1312/// second ELF-closure derivation. Entries already covered by a fixed path are dropped.
1313///
1314/// `staged_runtime_read_only` carries the `source_path` of each declared
1315/// `capabilities.shell.staged_runtime` grant. It is a *separate* parameter from `extra_read_only`
1316/// rather than more entries in it, because the two have opposite failure semantics and the
1317/// difference is the whole point of the capability. See [`PlanBuilder::require_bind`].
1318pub(crate) fn plan_composed_root(
1319    accessible_workdir: &Path,
1320    sources: &SealedBindSources<'_>,
1321    base: &Path,
1322    extra_read_only: &[PathBuf],
1323    staged_runtime_read_only: &[PathBuf],
1324    host: &dyn HostLayout,
1325) -> ComposedRootPlan {
1326    let mut builder = PlanBuilder::new(base);
1327
1328    // -1. Host paths from `~/.murmur/config.yaml`, verified and REQUIRED, shallowest first so a
1329    //     nested entry inside another lands on top of it; then every execute root inside a
1330    //     read-write one, read-only and REQUIRED on top of that, so the capsule cannot rewrite
1331    //     what it may run. First because a host path is disjoint from every fixed path the root
1332    //     binds: every runtime folder beneath one (the workdir, the runtime, records and home
1333    //     folders, a native tool's store) is bound later, on top, and keeps its own access.
1334    let mut host_paths: Vec<&HostPathBind> = sources.host_paths.iter().collect();
1335    host_paths.sort_by_key(|host_path| host_path.path.components().count());
1336    for host_path in &host_paths {
1337        builder.host_path_bind(host_path);
1338    }
1339    for path in sources.host_path_read_only {
1340        builder.require_bind(path);
1341    }
1342
1343    // 0. Declared `staged_runtime` trees, read-only and REQUIRED — before everything else.
1344    //
1345    //    Ordering is load-bearing, not tidiness. `mirror` below deduplicates by target path via
1346    //    the `made` set, so whichever loop registers a target first wins it and any later loop
1347    //    silently no-ops on the same target. Running the required binds first means a staged path
1348    //    that happens to collide with an optional one can only ever be *upgraded* to required,
1349    //    never silently downgraded to optional.
1350    //
1351    //    These are also planned ahead of /dev, /proc, /tmp and the workdir bind — and therefore
1352    //    ahead of `pivot_root`, which `construct_composed_root` performs only after every planned
1353    //    step has run. A capsule never reaches a shell tool call inside a root that is missing a
1354    //    runtime tree it declared.
1355    for path in staged_runtime_read_only {
1356        builder.require_bind(path);
1357    }
1358
1359    // 1. The host runtime tree, read-only. A usrmerge symlink is reproduced as a symlink so the
1360    //    composed root has the host's shape; a real directory is bind-mounted.
1361    for path in SEALED_RUNTIME_PATHS
1362        .iter()
1363        .map(Path::new)
1364        .chain(extra_read_only.iter().map(PathBuf::as_path))
1365    {
1366        builder.mirror(path, host, /* required */ false);
1367    }
1368
1369    //    A step-0 or step-1 bind that landed over a directory holding a host path (a host path
1370    //    inside an `interpreter_runtime` directory or a staged tree) hid it, so each such host path
1371    //    and its read-only execute roots are bound again, on top.
1372    builder.rebind_covered_host_paths(&host_paths, sources.host_path_read_only);
1373
1374    // 2. The narrow /etc allowlist, read-only. Everything else under /etc simply does not exist.
1375    //
1376    //    An entry marked synthetic is not mirrored from the host at all: its target is bound from
1377    //    the file the parent wrote in the session's `sealed-etc/` directory.
1378    //    `required: true` there, unlike the host-mirrored entries' `required: false` — those are
1379    //    optional because a distribution may genuinely not have them, whereas the parent wrote
1380    //    these two itself, so a bind that fails is a broken runtime rather than a narrower one, and
1381    //    the empty `MkFile` left in its place would be a passwd database with no accounts in it.
1382    for entry in SEALED_ETC_PATHS {
1383        let path = Path::new(entry.path);
1384        match entry.synthetic {
1385            None => builder.mirror(path, host, /* required */ false),
1386            Some(file) => builder.bind_file(
1387                synthetic_etc_source(sources.etc_dir, file),
1388                rebase(base, path),
1389                /* required */ true,
1390            ),
1391        }
1392    }
1393
1394    // 3. A private /dev tmpfs carrying the OCI default device set, sealed read-only once it is
1395    //    populated. Device nodes are bind-mounted from the host because `mknod` of a device is
1396    //    refused inside a user namespace — see `SealedDevice`.
1397    let dev = base.join("dev");
1398    builder.mkdir_p(&dev);
1399    builder.push(
1400        RootOp::Tmpfs {
1401            target: dev.clone(),
1402            options: "mode=0755,size=1m",
1403        },
1404        true,
1405    );
1406    for device in SEALED_DEVICE_NODES {
1407        let source = PathBuf::from(device.path);
1408        if host.kind(&source).is_none() {
1409            continue;
1410        }
1411        let target = dev.join(device.name);
1412        builder.push(RootOp::MkFile(target.clone()), false);
1413        builder.push(
1414            RootOp::Bind {
1415                source,
1416                target,
1417                read_only: false,
1418            },
1419            false,
1420        );
1421    }
1422    let pts = dev.join("pts");
1423    builder.push(RootOp::MkDir(pts.clone()), false);
1424    builder.push(RootOp::DevPts { target: pts }, false);
1425    for (link, target) in SEALED_DEVICE_SYMLINKS {
1426        builder.push(
1427            RootOp::Symlink {
1428                target: PathBuf::from(*target),
1429                link: dev.join(link),
1430            },
1431            false,
1432        );
1433    }
1434    builder.push(RootOp::RemountReadOnly(dev), false);
1435
1436    // 4. /proc — masked with hidepid where the kernel allows it, bound from the host where it does
1437    //    not. See `PROC_HIDEPID_OPTIONS`.
1438    let proc = base.join("proc");
1439    builder.push(RootOp::MkDir(proc.clone()), true);
1440    builder.push(RootOp::Proc { target: proc }, true);
1441
1442    // 5. /tmp, the session's temporary folder in its records directory, counted by the same
1443    //    size guard as the workdir.
1444    //
1445    //    Before the workdir, not after, and the ordering is load-bearing rather than tidy. A
1446    //    session workdir under `/tmp` is not an edge case — it is `mur run`'s default, since
1447    //    `--workdir` falls back to a temporary directory. With the workdir reproduced first, this
1448    //    mount landed *on top of* the path leading to it, the workdir bind vanished under it, and
1449    //    the construction died at `chdir into the workdir inside the root: ENOENT` — after the
1450    //    pivot, with the host root already detached. Mounting `/tmp` first means the workdir's path
1451    //    components are created inside it, so the deeper mount is the one that survives.
1452    let tmp = base.join("tmp");
1453    builder.push(RootOp::MkDir(tmp.clone()), true);
1454    builder.push(
1455        RootOp::BindVerified {
1456            source: sources.tmp_store.0.to_path_buf(),
1457            target: tmp,
1458            read_only: false,
1459            identity: sources.tmp_store.1,
1460        },
1461        true,
1462    );
1463
1464    // 6. The capsule's workdir — the accessible one, which is also the subprocess cwd — at its
1465    //    own absolute path, read-write. With /tmp above and the home folder below, the only
1466    //    writable paths in the composed root.
1467    let workdir_target = rebase(base, accessible_workdir);
1468    builder.mkdir_p(&workdir_target);
1469    builder.push(
1470        RootOp::Bind {
1471            source: accessible_workdir.to_path_buf(),
1472            target: workdir_target,
1473            read_only: false,
1474        },
1475        true,
1476    );
1477
1478    // 7. The session's runtime directory, read-only and REQUIRED, at its own absolute path, so
1479    //    `$MURMUR_RUNTIME_DIR` names the same directory inside the root as on the host. After the
1480    //    workdir for the reason /tmp precedes it: a runtime directory under either is created
1481    //    inside it, and the deeper mount is the one that survives. A runtime directory that
1482    //    cannot be bound ends the session rather than leaving its commands without their task
1483    //    and tools.
1484    if let Some((path, identity)) = sources.runtime_dir {
1485        let target = rebase(base, path);
1486        builder.mkdir_p(&target);
1487        builder.push(
1488            RootOp::BindVerified {
1489                source: path.to_path_buf(),
1490                target,
1491                read_only: true,
1492                identity,
1493            },
1494            true,
1495        );
1496    }
1497
1498    // 8. The session's records directory, read-only and REQUIRED, at its own absolute path, so
1499    //    `$MURMUR_RECORDS_DIR` names it inside the root: a command reads the trace and logs and
1500    //    changes none of them. Bound after the runtime directory, so the deeper bind wins when one
1501    //    lies beneath the other. A records directory that cannot be bound ends the session rather than leaving
1502    //    it writable, or absent from a root whose commands are told where it is.
1503    if let Some((path, identity)) = sources.records_dir {
1504        let target = rebase(base, path);
1505        builder.mkdir_p(&target);
1506        builder.push(
1507            RootOp::BindVerified {
1508                source: path.to_path_buf(),
1509                target,
1510                read_only: true,
1511                identity,
1512            },
1513            true,
1514        );
1515    }
1516
1517    // 9. The session's home folder, read-write and REQUIRED, at its own absolute path, so `$HOME`
1518    //    and the synthetic passwd's home directory name it inside the root. After the records
1519    //    directory it lies in, so this deeper, writable bind is the one that survives on top of
1520    //    that read-only one. Verified like `/tmp`: a link swapped onto its path moves nothing.
1521    if let Some((path, identity)) = sources.home_dir {
1522        let target = rebase(base, path);
1523        builder.mkdir_p(&target);
1524        builder.push(
1525            RootOp::BindVerified {
1526                source: path.to_path_buf(),
1527                target,
1528                read_only: false,
1529                identity,
1530            },
1531            true,
1532        );
1533    }
1534
1535    // 10. A native tool's durable store, read-write and REQUIRED, at its own absolute path, on
1536    //     the home folder's terms: a tool that declared a store never runs in a root without it.
1537    //     Last, so a store beneath any directory bound above it survives on top.
1538    if let Some((path, identity)) = sources.state_dir {
1539        let target = rebase(base, path);
1540        builder.mkdir_p(&target);
1541        builder.push(
1542            RootOp::BindVerified {
1543                source: path.to_path_buf(),
1544                target,
1545                read_only: false,
1546                identity,
1547            },
1548            true,
1549        );
1550    }
1551
1552    ComposedRootPlan {
1553        base: base.to_path_buf(),
1554        steps: builder.steps,
1555        workdir_in_root: accessible_workdir.to_path_buf(),
1556    }
1557}
1558
1559/// `base` + `path`, where `path` is absolute: `/tmp` + `/usr/lib` → `/tmp/usr/lib`.
1560///
1561/// `pub(crate)` so [`crate::staged_runtime::target_under_root`] can reuse this instead of
1562/// reimplementing the same re-basing rule.
1563pub(crate) fn rebase(base: &Path, path: &Path) -> PathBuf {
1564    let relative = path.strip_prefix("/").unwrap_or(path);
1565    base.join(relative)
1566}
1567
1568struct PlanBuilder {
1569    base: PathBuf,
1570    steps: Vec<RootStep>,
1571    /// Directories already scheduled for creation, so a shared prefix (`/usr`, `/etc`) is not
1572    /// `mkdir`ed once per entry under it.
1573    made: std::collections::HashSet<PathBuf>,
1574    /// Directories in [`Self::made`] only because a host path lies beneath them: a later bind of
1575    /// one of these is still planned, rather than skipped as already made.
1576    host_path_ancestors: std::collections::HashSet<PathBuf>,
1577    /// Targets bound over a host path ancestor, which [`Self::rebind_covered_host_paths`] binds
1578    /// the hidden host paths again on top of.
1579    covering: Vec<PathBuf>,
1580}
1581
1582impl PlanBuilder {
1583    fn new(base: &Path) -> Self {
1584        let mut made = std::collections::HashSet::new();
1585        made.insert(base.to_path_buf());
1586        Self {
1587            base: base.to_path_buf(),
1588            steps: Vec::new(),
1589            made,
1590            host_path_ancestors: std::collections::HashSet::new(),
1591            covering: Vec::new(),
1592        }
1593    }
1594
1595    /// Schedules one host path's verified, required bind at its own absolute path.
1596    fn host_path_bind(&mut self, host_path: &HostPathBind) {
1597        let target = rebase(&self.base, &host_path.path);
1598        let mut current = self.base.clone();
1599        if let Ok(relative) = target.strip_prefix(&self.base) {
1600            let components: Vec<_> = relative.components().collect();
1601            for component in &components[..components.len().saturating_sub(1)] {
1602                current = current.join(component);
1603                if !self.made.contains(&current) {
1604                    self.host_path_ancestors.insert(current.clone());
1605                }
1606            }
1607        }
1608        self.mkdir_p(&target);
1609        self.host_path_ancestors.remove(&target);
1610        self.push(
1611            RootOp::BindVerified {
1612                source: host_path.path.clone(),
1613                target,
1614                read_only: host_path.read_only,
1615                identity: host_path.identity,
1616            },
1617            /* required */ true,
1618        );
1619    }
1620
1621    /// Whether a bind at `target` is skipped: something already owns it. A directory made only as
1622    /// a host path's ancestor does not count, and binding over one is recorded in
1623    /// [`Self::covering`].
1624    fn already_owned(&mut self, target: &Path) -> bool {
1625        if !self.made.contains(target) {
1626            return false;
1627        }
1628        if self.host_path_ancestors.remove(target) {
1629            self.covering.push(target.to_path_buf());
1630            return false;
1631        }
1632        true
1633    }
1634
1635    /// Binds again, on top, every host path (and every read-only execute root) a bind in
1636    /// [`Self::covering`] hid.
1637    fn rebind_covered_host_paths(&mut self, host_paths: &[&HostPathBind], read_only: &[PathBuf]) {
1638        if self.covering.is_empty() {
1639            return;
1640        }
1641        let covering = std::mem::take(&mut self.covering);
1642        let hidden = |target: &Path| {
1643            covering
1644                .iter()
1645                .any(|cover| target != cover && target.starts_with(cover))
1646        };
1647        for host_path in host_paths {
1648            let target = rebase(&self.base, &host_path.path);
1649            if hidden(&target) {
1650                self.push(
1651                    RootOp::BindVerified {
1652                        source: host_path.path.clone(),
1653                        target,
1654                        read_only: host_path.read_only,
1655                        identity: host_path.identity,
1656                    },
1657                    true,
1658                );
1659            }
1660        }
1661        for path in read_only {
1662            let target = rebase(&self.base, path);
1663            if hidden(&target) {
1664                self.push(
1665                    RootOp::Bind {
1666                        source: path.clone(),
1667                        target,
1668                        read_only: true,
1669                    },
1670                    true,
1671                );
1672            }
1673        }
1674    }
1675
1676    fn push(&mut self, op: RootOp, required: bool) {
1677        self.steps.push(RootStep { op, required });
1678    }
1679
1680    /// Schedules `mkdir` for every not-yet-scheduled component of `target` under the base.
1681    fn mkdir_p(&mut self, target: &Path) {
1682        let mut current = self.base.clone();
1683        let Ok(relative) = target.strip_prefix(&self.base) else {
1684            return;
1685        };
1686        for component in relative.components() {
1687            current = current.join(component);
1688            if self.made.insert(current.clone()) {
1689                self.steps.push(RootStep {
1690                    op: RootOp::MkDir(current.clone()),
1691                    required: true,
1692                });
1693            }
1694        }
1695    }
1696
1697    /// Schedules an unconditional, **required** read-only directory bind of `source`.
1698    ///
1699    /// The deliberate difference from [`Self::mirror`] is that this never consults [`HostLayout`].
1700    /// `mirror` asks the host whether a path exists and, when it does not, returns having
1701    /// scheduled *nothing at all* — not even a step that would fail. That is right for
1702    /// `extra_read_only`, where a missing path should make the composed root narrower rather than
1703    /// refuse the launch. It is exactly wrong for `staged_runtime`, where a missing source must
1704    /// fail the launch: routed through `mirror`, a capsule declaring a runtime tree the host does
1705    /// not have would launch successfully into a root silently lacking it.
1706    ///
1707    /// So no existence pre-check happens here, or anywhere else in this module. The plan always
1708    /// carries the step, `required: true`, and the real `mount(2)` in `execute_step`'s
1709    /// [`CStepKind::Bind`] arm is the single source of truth for "does this exist" — a missing
1710    /// source fails with `ENOENT` at construction time, in the child, which
1711    /// `construct_composed_root`'s `step.required` check turns into a `SealedRootFailure`. That
1712    /// path already reaches the operator as `RuntimeError::SealedRootConstructionFailed`
1713    /// (`E-RUN-014`), already names the offending path via [`SealedRootSpec::describe`], and is
1714    /// already session-fatal, so no error variant of its own is needed.
1715    ///
1716    /// Always a directory bind: a staged runtime tree is a tree. `mkdir_p` registers the target in
1717    /// `made`, so a later `mirror` of the same path is the one that no-ops, never this.
1718    fn require_bind(&mut self, source: &Path) {
1719        let target = rebase(&self.base, source);
1720        if self.already_owned(&target) {
1721            return;
1722        }
1723        self.mkdir_p(&target);
1724        self.push(
1725            RootOp::Bind {
1726                source: source.to_path_buf(),
1727                target,
1728                read_only: true,
1729            },
1730            /* required */ true,
1731        );
1732    }
1733
1734    /// Reproduces one host path inside the composed root: a symlink as a symlink, a directory or
1735    /// file as a read-only bind mount. A path the host does not have contributes nothing.
1736    fn mirror(&mut self, source: &Path, host: &dyn HostLayout, required: bool) {
1737        let Some(kind) = host.kind(source) else {
1738            return;
1739        };
1740        let target = rebase(&self.base, source);
1741        if self.already_owned(&target) {
1742            return;
1743        }
1744        match kind {
1745            PathKind::Symlink(link_target) => {
1746                if let Some(parent) = target.parent() {
1747                    self.mkdir_p(parent);
1748                }
1749                self.made.insert(target.clone());
1750                self.push(
1751                    RootOp::Symlink {
1752                        target: link_target,
1753                        link: target,
1754                    },
1755                    required,
1756                );
1757            }
1758            PathKind::Dir => {
1759                self.mkdir_p(&target);
1760                self.push(
1761                    RootOp::Bind {
1762                        source: source.to_path_buf(),
1763                        target,
1764                        read_only: true,
1765                    },
1766                    required,
1767                );
1768            }
1769            PathKind::File => self.bind_file(source.to_path_buf(), target, required),
1770        }
1771    }
1772
1773    /// Schedules the mountpoint-then-bind pair one file bind needs: `mkdir -p` of its parent, an
1774    /// empty file to mount over (a bind needs an existing target of the right kind), then the
1775    /// read-only bind itself.
1776    ///
1777    /// Takes `source` and `target` separately rather than deriving one from the other, which is the
1778    /// whole reason it is a method: [`Self::mirror`] binds a host path at its own path, while a
1779    /// synthetic `/etc` entry binds a file from inside the workdir at a path that names something
1780    /// else entirely.
1781    fn bind_file(&mut self, source: PathBuf, target: PathBuf, required: bool) {
1782        if self.made.contains(&target) {
1783            return;
1784        }
1785        if let Some(parent) = target.parent() {
1786            self.mkdir_p(parent);
1787        }
1788        self.made.insert(target.clone());
1789        self.push(RootOp::MkFile(target.clone()), required);
1790        self.push(
1791            RootOp::Bind {
1792                source,
1793                target,
1794                read_only: true,
1795            },
1796            required,
1797        );
1798    }
1799}
1800
1801// ---------------------------------------------------------------- Linux: probe + execution
1802
1803/// Marker every composed-root failure message carries, so `shell.rs` can tell a `pre_exec`
1804/// sealed-root failure apart from a Landlock or seccomp one after reading it back off the
1805/// diagnostic pipe — the pipe carries one flat string, so the classification has to live in the
1806/// text. Declared here rather than inside the Linux submodule because the reader is
1807/// cross-platform code.
1808pub(crate) const SEALED_ROOT_FAILURE_PREFIX: &str = "sealed-root:";
1809
1810#[cfg(target_os = "linux")]
1811pub(crate) use linux::{build_sealed_root_spec, construct_composed_root, SealedRootSpec};
1812
1813/// The post-`fork()` namespace primitives [`crate::network_namespace`] reuses rather than
1814/// reimplementing.
1815///
1816/// The `dumpable` pair is shared because a non-dumpable task's `/proc/self/uid_map` is root-owned
1817/// and therefore unopenable by the task itself, and every namespace `mur` creates hits that trap
1818/// identically — see [`linux::make_dumpable_for_map_writes`].
1819///
1820/// `userns_grant` is shared because it answers where the permission for `unshare(CLONE_NEWUSER)`
1821/// comes from, which `sealed` and the capsule network namespace both need. Two implementations of
1822/// one host question could return two answers, and the operator would be told to fix two different
1823/// things.
1824#[cfg(target_os = "linux")]
1825pub(crate) use linux::{
1826    make_dumpable_for_map_writes, open_verified_dir, proc_fd_path, remount_read_only,
1827    restore_dumpable, userns_grant, write_decimal_map, PROC_FD_PATH_LEN,
1828};
1829
1830#[cfg(target_os = "linux")]
1831#[allow(unsafe_code)]
1832mod linux {
1833    use std::ffi::CString;
1834    use std::os::unix::ffi::OsStrExt;
1835    use std::path::Path;
1836
1837    use super::{
1838        ComposedRootPlan, RootOp, RootStep, UsernsGrant, OLD_ROOT_NAME, PROC_HIDEPID_OPTIONS,
1839        SEALED_ROOT_FAILURE_PREFIX,
1840    };
1841
1842    /// Reads the three host files [`UsernsGrant::from_readings`] decides on, which holds the
1843    /// whole decision.
1844    ///
1845    /// The knob is read from `/sys/module/apparmor/parameters` first and from `/proc/sys/kernel`
1846    /// only if that is missing: the two are the same value, and the `/sys` path is the one that
1847    /// exists on kernels where the `/proc` alias does not. Reading `/proc/self/attr/current`
1848    /// rather than the loaded-profile list is deliberate: a profile that is loaded but does not
1849    /// *attach* to the path `mur` was installed at helps nobody, and this asks the question that
1850    /// decides the outcome.
1851    pub(crate) fn userns_grant() -> UsernsGrant {
1852        let enabled = read_trimmed("/sys/module/apparmor/parameters/enabled");
1853        let restricted =
1854            read_trimmed("/sys/module/apparmor/parameters/restrict_unprivileged_userns")
1855                .or_else(|| read_trimmed("/proc/sys/kernel/apparmor_restrict_unprivileged_userns"));
1856        let current = read_trimmed("/proc/self/attr/current");
1857        UsernsGrant::from_readings(
1858            enabled.as_deref(),
1859            restricted.as_deref(),
1860            current.as_deref(),
1861        )
1862    }
1863
1864    fn read_trimmed(path: &str) -> Option<String> {
1865        std::fs::read_to_string(path)
1866            .ok()
1867            .map(|value| value.trim().to_string())
1868            .filter(|value| !value.is_empty())
1869    }
1870
1871    /// Flips `PR_SET_DUMPABLE` for the duration of the identity-map writes, and back again.
1872    ///
1873    /// Not optional. `mur` marks itself
1874    /// non-dumpable at startup (`security::harden_process_dumpable`, `prctl(PR_SET_DUMPABLE, 0)`)
1875    /// so that no same-uid process can read its `/proc/<pid>/environ`. That flag is inherited
1876    /// across `fork()`, and the kernel's `task_dump_owner()` reassigns *every* `/proc/<pid>/*`
1877    /// entry of a non-dumpable task to root — including `setgroups`, `uid_map` and `gid_map`. An
1878    /// unprivileged process therefore cannot open its own `uid_map` for writing, and the namespace
1879    /// it just created is one it can never own.
1880    ///
1881    /// The symptom is misleading: `unshare` succeeds, the map write fails with `EACCES`, and the
1882    /// refusal blames the host's id-mapping policy — on a host whose id-mapping policy is fine,
1883    /// and where the identical syscall sequence run from any other program succeeds. Without this
1884    /// flip, `sealed` is unreachable on every host for that reason alone.
1885    ///
1886    /// The window is reopened for exactly the three `open`/`write`/`close` pairs that need it and
1887    /// closed again before the first mount, so the child spends no longer readable than the map
1888    /// writes take.
1889    ///
1890    /// [`restore_dumpable`] puts back whatever the flag *was*, read here and returned, rather than
1891    /// assuming `0`. Hardcoding `0` looked equivalent — `mur` always runs non-dumpable — and was
1892    /// not: it also cleared the flag for every process that had *not* hardened itself, which
1893    /// includes this crate's own integration tests, and a non-dumpable child is one whose
1894    /// `/proc/<pid>/mem` the seccomp-notify supervisor could not read — every allowlisted `execve`
1895    /// then failed closed to `EACCES`.
1896    ///
1897    /// # Safety
1898    /// Post-`fork()`, pre-exec child context only — `prctl(2)` is async-signal-safe, but the
1899    /// dumpable window this reopens is only sound while this process is the single-threaded child
1900    /// that is about to `execve` a sandboxed binary.
1901    pub(crate) unsafe fn make_dumpable_for_map_writes() -> libc::c_int {
1902        // SAFETY: both `prctl` calls take four integer arguments; no pointers cross the boundary.
1903        // `PR_GET_DUMPABLE` returning `-1` means the query failed, in which case the restore below
1904        // is skipped rather than guessing.
1905        unsafe {
1906            let previous = libc::prctl(libc::PR_GET_DUMPABLE, 0, 0, 0, 0);
1907            libc::prctl(libc::PR_SET_DUMPABLE, 1 as libc::c_int, 0, 0, 0);
1908            previous
1909        }
1910    }
1911
1912    /// Undoes [`make_dumpable_for_map_writes`], restoring the exact value it found.
1913    ///
1914    /// # Safety
1915    /// Same window and same constraints as [`make_dumpable_for_map_writes`]; `previous` must be
1916    /// the value that call returned.
1917    pub(crate) unsafe fn restore_dumpable(previous: libc::c_int) {
1918        if previous < 0 {
1919            return;
1920        }
1921        // SAFETY: integer-only `prctl`, in the post-fork child.
1922        unsafe {
1923            libc::prctl(libc::PR_SET_DUMPABLE, previous, 0, 0, 0);
1924        }
1925    }
1926
1927    /// Writes either the literal `deny` (when `id` is `None`) or an identity map line
1928    /// `"<id> <id> 1\n"`, formatting the number into a stack buffer.
1929    ///
1930    /// Hand-rolled rather than `format!` because this runs in a forked child: the parent may have
1931    /// been mid-allocation on another thread when `fork()` returned, so touching the allocator
1932    /// here can deadlock. `id` never exceeds `u32::MAX`, so ten digits is the whole range.
1933    ///
1934    /// # Safety
1935    /// Must only be called from the post-`fork()`, pre-exec window in the child, before any other
1936    /// thread could exist in this process — the same async-signal-safety constraint documented on
1937    /// [`construct_composed_root`]'s `unsafe` block, which this function's callers all sit inside.
1938    pub(crate) unsafe fn write_decimal_map(
1939        path: &std::ffi::CStr,
1940        id: Option<libc::uid_t>,
1941        value: libc::uid_t,
1942    ) -> Result<(), ()> {
1943        let Some(_) = id else {
1944            return write_file(path, b"deny");
1945        };
1946
1947        let mut buf = [0u8; 32];
1948        let mut len = 0usize;
1949        push_decimal(&mut buf, &mut len, value);
1950        buf[len] = b' ';
1951        len += 1;
1952        push_decimal(&mut buf, &mut len, value);
1953        buf[len] = b' ';
1954        len += 1;
1955        buf[len] = b'1';
1956        len += 1;
1957        buf[len] = b'\n';
1958        len += 1;
1959
1960        write_file(path, &buf[..len])
1961    }
1962
1963    // ------------------------------------------------------------ spec (parent side)
1964
1965    /// One planned operation, lowered to NUL-terminated C strings plus a pre-rendered label.
1966    ///
1967    /// The label is built here, in the parent, precisely so the child never has to: a failure
1968    /// message that names its step costs one `String` per step at spawn time and zero allocation
1969    /// inside `pre_exec`.
1970    struct CStep {
1971        kind: CStepKind,
1972        required: bool,
1973        label: String,
1974    }
1975
1976    enum CStepKind {
1977        MkDir(CString),
1978        MkFile(CString),
1979        Symlink {
1980            target: CString,
1981            link: CString,
1982        },
1983        Bind {
1984            source: CString,
1985            target: CString,
1986            read_only: bool,
1987        },
1988        BindVerified {
1989            source: CString,
1990            target: CString,
1991            read_only: bool,
1992            identity: (u64, u64),
1993        },
1994        Tmpfs {
1995            target: CString,
1996            options: CString,
1997        },
1998        Proc {
1999            target: CString,
2000        },
2001        DevPts {
2002            target: CString,
2003        },
2004        RemountReadOnly(CString),
2005    }
2006
2007    /// Everything the child needs to build a composed root, fully resolved before `fork()`.
2008    pub(crate) struct SealedRootSpec {
2009        uid_map: Vec<u8>,
2010        gid_map: Vec<u8>,
2011        base: CString,
2012        /// `<base>/.mur-oldroot` — created before the pivot.
2013        old_root_full: CString,
2014        /// `.mur-oldroot`, relative, as `pivot_root(2)` wants it with `cwd == base`.
2015        old_root_relative: CString,
2016        /// `/.mur-oldroot`, as it is named after the pivot, for the detach and the rmdir.
2017        old_root_pivoted: CString,
2018        steps: Vec<CStep>,
2019        workdir_in_root: CString,
2020    }
2021
2022    /// Where a composed root's construction failed. `Copy` and allocation-free by construction —
2023    /// it is produced inside `pre_exec`; the message is rendered by the caller, afterwards.
2024    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
2025    pub(crate) struct SealedRootFailure {
2026        /// Index into the spec's step list, or `usize::MAX` for one of the fixed
2027        /// unshare/pivot/chdir stages.
2028        pub(crate) step: usize,
2029        /// A fixed stage name when `step` is `usize::MAX`; otherwise unused.
2030        pub(crate) stage: &'static str,
2031        pub(crate) errno: i32,
2032    }
2033
2034    impl SealedRootFailure {
2035        fn stage(stage: &'static str) -> Self {
2036            Self {
2037                step: usize::MAX,
2038                stage,
2039                errno: std::io::Error::last_os_error().raw_os_error().unwrap_or(0),
2040            }
2041        }
2042
2043        fn step(index: usize) -> Self {
2044            Self {
2045                step: index,
2046                stage: "",
2047                errno: std::io::Error::last_os_error().raw_os_error().unwrap_or(0),
2048            }
2049        }
2050    }
2051
2052    impl SealedRootSpec {
2053        /// Renders a failure into the message that reaches the operator, naming both the step and
2054        /// the errno. Runs in the *parent's* error path (via the diagnostic pipe) or in the
2055        /// child's, where an allocation is already unavoidable because the launch is over.
2056        pub(crate) fn describe(&self, failure: SealedRootFailure) -> String {
2057            let what = if failure.step == usize::MAX {
2058                failure.stage.to_string()
2059            } else {
2060                self.steps
2061                    .get(failure.step)
2062                    .map(|step| step.label.clone())
2063                    .unwrap_or_else(|| format!("step {}", failure.step))
2064            };
2065            format!(
2066                "{SEALED_ROOT_FAILURE_PREFIX} {what} failed: {}",
2067                std::io::Error::from_raw_os_error(failure.errno)
2068            )
2069        }
2070    }
2071
2072    /// Remounts the bind at `target` read-only and `nosuid`, keeping the flags its source mount
2073    /// locks. `target` may be a `/proc/self/fd/<n>` path from [`proc_fd_path`].
2074    ///
2075    /// # Safety
2076    /// Allocation-free; callable post-`fork()`, pre-exec, with the capabilities to remount.
2077    pub(crate) unsafe fn remount_read_only(target: &std::ffi::CStr) -> Result<(), ()> {
2078        // Inside a user namespace the bind inherits the source mount's flags locked: a remount
2079        // that does not repeat them is refused with `EPERM`. `statvfs` on the new bind reports
2080        // them from the kernel's `statfs` `f_flags`, with no allocation.
2081        let mut stat: libc::statvfs = std::mem::zeroed();
2082        let kept = if libc::statvfs(target.as_ptr(), &mut stat) == 0 {
2083            remount_flags_kept(stat.f_flag)
2084        } else {
2085            0
2086        };
2087        if libc::mount(
2088            std::ptr::null(),
2089            target.as_ptr(),
2090            std::ptr::null(),
2091            libc::MS_REMOUNT | libc::MS_BIND | libc::MS_RDONLY | libc::MS_NOSUID | kept,
2092            std::ptr::null(),
2093        ) != 0
2094        {
2095            return Err(());
2096        }
2097        Ok(())
2098    }
2099
2100    /// `"/proc/self/fd/"` and the longest decimal `c_int` and a NUL.
2101    pub(crate) const PROC_FD_PATH_LEN: usize = 32;
2102
2103    /// Writes `/proc/self/fd/<fd>` and a NUL into `out`, with no allocation.
2104    pub(crate) fn proc_fd_path(
2105        fd: libc::c_int,
2106        out: &mut [u8; PROC_FD_PATH_LEN],
2107    ) -> Result<(), ()> {
2108        const PREFIX: &[u8] = b"/proc/self/fd/";
2109        if fd < 0 {
2110            return Err(());
2111        }
2112        out[..PREFIX.len()].copy_from_slice(PREFIX);
2113        let mut len = PREFIX.len();
2114        push_decimal(out, &mut len, fd as u32);
2115        out[len] = 0;
2116        Ok(())
2117    }
2118
2119    /// Appends `n` in decimal to `buf` at `*len`, advancing `*len`. Allocation-free, for the
2120    /// post-`fork()` window; `buf` must have room for ten more bytes.
2121    fn push_decimal(buf: &mut [u8], len: &mut usize, mut n: u32) {
2122        let mut digits = [0u8; 10];
2123        let mut count = 0;
2124        loop {
2125            digits[count] = b'0' + (n % 10) as u8;
2126            count += 1;
2127            n /= 10;
2128            if n == 0 {
2129                break;
2130            }
2131        }
2132        while count > 0 {
2133            count -= 1;
2134            buf[*len] = digits[count];
2135            *len += 1;
2136        }
2137    }
2138
2139    /// Opens the directory at `source` with `O_PATH`, and returns its descriptor only when its
2140    /// `(st_dev, st_ino)` is `identity`. A mismatch closes it and fails with `ESTALE`.
2141    ///
2142    /// # Safety
2143    /// Allocation-free; callable post-`fork()`, pre-exec.
2144    pub(crate) unsafe fn open_verified_dir(
2145        source: &CString,
2146        identity: (u64, u64),
2147    ) -> Result<libc::c_int, ()> {
2148        let fd = libc::open(
2149            source.as_ptr(),
2150            libc::O_PATH | libc::O_DIRECTORY | libc::O_CLOEXEC,
2151        );
2152        if fd < 0 {
2153            return Err(());
2154        }
2155        let mut stat: libc::stat = std::mem::zeroed();
2156        if libc::fstat(fd, &mut stat) != 0 {
2157            let errno = *libc::__errno_location();
2158            libc::close(fd);
2159            *libc::__errno_location() = errno;
2160            return Err(());
2161        }
2162        if (stat.st_dev as u64, stat.st_ino as u64) != identity {
2163            libc::close(fd);
2164            *libc::__errno_location() = libc::ESTALE;
2165            return Err(());
2166        }
2167        Ok(fd)
2168    }
2169
2170    /// The mount flags a read-only remount of a bind has to repeat from the mount it was bound
2171    /// from, given that mount's `statvfs` `f_flag`: `nodev`, `noexec`, `nosuid` and the atime
2172    /// mode. A user namespace locks each of them on a mount it did not create, and a remount that
2173    /// drops one is refused with `EPERM`. No atime flag at all is `strictatime`, which is stated
2174    /// explicitly because a remount naming none defaults to `relatime`.
2175    pub(crate) fn remount_flags_kept(f_flags: libc::c_ulong) -> libc::c_ulong {
2176        let has = |flag: libc::c_ulong| f_flags & flag != 0;
2177        let mut kept: libc::c_ulong = 0;
2178        for (st, ms) in [
2179            (libc::ST_NODEV, libc::MS_NODEV),
2180            (libc::ST_NOEXEC, libc::MS_NOEXEC),
2181            (libc::ST_NOSUID, libc::MS_NOSUID),
2182            (libc::ST_NOATIME, libc::MS_NOATIME),
2183            (libc::ST_NODIRATIME, libc::MS_NODIRATIME),
2184            (libc::ST_RELATIME, libc::MS_RELATIME),
2185        ] {
2186            if has(st) {
2187                kept |= ms;
2188            }
2189        }
2190        if !has(libc::ST_NOATIME) && !has(libc::ST_RELATIME) {
2191            kept |= libc::MS_STRICTATIME;
2192        }
2193        kept
2194    }
2195
2196    /// Lowers a [`ComposedRootPlan`] into the C-string form the child executes. Fails only on a
2197    /// path containing an interior NUL, which no real path does.
2198    pub(crate) fn build_sealed_root_spec(
2199        plan: &ComposedRootPlan,
2200    ) -> Result<SealedRootSpec, String> {
2201        // SAFETY: `getuid`/`getgid` take no arguments, dereference nothing and cannot fail.
2202        let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
2203
2204        let mut steps = Vec::with_capacity(plan.steps.len());
2205        for RootStep { op, required } in &plan.steps {
2206            let (kind, label) = match op {
2207                RootOp::MkDir(path) => (
2208                    CStepKind::MkDir(cstr(path)?),
2209                    format!("mkdir {}", path.display()),
2210                ),
2211                RootOp::MkFile(path) => (
2212                    CStepKind::MkFile(cstr(path)?),
2213                    format!("create {}", path.display()),
2214                ),
2215                RootOp::Symlink { target, link } => (
2216                    CStepKind::Symlink {
2217                        target: cstr(target)?,
2218                        link: cstr(link)?,
2219                    },
2220                    format!("symlink {} -> {}", link.display(), target.display()),
2221                ),
2222                RootOp::Bind {
2223                    source,
2224                    target,
2225                    read_only,
2226                } => (
2227                    CStepKind::Bind {
2228                        source: cstr(source)?,
2229                        target: cstr(target)?,
2230                        read_only: *read_only,
2231                    },
2232                    format!(
2233                        "bind{} {} -> {}",
2234                        if *read_only { " (ro)" } else { "" },
2235                        source.display(),
2236                        target.display()
2237                    ),
2238                ),
2239                RootOp::BindVerified {
2240                    source,
2241                    target,
2242                    read_only,
2243                    identity,
2244                } => (
2245                    CStepKind::BindVerified {
2246                        source: cstr(source)?,
2247                        target: cstr(target)?,
2248                        read_only: *read_only,
2249                        identity: *identity,
2250                    },
2251                    format!(
2252                        "bind{} {} (dev:ino {}:{}) -> {}",
2253                        if *read_only { " (ro)" } else { "" },
2254                        source.display(),
2255                        identity.0,
2256                        identity.1,
2257                        target.display()
2258                    ),
2259                ),
2260                RootOp::Tmpfs { target, options } => (
2261                    CStepKind::Tmpfs {
2262                        target: cstr(target)?,
2263                        options: CString::new(*options).map_err(|error| error.to_string())?,
2264                    },
2265                    format!("tmpfs on {}", target.display()),
2266                ),
2267                RootOp::Proc { target } => (
2268                    CStepKind::Proc {
2269                        target: cstr(target)?,
2270                    },
2271                    format!("proc on {}", target.display()),
2272                ),
2273                RootOp::DevPts { target } => (
2274                    CStepKind::DevPts {
2275                        target: cstr(target)?,
2276                    },
2277                    format!("devpts on {}", target.display()),
2278                ),
2279                RootOp::RemountReadOnly(path) => (
2280                    CStepKind::RemountReadOnly(cstr(path)?),
2281                    format!("remount read-only {}", path.display()),
2282                ),
2283            };
2284            steps.push(CStep {
2285                kind,
2286                required: *required,
2287                label,
2288            });
2289        }
2290
2291        Ok(SealedRootSpec {
2292            // Identity maps: the capsule keeps the uid it already had, so files it writes into the
2293            // bind-mounted workdir are owned by the real user rather than by `nobody`.
2294            uid_map: format!("{uid} {uid} 1\n").into_bytes(),
2295            gid_map: format!("{gid} {gid} 1\n").into_bytes(),
2296            base: cstr(&plan.base)?,
2297            old_root_full: cstr(&plan.base.join(OLD_ROOT_NAME))?,
2298            old_root_relative: CString::new(OLD_ROOT_NAME).map_err(|e| e.to_string())?,
2299            old_root_pivoted: CString::new(format!("/{OLD_ROOT_NAME}"))
2300                .map_err(|e| e.to_string())?,
2301            steps,
2302            workdir_in_root: cstr(&plan.workdir_in_root)?,
2303        })
2304    }
2305
2306    fn cstr(path: &Path) -> Result<CString, String> {
2307        CString::new(path.as_os_str().as_bytes())
2308            .map_err(|_| format!("path contains an interior NUL: {}", path.display()))
2309    }
2310
2311    // ------------------------------------------------------------ execution (child side)
2312
2313    /// Builds the composed root and pivots onto it. Runs inside the forked child's `pre_exec`
2314    /// window, before fd hygiene, the capability drop, seccomp and Landlock — which is the only
2315    /// window in which `unshare`/`mount`/`pivot_root` are available at all, since
2316    /// `sandbox::SECCOMP_MUST_STAY_DENIED` denies all three to every process the filter covers.
2317    ///
2318    /// Allocation-free on the success path: every path was turned into a `CString` by
2319    /// [`build_sealed_root_spec`] before `fork()`.
2320    pub(crate) fn construct_composed_root(spec: &SealedRootSpec) -> Result<(), SealedRootFailure> {
2321        // SAFETY: every call below is a bare syscall over pointers into `spec`, which outlives
2322        // this function. No allocation, no locks, no reentrancy — the constraints of the
2323        // post-fork/pre-exec window.
2324        unsafe {
2325            // 1. The namespaces. `CLONE_NEWUSER` is what makes `CLONE_NEWNS` available without
2326            //    host root; asking for both in one call means the mount namespace is created with
2327            //    the new user namespace's credentials already in force.
2328            if libc::unshare(libc::CLONE_NEWUSER | libc::CLONE_NEWNS) != 0 {
2329                return Err(SealedRootFailure::stage(
2330                    "unshare(CLONE_NEWUSER|CLONE_NEWNS)",
2331                ));
2332            }
2333
2334            // 2. Identity uid/gid maps. `setgroups=deny` first, which the kernel requires before
2335            //    an unprivileged process may write `gid_map`. A host without `setgroups` (pre-3.19)
2336            //    is tolerated; a failing `uid_map` is not, because running as the overflow uid
2337            //    would leave the capsule unable to write its own workdir.
2338            //
2339            //    Dumpable is flipped on for exactly these three writes and put back before the
2340            //    first mount: `mur` runs non-dumpable, and a non-dumpable task's `/proc/self/*`
2341            //    entries are owned by root, which makes its own `uid_map` unopenable. See
2342            //    [`make_dumpable_for_map_writes`].
2343            let previous_dumpable = make_dumpable_for_map_writes();
2344            let _ = write_file(c"/proc/self/setgroups", b"deny");
2345            let uid_mapped = write_file(c"/proc/self/uid_map", &spec.uid_map).is_ok();
2346            let gid_mapped = uid_mapped && write_file(c"/proc/self/gid_map", &spec.gid_map).is_ok();
2347            restore_dumpable(previous_dumpable);
2348            if !uid_mapped {
2349                return Err(SealedRootFailure::stage("write /proc/self/uid_map"));
2350            }
2351            if !gid_mapped {
2352                return Err(SealedRootFailure::stage("write /proc/self/gid_map"));
2353            }
2354
2355            // 3. Private propagation, so none of what follows escapes back to the host's mounts.
2356            if libc::mount(
2357                std::ptr::null(),
2358                c"/".as_ptr(),
2359                std::ptr::null(),
2360                libc::MS_REC | libc::MS_PRIVATE,
2361                std::ptr::null(),
2362            ) != 0
2363            {
2364                return Err(SealedRootFailure::stage("mount --make-rprivate /"));
2365            }
2366
2367            // 4. The new root: an empty tmpfs over the chosen base. It is a mount point, which is
2368            //    what `pivot_root(2)` requires of its new root.
2369            if libc::mount(
2370                c"tmpfs".as_ptr(),
2371                spec.base.as_ptr(),
2372                c"tmpfs".as_ptr(),
2373                libc::MS_NOSUID | libc::MS_NODEV,
2374                c"mode=0755".as_ptr() as *const libc::c_void,
2375            ) != 0
2376            {
2377                return Err(SealedRootFailure::stage(
2378                    "mount tmpfs for the composed root",
2379                ));
2380            }
2381
2382            // 5. The plan.
2383            for (index, step) in spec.steps.iter().enumerate() {
2384                if execute_step(step).is_err() && step.required {
2385                    return Err(SealedRootFailure::step(index));
2386                }
2387            }
2388
2389            // 6. Pivot. `chdir` into the new root first so `pivot_root` can be handed the pair of
2390            //    relative paths the syscall is happiest with.
2391            if libc::mkdir(spec.old_root_full.as_ptr(), 0o700) != 0 {
2392                return Err(SealedRootFailure::stage(
2393                    "mkdir the old-root parking directory",
2394                ));
2395            }
2396            if libc::chdir(spec.base.as_ptr()) != 0 {
2397                return Err(SealedRootFailure::stage("chdir into the composed root"));
2398            }
2399            if libc::syscall(
2400                libc::SYS_pivot_root,
2401                c".".as_ptr(),
2402                spec.old_root_relative.as_ptr(),
2403            ) != 0
2404            {
2405                return Err(SealedRootFailure::stage("pivot_root"));
2406            }
2407            if libc::chdir(c"/".as_ptr()) != 0 {
2408                return Err(SealedRootFailure::stage("chdir / after pivot_root"));
2409            }
2410
2411            // 7. Detach and remove the old root. After this there is no pathname anywhere in this
2412            //    namespace that reaches the host filesystem.
2413            if libc::umount2(spec.old_root_pivoted.as_ptr(), libc::MNT_DETACH) != 0 {
2414                return Err(SealedRootFailure::stage("umount2 the old root"));
2415            }
2416            // Best-effort, deliberately: `MNT_DETACH` already removed the old root from this
2417            // namespace's tree, so a leftover empty directory here is cosmetic. Failing a launch
2418            // over it would trade a working capsule for nothing.
2419            libc::rmdir(spec.old_root_pivoted.as_ptr());
2420
2421            // 8. Seal the root tmpfs itself. `MS_REMOUNT | MS_BIND` changes per-mount flags only,
2422            //    which is the well-defined way to make an existing mount read-only; the workdir
2423            //    bind underneath it keeps its own (writable) flags.
2424            if libc::mount(
2425                std::ptr::null(),
2426                c"/".as_ptr(),
2427                std::ptr::null(),
2428                libc::MS_REMOUNT
2429                    | libc::MS_BIND
2430                    | libc::MS_RDONLY
2431                    | libc::MS_NOSUID
2432                    | libc::MS_NODEV,
2433                std::ptr::null(),
2434            ) != 0
2435            {
2436                return Err(SealedRootFailure::stage(
2437                    "remount the composed root read-only",
2438                ));
2439            }
2440
2441            // 9. Back into the workdir, at the same absolute path it had on the host. `Command`
2442            //    already `chdir`ed here before this closure ran, but that was in the old root.
2443            if libc::chdir(spec.workdir_in_root.as_ptr()) != 0 {
2444                return Err(SealedRootFailure::stage(
2445                    "chdir into the workdir inside the root",
2446                ));
2447            }
2448        }
2449
2450        Ok(())
2451    }
2452
2453    /// Executes one planned step. `Err(())` carries no detail — the caller pairs the index with
2454    /// the spec's pre-rendered label and the live errno.
2455    ///
2456    /// # Safety
2457    /// Must only be called from the post-`fork()`, pre-exec window: every branch is a bare
2458    /// syscall over `CStr` pointers borrowed from `step`, which the caller guarantees outlives
2459    /// the call, and none may allocate or take a lock that a sibling thread could hold frozen at
2460    /// `fork()` time.
2461    unsafe fn execute_step(step: &CStep) -> Result<(), ()> {
2462        match &step.kind {
2463            CStepKind::MkDir(path) => {
2464                if libc::mkdir(path.as_ptr(), 0o755) != 0
2465                    && *libc::__errno_location() != libc::EEXIST
2466                {
2467                    return Err(());
2468                }
2469            }
2470            CStepKind::MkFile(path) => {
2471                let fd = libc::open(
2472                    path.as_ptr(),
2473                    libc::O_CREAT | libc::O_WRONLY | libc::O_CLOEXEC,
2474                    libc::mode_t::from(0o644u16),
2475                );
2476                if fd < 0 {
2477                    return Err(());
2478                }
2479                libc::close(fd);
2480            }
2481            CStepKind::Symlink { target, link } => {
2482                if libc::symlink(target.as_ptr(), link.as_ptr()) != 0 {
2483                    return Err(());
2484                }
2485            }
2486            CStepKind::Bind {
2487                source,
2488                target,
2489                read_only,
2490            } => {
2491                if libc::mount(
2492                    source.as_ptr(),
2493                    target.as_ptr(),
2494                    std::ptr::null(),
2495                    libc::MS_BIND | libc::MS_REC,
2496                    std::ptr::null(),
2497                ) != 0
2498                {
2499                    return Err(());
2500                }
2501                if *read_only {
2502                    remount_read_only(target)?;
2503                }
2504            }
2505            CStepKind::BindVerified {
2506                source,
2507                target,
2508                read_only,
2509                identity,
2510            } => {
2511                let fd = open_verified_dir(source, *identity)?;
2512                let mut path = [0u8; PROC_FD_PATH_LEN];
2513                let bound = proc_fd_path(fd, &mut path).is_ok()
2514                    && libc::mount(
2515                        path.as_ptr() as *const libc::c_char,
2516                        target.as_ptr(),
2517                        std::ptr::null(),
2518                        libc::MS_BIND | libc::MS_REC,
2519                        std::ptr::null(),
2520                    ) == 0;
2521                // `close` may overwrite `errno`; the mount's is the one the failure reports.
2522                let errno = *libc::__errno_location();
2523                libc::close(fd);
2524                if !bound {
2525                    *libc::__errno_location() = errno;
2526                    return Err(());
2527                }
2528                if *read_only {
2529                    remount_read_only(target)?;
2530                }
2531            }
2532            CStepKind::Tmpfs { target, options } => {
2533                if libc::mount(
2534                    c"tmpfs".as_ptr(),
2535                    target.as_ptr(),
2536                    c"tmpfs".as_ptr(),
2537                    libc::MS_NOSUID,
2538                    options.as_ptr() as *const libc::c_void,
2539                ) != 0
2540                {
2541                    return Err(());
2542                }
2543            }
2544            CStepKind::Proc { target } => {
2545                // Try each `hidepid` spelling in turn, then a recursive bind of the host's
2546                // `/proc` — see `PROC_HIDEPID_OPTIONS` for the bind's rationale and exposure.
2547                for option in PROC_HIDEPID_OPTIONS {
2548                    let mut buffer = [0u8; 32];
2549                    let bytes = option.as_bytes();
2550                    if bytes.len() >= buffer.len() {
2551                        continue;
2552                    }
2553                    buffer[..bytes.len()].copy_from_slice(bytes);
2554                    let data = if bytes.is_empty() {
2555                        std::ptr::null()
2556                    } else {
2557                        buffer.as_ptr() as *const libc::c_void
2558                    };
2559                    if libc::mount(
2560                        c"proc".as_ptr(),
2561                        target.as_ptr(),
2562                        c"proc".as_ptr(),
2563                        libc::MS_NOSUID | libc::MS_NODEV | libc::MS_NOEXEC,
2564                        data,
2565                    ) == 0
2566                    {
2567                        return Ok(());
2568                    }
2569                }
2570
2571                // Last resort: bind the host's `/proc` in. `mount -t proc` needs `CAP_SYS_ADMIN`
2572                // over the *PID* namespace's user namespace, which an unprivileged user namespace
2573                // never has, so on a bare host every spelling above fails with `EPERM` and this is
2574                // the branch that actually runs. A bind of an existing mount needs no such
2575                // privilege. Recursive so the `/proc` submounts a modern host carries come with it
2576                // rather than leaving empty stubs.
2577                if libc::mount(
2578                    c"/proc".as_ptr(),
2579                    target.as_ptr(),
2580                    std::ptr::null(),
2581                    libc::MS_BIND | libc::MS_REC,
2582                    std::ptr::null(),
2583                ) != 0
2584                {
2585                    return Err(());
2586                }
2587                // Best-effort hardening of the bind. `MS_REMOUNT | MS_BIND` changes per-mount
2588                // flags only — unlike `hidepid`, which is a superblock option and stays refused —
2589                // so this narrows what the bind can do without touching the host's own `/proc`.
2590                // Not fatal: a `/proc` that is present and merely unhardened beats no `/proc`.
2591                libc::mount(
2592                    std::ptr::null(),
2593                    target.as_ptr(),
2594                    std::ptr::null(),
2595                    libc::MS_REMOUNT
2596                        | libc::MS_BIND
2597                        | libc::MS_NOSUID
2598                        | libc::MS_NODEV
2599                        | libc::MS_NOEXEC,
2600                    std::ptr::null(),
2601                );
2602                return Ok(());
2603            }
2604            CStepKind::DevPts { target } => {
2605                if libc::mount(
2606                    c"devpts".as_ptr(),
2607                    target.as_ptr(),
2608                    c"devpts".as_ptr(),
2609                    libc::MS_NOSUID | libc::MS_NOEXEC,
2610                    c"newinstance,ptmxmode=0666,mode=0620".as_ptr() as *const libc::c_void,
2611                ) != 0
2612                {
2613                    return Err(());
2614                }
2615            }
2616            CStepKind::RemountReadOnly(target) => {
2617                if libc::mount(
2618                    std::ptr::null(),
2619                    target.as_ptr(),
2620                    std::ptr::null(),
2621                    libc::MS_REMOUNT | libc::MS_BIND | libc::MS_RDONLY | libc::MS_NOSUID,
2622                    std::ptr::null(),
2623                ) != 0
2624                {
2625                    return Err(());
2626                }
2627            }
2628        }
2629        Ok(())
2630    }
2631
2632    /// `open`/`write`/`close` over a static path, with no allocation — the `/proc/self/*_map`
2633    /// writes are the only file I/O the composed-root construction performs.
2634    ///
2635    /// # Safety
2636    /// Must only be called from the post-`fork()`, pre-exec window, for the same reason as
2637    /// [`execute_step`]: `path` and `data` are borrowed for the duration of the call only, and the
2638    /// raw `open`/`write`/`close` sequence must stay allocation- and lock-free in that window.
2639    unsafe fn write_file(path: &std::ffi::CStr, data: &[u8]) -> Result<(), ()> {
2640        let fd = libc::open(path.as_ptr(), libc::O_WRONLY | libc::O_CLOEXEC);
2641        if fd < 0 {
2642            return Err(());
2643        }
2644        let mut written = 0usize;
2645        while written < data.len() {
2646            let n = libc::write(
2647                fd,
2648                data[written..].as_ptr() as *const libc::c_void,
2649                data.len() - written,
2650            );
2651            if n < 0 {
2652                if *libc::__errno_location() == libc::EINTR {
2653                    continue;
2654                }
2655                libc::close(fd);
2656                return Err(());
2657            }
2658            if n == 0 {
2659                break;
2660            }
2661            written += n as usize;
2662        }
2663        libc::close(fd);
2664        if written == data.len() {
2665            Ok(())
2666        } else {
2667            Err(())
2668        }
2669    }
2670}
2671
2672#[cfg(test)]
2673mod tests {
2674    use super::*;
2675    use std::collections::HashMap;
2676
2677    struct FakeHost(HashMap<PathBuf, PathKind>);
2678
2679    impl FakeHost {
2680        fn usrmerge() -> Self {
2681            let mut map = HashMap::new();
2682            map.insert(PathBuf::from("/usr"), PathKind::Dir);
2683            map.insert(
2684                PathBuf::from("/bin"),
2685                PathKind::Symlink(PathBuf::from("usr/bin")),
2686            );
2687            map.insert(
2688                PathBuf::from("/sbin"),
2689                PathKind::Symlink(PathBuf::from("usr/sbin")),
2690            );
2691            map.insert(
2692                PathBuf::from("/lib"),
2693                PathKind::Symlink(PathBuf::from("usr/lib")),
2694            );
2695            map.insert(
2696                PathBuf::from("/lib64"),
2697                PathKind::Symlink(PathBuf::from("usr/lib64")),
2698            );
2699            map.insert(PathBuf::from("/etc/ld.so.cache"), PathKind::File);
2700            map.insert(PathBuf::from("/etc/ssl"), PathKind::Dir);
2701            // Present on the fake host precisely so the synthesis tests below can show that having
2702            // them is not what decides whether they are mirrored.
2703            map.insert(PathBuf::from("/etc/passwd"), PathKind::File);
2704            map.insert(PathBuf::from("/etc/group"), PathKind::File);
2705            for device in SEALED_DEVICE_NODES {
2706                map.insert(PathBuf::from(device.path), PathKind::File);
2707            }
2708            Self(map)
2709        }
2710    }
2711
2712    impl HostLayout for FakeHost {
2713        fn kind(&self, path: &Path) -> Option<PathKind> {
2714            self.0.get(path).cloned()
2715        }
2716    }
2717
2718    /// The session's temporary folder in its records directory, and the identity of the
2719    /// descriptor its records root holds on it.
2720    const TEST_TMP_SOURCE: &str = "/home/u/.murmur/records/home/u/w/ses_plan_tests/tmp";
2721    const TEST_TMP_IDENTITY: (u64, u64) = (64, 7);
2722
2723    /// The session's `sealed-etc/`, beside its runtime directory.
2724    const TEST_ETC_DIR: &str = "/home/u/.murmur/sessions/ses_plan_tests/sealed-etc";
2725
2726    /// The runtime directory and the identity of its root's descriptor.
2727    const TEST_RUNTIME_DIR: &str = "/home/u/.murmur/sessions/ses_plan_tests/runtime";
2728    const TEST_RUNTIME_IDENTITY: (u64, u64) = (64, 8);
2729
2730    /// The runtime-provided bind sources, with no runtime directory: the planner's steps 1–6.
2731    fn test_sources() -> SealedBindSources<'static> {
2732        SealedBindSources {
2733            tmp_store: (Path::new(TEST_TMP_SOURCE), TEST_TMP_IDENTITY),
2734            etc_dir: Path::new(TEST_ETC_DIR),
2735            runtime_dir: None,
2736            records_dir: None,
2737            home_dir: None,
2738            state_dir: None,
2739            host_paths: &[],
2740            host_path_read_only: &[],
2741        }
2742    }
2743
2744    fn plan_for(workdir: &str) -> ComposedRootPlan {
2745        plan_composed_root(
2746            Path::new(workdir),
2747            &test_sources(),
2748            Path::new("/tmp"),
2749            &[],
2750            &[],
2751            &FakeHost::usrmerge(),
2752        )
2753    }
2754
2755    fn ops(plan: &ComposedRootPlan) -> Vec<RootOp> {
2756        plan.steps.iter().map(|step| step.op.clone()).collect()
2757    }
2758
2759    #[test]
2760    fn base_selection_skips_any_ancestor_of_the_workdir() {
2761        // The default case: a workdir under the user's home, so `/tmp` is free.
2762        assert_eq!(
2763            choose_root_base(
2764                Path::new("/home/u/w"),
2765                &[],
2766                SEALED_ROOT_BASE_CANDIDATES,
2767                |_| { true }
2768            ),
2769            Some(PathBuf::from("/tmp"))
2770        );
2771        // A workdir under /tmp — overmounting /tmp would hide the workdir before it can be bound.
2772        assert_eq!(
2773            choose_root_base(
2774                Path::new("/tmp/session/w"),
2775                &[],
2776                SEALED_ROOT_BASE_CANDIDATES,
2777                |_| true
2778            ),
2779            Some(PathBuf::from("/run"))
2780        );
2781        // A host missing the first two candidates falls through to the third.
2782        assert_eq!(
2783            choose_root_base(
2784                Path::new("/home/u/w"),
2785                &[],
2786                SEALED_ROOT_BASE_CANDIDATES,
2787                |path| { path != Path::new("/tmp") && path != Path::new("/run") }
2788            ),
2789            Some(PathBuf::from("/var/tmp"))
2790        );
2791        assert_eq!(
2792            choose_root_base(
2793                Path::new("/home/u/w"),
2794                &[],
2795                SEALED_ROOT_BASE_CANDIDATES,
2796                |_| { false }
2797            ),
2798            None
2799        );
2800    }
2801
2802    #[test]
2803    fn base_selection_skips_any_ancestor_of_a_host_path() {
2804        assert_eq!(
2805            choose_root_base(
2806                Path::new("/home/u/w"),
2807                &[PathBuf::from("/run/media/data")],
2808                &["/run", "/var/tmp"],
2809                |_| true
2810            ),
2811            Some(PathBuf::from("/var/tmp"))
2812        );
2813        assert_eq!(
2814            choose_root_base(
2815                Path::new("/home/u/w"),
2816                &[PathBuf::from("/var/tmp")],
2817                &["/var/tmp", "/mnt"],
2818                |_| true
2819            ),
2820            Some(PathBuf::from("/mnt"))
2821        );
2822        // No host path, no restriction.
2823        assert_eq!(
2824            choose_root_base(Path::new("/home/u/w"), &[], &["/run"], |_| true),
2825            Some(PathBuf::from("/run"))
2826        );
2827    }
2828
2829    fn host_bind(path: &str, read_only: bool, ino: u64) -> HostPathBind {
2830        HostPathBind {
2831            path: PathBuf::from(path),
2832            identity: (70, ino),
2833            read_only,
2834        }
2835    }
2836
2837    fn host_path_plan(
2838        host_paths: &[HostPathBind],
2839        read_only: &[PathBuf],
2840        extra_read_only: &[PathBuf],
2841    ) -> ComposedRootPlan {
2842        let mut host = FakeHost::usrmerge();
2843        for path in extra_read_only {
2844            host.0.insert(path.clone(), PathKind::Dir);
2845        }
2846        plan_composed_root(
2847            Path::new("/home/u/w"),
2848            &SealedBindSources {
2849                runtime_dir: Some((Path::new(TEST_RUNTIME_DIR), TEST_RUNTIME_IDENTITY)),
2850                home_dir: Some((Path::new("/home/u/.murmur/records/x/home"), (64, 9))),
2851                state_dir: Some((Path::new("/home/u/.murmur/state/s"), (64, 10))),
2852                host_paths,
2853                host_path_read_only: read_only,
2854                ..test_sources()
2855            },
2856            Path::new("/tmp"),
2857            extra_read_only,
2858            &[PathBuf::from("/opt/staged")],
2859            &host,
2860        )
2861    }
2862
2863    fn position(plan: &ComposedRootPlan, matches: impl Fn(&RootStep) -> bool) -> usize {
2864        plan.steps
2865            .iter()
2866            .position(matches)
2867            .expect("the plan holds the step")
2868    }
2869
2870    #[test]
2871    fn host_paths_are_planned_first_shallowest_first_verified_and_required() {
2872        let host_paths = [
2873            host_bind("/home/u/data/inner", false, 2),
2874            host_bind("/home/u/data", true, 1),
2875        ];
2876        let plan = host_path_plan(&host_paths, &[], &[]);
2877        let outer = position(&plan, |step| {
2878            step.op
2879                == RootOp::BindVerified {
2880                    source: PathBuf::from("/home/u/data"),
2881                    target: PathBuf::from("/tmp/home/u/data"),
2882                    read_only: true,
2883                    identity: (70, 1),
2884                }
2885                && step.required
2886        });
2887        let inner = position(&plan, |step| {
2888            step.op
2889                == RootOp::BindVerified {
2890                    source: PathBuf::from("/home/u/data/inner"),
2891                    target: PathBuf::from("/tmp/home/u/data/inner"),
2892                    read_only: false,
2893                    identity: (70, 2),
2894                }
2895                && step.required
2896        });
2897        assert!(outer < inner, "the shallower entry is bound first");
2898        // Before every other step that binds anything: staged runtime, runtime tree, /tmp, the
2899        // workdir, runtime directory, records, home and store.
2900        let first_other_bind = position(&plan, |step| {
2901            matches!(
2902                &step.op,
2903                RootOp::Bind { .. }
2904                    | RootOp::BindVerified { .. }
2905                    | RootOp::Symlink { .. }
2906                    | RootOp::Tmpfs { .. }
2907            ) && !matches!(&step.op, RootOp::BindVerified { source, .. } if source.starts_with("/home/u/data"))
2908        });
2909        assert!(inner < first_other_bind);
2910        for source in [
2911            "/opt/staged",
2912            "/home/u/w",
2913            TEST_RUNTIME_DIR,
2914            "/home/u/.murmur/records/x/home",
2915            "/home/u/.murmur/state/s",
2916        ] {
2917            let at = position(&plan, |step| match &step.op {
2918                RootOp::Bind { source: s, .. } | RootOp::BindVerified { source: s, .. } => {
2919                    s == Path::new(source)
2920                }
2921                _ => false,
2922            });
2923            assert!(inner < at, "{source} is bound after every host path");
2924        }
2925    }
2926
2927    #[test]
2928    fn an_execute_root_inside_a_read_write_host_path_is_bound_read_only_and_required_on_top() {
2929        let host_paths = [host_bind("/home/u/tools", false, 1)];
2930        let plan = host_path_plan(
2931            &host_paths,
2932            &[PathBuf::from("/home/u/tools/bin")],
2933            &[PathBuf::from("/home/u/tools/bin")],
2934        );
2935        let host = position(
2936            &plan,
2937            |step| matches!(&step.op, RootOp::BindVerified { source, read_only: false, .. } if source == Path::new("/home/u/tools")),
2938        );
2939        let protect = position(&plan, |step| {
2940            step.op
2941                == RootOp::Bind {
2942                    source: PathBuf::from("/home/u/tools/bin"),
2943                    target: PathBuf::from("/tmp/home/u/tools/bin"),
2944                    read_only: true,
2945                }
2946                && step.required
2947        });
2948        assert!(host < protect);
2949        // The optional mirror of the same directory adds nothing: the required bind owns it.
2950        let binds_of_bin = plan
2951            .steps
2952            .iter()
2953            .filter(|step| matches!(&step.op, RootOp::Bind { source, .. } if source == Path::new("/home/u/tools/bin")))
2954            .count();
2955        assert_eq!(binds_of_bin, 1);
2956    }
2957
2958    #[test]
2959    fn a_host_path_inside_a_later_bound_directory_is_bound_again_on_top() {
2960        let host_paths = [host_bind("/home/u/py/site/data", true, 1)];
2961        let plan = host_path_plan(&host_paths, &[], &[PathBuf::from("/home/u/py")]);
2962        let cover = position(
2963            &plan,
2964            |step| matches!(&step.op, RootOp::Bind { source, .. } if source == Path::new("/home/u/py")),
2965        );
2966        let binds: Vec<usize> = plan
2967            .steps
2968            .iter()
2969            .enumerate()
2970            .filter(|(_, step)| matches!(&step.op, RootOp::BindVerified { source, .. } if source == Path::new("/home/u/py/site/data")))
2971            .map(|(at, _)| at)
2972            .collect();
2973        assert_eq!(binds.len(), 2, "{:#?}", plan.steps);
2974        assert!(binds[0] < cover && cover < binds[1]);
2975    }
2976
2977    #[test]
2978    fn no_host_paths_add_no_step() {
2979        let without = host_path_plan(&[], &[], &[]);
2980        let baseline = plan_composed_root(
2981            Path::new("/home/u/w"),
2982            &SealedBindSources {
2983                runtime_dir: Some((Path::new(TEST_RUNTIME_DIR), TEST_RUNTIME_IDENTITY)),
2984                home_dir: Some((Path::new("/home/u/.murmur/records/x/home"), (64, 9))),
2985                state_dir: Some((Path::new("/home/u/.murmur/state/s"), (64, 10))),
2986                ..test_sources()
2987            },
2988            Path::new("/tmp"),
2989            &[],
2990            &[PathBuf::from("/opt/staged")],
2991            &FakeHost::usrmerge(),
2992        );
2993        assert_eq!(without, baseline);
2994    }
2995
2996    #[test]
2997    fn usrmerge_symlinks_are_reproduced_rather_than_bound_through() {
2998        let plan = plan_for("/home/u/w");
2999        assert!(ops(&plan).contains(&RootOp::Symlink {
3000            target: PathBuf::from("usr/bin"),
3001            link: PathBuf::from("/tmp/bin"),
3002        }));
3003        assert!(ops(&plan).contains(&RootOp::Bind {
3004            source: PathBuf::from("/usr"),
3005            target: PathBuf::from("/tmp/usr"),
3006            read_only: true,
3007        }));
3008        // `/lib32` and `/libx32` are absent from this fake host, so they contribute nothing.
3009        assert!(!ops(&plan)
3010            .iter()
3011            .any(|op| matches!(op, RootOp::Bind { source, .. } if source == Path::new("/lib32"))));
3012    }
3013
3014    #[test]
3015    fn every_runtime_and_etc_bind_is_read_only_and_only_the_workdir_is_not() {
3016        let plan = plan_for("/home/u/w");
3017        let writable: Vec<&PathBuf> = plan
3018            .steps
3019            .iter()
3020            .filter_map(|step| match &step.op {
3021                RootOp::Bind {
3022                    source,
3023                    read_only: false,
3024                    ..
3025                } => Some(source),
3026                _ => None,
3027            })
3028            .collect();
3029
3030        // The workdir and the /dev nodes (whose write side is the device driver's, not the
3031        // filesystem's) — and nothing else. /tmp is a verified bind, not among these.
3032        for source in &writable {
3033            assert!(
3034                source.starts_with("/home/u/w") || source.starts_with("/dev/"),
3035                "unexpected writable bind of {}",
3036                source.display()
3037            );
3038        }
3039        assert!(writable.contains(&&PathBuf::from("/home/u/w")));
3040    }
3041
3042    #[test]
3043    fn etc_is_an_allowlist_so_shadow_is_absent_by_construction() {
3044        let plan = plan_for("/home/u/w");
3045        let bound: Vec<String> = plan
3046            .steps
3047            .iter()
3048            .filter_map(|step| match &step.op {
3049                RootOp::Bind { source, .. } => Some(source.display().to_string()),
3050                _ => None,
3051            })
3052            .collect();
3053
3054        assert!(bound.iter().any(|path| path == "/etc/ld.so.cache"));
3055        assert!(!bound.iter().any(|path| path == "/etc"));
3056        assert!(!bound.iter().any(|path| path.starts_with("/etc/shadow")));
3057        assert!(!bound.iter().any(|path| path.starts_with("/root")));
3058        assert!(!bound.iter().any(|path| path.starts_with("/home/u/.ssh")));
3059    }
3060
3061    /// The host has both files (see `FakeHost::usrmerge`) and the plan mirrors neither: their
3062    /// targets are bound from the files the parent writes into `sealed-etc/` instead.
3063    #[test]
3064    fn passwd_and_group_are_bound_from_the_staging_files_never_from_the_host() {
3065        let plan = plan_for("/home/u/w");
3066
3067        for host_path in ["/etc/passwd", "/etc/group"] {
3068            assert!(
3069                !plan.steps.iter().any(|step| matches!(
3070                    &step.op,
3071                    RootOp::Bind { source, .. } if source == Path::new(host_path)
3072                )),
3073                "the host's {host_path} carries every account on the machine and must not be \
3074                 bound into a capsule",
3075            );
3076        }
3077
3078        for (file, target) in [
3079            (SyntheticEtcFile::Passwd, "/tmp/etc/passwd"),
3080            (SyntheticEtcFile::Group, "/tmp/etc/group"),
3081        ] {
3082            let expected = RootStep {
3083                op: RootOp::Bind {
3084                    source: synthetic_etc_source(Path::new(TEST_ETC_DIR), file),
3085                    target: PathBuf::from(target),
3086                    read_only: true,
3087                },
3088                // Not the host-mirrored entries' `required: false`: the parent wrote this file, so
3089                // a failed bind is a broken runtime, not a distribution that lacks the path.
3090                required: true,
3091            };
3092            assert!(
3093                plan.steps.contains(&expected),
3094                "expected {expected:?} in {:?}",
3095                plan.steps,
3096            );
3097            // And the mountpoint the bind lands on, since a bind needs an existing target.
3098            assert!(ops(&plan).contains(&RootOp::MkFile(PathBuf::from(target))));
3099        }
3100
3101        // Every other /etc entry is still the host's own file, untouched by this.
3102        assert!(ops(&plan).contains(&RootOp::Bind {
3103            source: PathBuf::from("/etc/ld.so.cache"),
3104            target: PathBuf::from("/tmp/etc/ld.so.cache"),
3105            read_only: true,
3106        }));
3107    }
3108
3109    /// Two lines, and the `pw_dir` field is the synthetic `HOME` verbatim — the property a capsule
3110    /// observes as `pwd.getpwuid(os.getuid()).pw_dir == os.environ["HOME"]`.
3111    #[test]
3112    fn the_synthetic_databases_name_root_and_the_capsules_own_id_and_nothing_else() {
3113        let identity = SealedAccountIdentity {
3114            uid: 1000,
3115            gid: 1000,
3116            home: "/r/ses/home",
3117        };
3118
3119        let passwd = SyntheticEtcFile::Passwd.render(&identity).unwrap();
3120        assert_eq!(
3121            passwd,
3122            "root:x:0:0:root:/root:/bin/sh\n\
3123             capsule:x:1000:1000:Murmur capsule:/r/ses/home:/bin/sh\n"
3124        );
3125        let group = SyntheticEtcFile::Group.render(&identity).unwrap();
3126        assert_eq!(group, "root:x:0:\ncapsule:x:1000:\n");
3127
3128        for (file, contents) in [
3129            (SyntheticEtcFile::Passwd, &passwd),
3130            (SyntheticEtcFile::Group, &group),
3131        ] {
3132            assert_eq!(
3133                contents.lines().count(),
3134                2,
3135                "{file:?} must stay a two-line database"
3136            );
3137            assert!(
3138                contents.ends_with('\n'),
3139                "{file:?} must end its last record with a newline"
3140            );
3141        }
3142    }
3143
3144    /// A capsule already running as uid 0 gets *one* passwd line, not a second `root` — two entries
3145    /// for uid 0 would leave `getpwuid(0)` resolving to whichever the parser saw first.
3146    #[test]
3147    fn a_root_capsule_gets_a_single_passwd_line_carrying_its_own_home() {
3148        let identity = SealedAccountIdentity {
3149            uid: 0,
3150            gid: 0,
3151            home: "/r/ses/home",
3152        };
3153
3154        assert_eq!(
3155            SyntheticEtcFile::Passwd.render(&identity).unwrap(),
3156            "root:x:0:0:root:/r/ses/home:/bin/sh\n"
3157        );
3158        assert_eq!(
3159            SyntheticEtcFile::Group.render(&identity).unwrap(),
3160            "root:x:0:\n"
3161        );
3162    }
3163
3164    /// `passwd(5)` has no escaping, so a home path containing the field separator would silently
3165    /// shift every field after it — a `pw_shell` of half a path, or worse.
3166    #[test]
3167    fn a_home_path_that_cannot_be_spelled_in_a_passwd_field_is_refused() {
3168        for home in ["/w/od:d", "/w/two\nlines"] {
3169            let identity = SealedAccountIdentity {
3170                uid: 1000,
3171                gid: 1000,
3172                home,
3173            };
3174            let error = SyntheticEtcFile::Passwd.render(&identity).unwrap_err();
3175            assert!(
3176                error.contains(home),
3177                "the message must name the offending path: {error}"
3178            );
3179        }
3180    }
3181
3182    /// Step 7: the runtime directory, read-only and required, at its own absolute path, from the
3183    /// descriptor the parent took, after the workdir's own bind.
3184    #[test]
3185    fn the_runtime_directory_is_bound_read_only_and_required_after_the_workdir() {
3186        let plan = plan_composed_root(
3187            Path::new("/home/u/w"),
3188            &SealedBindSources {
3189                runtime_dir: Some((Path::new(TEST_RUNTIME_DIR), TEST_RUNTIME_IDENTITY)),
3190                ..test_sources()
3191            },
3192            Path::new("/tmp"),
3193            &[],
3194            &[],
3195            &FakeHost::usrmerge(),
3196        );
3197        let bind = RootOp::BindVerified {
3198            source: PathBuf::from(TEST_RUNTIME_DIR),
3199            target: PathBuf::from("/tmp").join(TEST_RUNTIME_DIR.trim_start_matches('/')),
3200            read_only: true,
3201            identity: TEST_RUNTIME_IDENTITY,
3202        };
3203        let at = plan
3204            .steps
3205            .iter()
3206            .position(|step| step.op == bind)
3207            .expect("the runtime directory is bound");
3208        assert!(
3209            plan.steps[at].required,
3210            "a missing runtime directory ends the session"
3211        );
3212        let workdir = plan
3213            .steps
3214            .iter()
3215            .position(|step| {
3216                matches!(&step.op, RootOp::Bind { source, .. } if source == Path::new("/home/u/w"))
3217            })
3218            .unwrap();
3219        assert!(
3220            workdir < at,
3221            "the runtime directory is planned after the workdir"
3222        );
3223        assert_eq!(at, plan.steps.len() - 1);
3224        assert!(plan_for("/home/u/w").steps.iter().all(|step| !matches!(
3225            &step.op,
3226            RootOp::BindVerified { source, .. } if source == Path::new(TEST_RUNTIME_DIR)
3227        )));
3228    }
3229
3230    /// The records directory is bound read-only and required at its own path, after the runtime
3231    /// directory, so the deeper bind survives a records directory beneath either.
3232    #[test]
3233    fn the_records_directory_is_bound_read_only_and_required_after_the_runtime_directory() {
3234        const TEST_RECORDS_DIR: &str = "/home/u/.murmur/records/home/u/w/ses_x";
3235        const TEST_RECORDS_IDENTITY: (u64, u64) = (7, 77);
3236        let plan = plan_composed_root(
3237            Path::new("/home/u/w"),
3238            &SealedBindSources {
3239                runtime_dir: Some((Path::new(TEST_RUNTIME_DIR), TEST_RUNTIME_IDENTITY)),
3240                records_dir: Some((Path::new(TEST_RECORDS_DIR), TEST_RECORDS_IDENTITY)),
3241                ..test_sources()
3242            },
3243            Path::new("/tmp"),
3244            &[],
3245            &[],
3246            &FakeHost::usrmerge(),
3247        );
3248        let position = |source: &str| {
3249            plan.steps
3250                .iter()
3251                .position(|step| {
3252                    matches!(&step.op, RootOp::BindVerified { source: s, read_only: true, .. } if s == Path::new(source))
3253                })
3254                .unwrap_or_else(|| panic!("{source} is bound read-only"))
3255        };
3256        let records = position(TEST_RECORDS_DIR);
3257        assert!(
3258            plan.steps[records].required,
3259            "a missing records directory ends the session"
3260        );
3261        assert!(position(TEST_RUNTIME_DIR) < records);
3262        assert_eq!(
3263            plan.steps[records].op,
3264            RootOp::BindVerified {
3265                source: PathBuf::from(TEST_RECORDS_DIR),
3266                target: PathBuf::from("/tmp").join(TEST_RECORDS_DIR.trim_start_matches('/')),
3267                read_only: true,
3268                identity: TEST_RECORDS_IDENTITY,
3269            }
3270        );
3271    }
3272
3273    /// The home folder is bound read-write, verified and required at its own path, after the
3274    /// records directory's read-only bind it lies beneath, so the deeper, writable bind survives.
3275    #[test]
3276    fn home_folder_is_bound_read_write_and_verified_after_the_records_directory() {
3277        const TEST_RECORDS_DIR: &str = "/home/u/.murmur/records/home/u/w/ses_x";
3278        const TEST_RECORDS_IDENTITY: (u64, u64) = (7, 77);
3279        const TEST_HOME_DIR: &str = "/home/u/.murmur/records/home/u/w/ses_x/home";
3280        const TEST_HOME_IDENTITY: (u64, u64) = (7, 78);
3281        let plan = plan_composed_root(
3282            Path::new("/home/u/w"),
3283            &SealedBindSources {
3284                runtime_dir: Some((Path::new(TEST_RUNTIME_DIR), TEST_RUNTIME_IDENTITY)),
3285                records_dir: Some((Path::new(TEST_RECORDS_DIR), TEST_RECORDS_IDENTITY)),
3286                home_dir: Some((Path::new(TEST_HOME_DIR), TEST_HOME_IDENTITY)),
3287                ..test_sources()
3288            },
3289            Path::new("/tmp"),
3290            &[],
3291            &[],
3292            &FakeHost::usrmerge(),
3293        );
3294        let position = |source: &str| {
3295            plan.steps
3296                .iter()
3297                .position(|step| {
3298                    matches!(&step.op, RootOp::BindVerified { source: s, .. } if s == Path::new(source))
3299                })
3300                .unwrap_or_else(|| panic!("{source} is bound"))
3301        };
3302        let records = position(TEST_RECORDS_DIR);
3303        let home = position(TEST_HOME_DIR);
3304        assert!(records < home, "the home bind comes after the records bind");
3305        assert!(
3306            plan.steps[home].required,
3307            "a missing home folder ends the session"
3308        );
3309        assert_eq!(
3310            plan.steps[home].op,
3311            RootOp::BindVerified {
3312                source: PathBuf::from(TEST_HOME_DIR),
3313                target: PathBuf::from("/tmp").join(TEST_HOME_DIR.trim_start_matches('/')),
3314                read_only: false,
3315                identity: TEST_HOME_IDENTITY,
3316            }
3317        );
3318        assert!(matches!(
3319            plan.steps[records].op,
3320            RootOp::BindVerified {
3321                read_only: true,
3322                ..
3323            }
3324        ));
3325    }
3326
3327    /// A native tool's store is bound read-write, verified and required at its own absolute
3328    /// path, after the home folder; a spawn with no store binds nothing there.
3329    #[test]
3330    fn a_native_tools_store_is_bound_read_write_verified_and_required_at_its_own_path() {
3331        const TEST_HOME_DIR: &str = "/home/u/.murmur/records/home/u/w/ses_x/home";
3332        const TEST_STATE_DIR: &str = "/home/u/.murmur/state/config-capsule";
3333        const TEST_STATE_IDENTITY: (u64, u64) = (7, 79);
3334        let sources = SealedBindSources {
3335            runtime_dir: Some((Path::new(TEST_RUNTIME_DIR), TEST_RUNTIME_IDENTITY)),
3336            home_dir: Some((Path::new(TEST_HOME_DIR), (7, 78))),
3337            ..test_sources()
3338        };
3339        let plan = |sources: &SealedBindSources<'_>| {
3340            plan_composed_root(
3341                Path::new("/home/u/w"),
3342                sources,
3343                Path::new("/tmp"),
3344                &[],
3345                &[],
3346                &FakeHost::usrmerge(),
3347            )
3348        };
3349        let with_store = plan(&SealedBindSources {
3350            state_dir: Some((Path::new(TEST_STATE_DIR), TEST_STATE_IDENTITY)),
3351            ..sources
3352        });
3353        let position = |plan: &ComposedRootPlan, source: &str| {
3354            plan.steps.iter().position(|step| {
3355                matches!(&step.op, RootOp::BindVerified { source: s, .. } if s == Path::new(source))
3356            })
3357        };
3358        let home = position(&with_store, TEST_HOME_DIR).expect("the home folder is bound");
3359        let store = position(&with_store, TEST_STATE_DIR).expect("the store is bound");
3360        assert!(home < store, "the store bind comes after the home bind");
3361        assert!(
3362            with_store.steps[store].required,
3363            "a store that cannot be bound ends the session"
3364        );
3365        let target = PathBuf::from("/tmp").join(TEST_STATE_DIR.trim_start_matches('/'));
3366        assert_eq!(
3367            with_store.steps[store].op,
3368            RootOp::BindVerified {
3369                source: PathBuf::from(TEST_STATE_DIR),
3370                target: target.clone(),
3371                read_only: false,
3372                identity: TEST_STATE_IDENTITY,
3373            }
3374        );
3375        assert!(
3376            with_store.steps[..store]
3377                .iter()
3378                .any(|step| step.op == RootOp::MkDir(target.clone())),
3379            "the store's mount point is made before its bind"
3380        );
3381        assert_eq!(position(&plan(&sources), TEST_STATE_DIR), None);
3382    }
3383
3384    /// A delegating session's root binds nothing beneath the directory its delegated children keep
3385    /// their records in, `records_group(<records dir>/children)`: the parent's runtime reads a
3386    /// child's answer and hands it over, so no command of the parent's reaches a child's records.
3387    #[test]
3388    fn nothing_is_bound_beneath_the_childrens_records() {
3389        const TEST_RECORDS_DIR: &str = "/home/u/.murmur/records/home/u/w/ses_x";
3390        const TEST_HOME_DIR: &str = "/home/u/.murmur/records/home/u/w/ses_x/home";
3391        const TEST_CHILDREN_RECORDS: &str =
3392            "/home/u/.murmur/records/home/u/.murmur/records/home/u/w/ses_x/children";
3393        let plan = plan_composed_root(
3394            Path::new("/home/u/w"),
3395            &SealedBindSources {
3396                runtime_dir: Some((Path::new(TEST_RUNTIME_DIR), TEST_RUNTIME_IDENTITY)),
3397                records_dir: Some((Path::new(TEST_RECORDS_DIR), (7, 77))),
3398                home_dir: Some((Path::new(TEST_HOME_DIR), (7, 78))),
3399                ..test_sources()
3400            },
3401            Path::new("/tmp"),
3402            &[],
3403            &[],
3404            &FakeHost::usrmerge(),
3405        );
3406        let bound: Vec<&Path> = plan
3407            .steps
3408            .iter()
3409            .filter_map(|step| match &step.op {
3410                RootOp::BindVerified { source, .. } | RootOp::Bind { source, .. } => {
3411                    Some(source.as_path())
3412                }
3413                _ => None,
3414            })
3415            .collect();
3416        assert!(
3417            bound.contains(&Path::new(TEST_RECORDS_DIR)),
3418            "the plan binds the session's own records: {bound:?}"
3419        );
3420        for source in &bound {
3421            assert!(
3422                !source.starts_with(TEST_CHILDREN_RECORDS)
3423                    && !Path::new(TEST_CHILDREN_RECORDS).starts_with(source),
3424                "{} is bound, which reaches the children's records",
3425                source.display()
3426            );
3427        }
3428    }
3429
3430    /// The remount keeps every flag a user namespace locks on the source mount.
3431    #[test]
3432    #[cfg(target_os = "linux")]
3433    fn a_read_only_remount_keeps_the_source_mounts_locked_flags() {
3434        let kept = linux::remount_flags_kept(
3435            libc::ST_NODEV | libc::ST_NOEXEC | libc::ST_NOSUID | libc::ST_RELATIME,
3436        );
3437        for flag in [
3438            libc::MS_NODEV,
3439            libc::MS_NOEXEC,
3440            libc::MS_NOSUID,
3441            libc::MS_RELATIME,
3442        ] {
3443            assert_ne!(kept & flag, 0, "{flag:#x}");
3444        }
3445        assert_eq!(kept & libc::MS_STRICTATIME, 0);
3446        let strict = linux::remount_flags_kept(0);
3447        assert_ne!(strict & libc::MS_STRICTATIME, 0);
3448        assert_eq!(strict & (libc::MS_NODEV | libc::MS_NOEXEC), 0);
3449    }
3450
3451    #[test]
3452    fn the_workdir_keeps_its_absolute_path_inside_the_root() {
3453        let plan = plan_for("/home/u/.murmur/sessions/abc");
3454        assert_eq!(
3455            plan.workdir_in_root,
3456            PathBuf::from("/home/u/.murmur/sessions/abc")
3457        );
3458        assert!(ops(&plan).contains(&RootOp::Bind {
3459            source: PathBuf::from("/home/u/.murmur/sessions/abc"),
3460            target: PathBuf::from("/tmp/home/u/.murmur/sessions/abc"),
3461            read_only: false,
3462        }));
3463        // Every parent component is created before the bind, exactly once.
3464        let mkdirs: Vec<&RootOp> = plan
3465            .steps
3466            .iter()
3467            .map(|step| &step.op)
3468            .filter(|op| matches!(op, RootOp::MkDir(path) if path.starts_with("/tmp/home")))
3469            .collect();
3470        assert_eq!(mkdirs.len(), 5, "got {mkdirs:?}");
3471    }
3472
3473    #[test]
3474    fn tmp_is_bound_only_when_it_is_the_directory_the_parent_holds() {
3475        let plan = plan_for("/home/u/w");
3476        assert!(ops(&plan).contains(&RootOp::BindVerified {
3477            source: PathBuf::from(TEST_TMP_SOURCE),
3478            target: PathBuf::from("/tmp/tmp"),
3479            read_only: false,
3480            identity: TEST_TMP_IDENTITY,
3481        }));
3482        // Never an unverified bind of a path a capsule's command could have replaced with a link.
3483        assert!(!ops(&plan).iter().any(|op| matches!(
3484            op,
3485            RootOp::Bind { source, .. } if source == Path::new(TEST_TMP_SOURCE)
3486        )));
3487    }
3488
3489    /// The child's check: the directory reached is the one whose identity the parent recorded,
3490    /// or nothing is bound. A link swapped in after the parent's descriptor was taken reaches
3491    /// another directory, and is refused with `ESTALE`.
3492    #[test]
3493    #[cfg(target_os = "linux")]
3494    #[allow(unsafe_code)]
3495    fn a_verified_bind_source_refuses_a_directory_swapped_in_after_the_descriptor() {
3496        use std::os::unix::fs::MetadataExt;
3497
3498        let dir = tempfile::tempdir().unwrap();
3499        let store = dir.path().join("tmp");
3500        let outside = dir.path().join("outside");
3501        std::fs::create_dir_all(&store).unwrap();
3502        std::fs::create_dir_all(&outside).unwrap();
3503        let meta = std::fs::metadata(&store).unwrap();
3504        let identity = (meta.dev(), meta.ino());
3505        let source = std::ffi::CString::new(store.to_str().unwrap()).unwrap();
3506
3507        // SAFETY: open, fstat and close on a path this test owns; nothing else holds the fd.
3508        let fd = unsafe { linux::open_verified_dir(&source, identity) }.expect("same directory");
3509        let mut path = [0u8; 32];
3510        linux::proc_fd_path(fd, &mut path).unwrap();
3511        let resolved = std::fs::canonicalize(
3512            std::ffi::CStr::from_bytes_until_nul(&path)
3513                .unwrap()
3514                .to_str()
3515                .unwrap(),
3516        )
3517        .unwrap();
3518        assert_eq!(resolved, store.canonicalize().unwrap());
3519        // SAFETY: `fd` was returned open by `open_verified_dir` and is closed once.
3520        unsafe { libc::close(fd) };
3521
3522        std::fs::rename(&store, dir.path().join("moved")).unwrap();
3523        std::os::unix::fs::symlink(&outside, &store).unwrap();
3524        // SAFETY: `source` is a NUL-terminated path this test owns; on the identity mismatch it
3525        // reports, `open_verified_dir` has already closed the descriptor it opened.
3526        assert!(unsafe { linux::open_verified_dir(&source, identity) }.is_err());
3527        assert_eq!(
3528            std::io::Error::last_os_error().raw_os_error(),
3529            Some(libc::ESTALE)
3530        );
3531    }
3532
3533    /// The composed root's own `/tmp` must not land on top of the path leading to the workdir.
3534    ///
3535    /// This is `mur run`'s default layout, not a curiosity: with no `--workdir`, the session
3536    /// workdir is a temporary directory, which is under `/tmp`. Planned in the other order, the
3537    /// `/tmp` bind hid the workdir bind underneath it and the construction died at `chdir` —
3538    /// *after* `pivot_root`, with the host root already detached, so the failure arrived as a bare
3539    /// `ENOENT` from inside a root that no longer had a way back.
3540    #[test]
3541    fn a_workdir_under_tmp_survives_the_composed_tmp_mounted_over_its_path() {
3542        let workdir = "/tmp/session-1234/w";
3543        let plan = plan_for(workdir);
3544        let operations = ops(&plan);
3545
3546        // A workdir under /tmp forces a base other than /tmp, so both mounts are still expressible.
3547        assert_eq!(
3548            choose_root_base(Path::new(workdir), &[], SEALED_ROOT_BASE_CANDIDATES, |_| {
3549                true
3550            }),
3551            Some(PathBuf::from("/run")),
3552        );
3553
3554        let tmp_index = operations
3555            .iter()
3556            .position(
3557                |op| matches!(op, RootOp::BindVerified { target, .. } if target == Path::new("/tmp/tmp")),
3558            )
3559            .expect("the composed /tmp is bound");
3560        let workdir_index = operations
3561            .iter()
3562            .position(
3563                |op| matches!(op, RootOp::Bind { source, .. } if source == Path::new(workdir)),
3564            )
3565            .expect("the workdir is bound");
3566        assert!(
3567            tmp_index < workdir_index,
3568            "/tmp must be mounted before the workdir path is built inside it, or the workdir bind \
3569             is hidden by it",
3570        );
3571    }
3572
3573    #[test]
3574    fn dev_carries_the_oci_default_set_and_is_sealed_afterwards() {
3575        let plan = plan_for("/home/u/w");
3576        let operations = ops(&plan);
3577
3578        for device in SEALED_DEVICE_NODES {
3579            assert!(
3580                operations.contains(&RootOp::Bind {
3581                    source: PathBuf::from("/dev").join(device.name),
3582                    target: PathBuf::from("/tmp/dev").join(device.name),
3583                    read_only: false,
3584                }),
3585                "missing /dev/{}",
3586                device.name
3587            );
3588        }
3589        // No block device, and nothing outside the OCI default set.
3590        assert!(!operations.iter().any(
3591            |op| matches!(op, RootOp::Bind { source, .. } if source == Path::new("/dev/sda"))
3592        ));
3593
3594        let tmpfs_index = operations
3595            .iter()
3596            .position(
3597                |op| matches!(op, RootOp::Tmpfs { target, .. } if target == Path::new("/tmp/dev")),
3598            )
3599            .expect("/dev tmpfs");
3600        let sealed_index = operations
3601            .iter()
3602            .position(
3603                |op| matches!(op, RootOp::RemountReadOnly(path) if path == Path::new("/tmp/dev")),
3604            )
3605            .expect("/dev sealed read-only");
3606        assert!(
3607            tmpfs_index < sealed_index,
3608            "the device nodes must be bound before /dev is sealed"
3609        );
3610    }
3611
3612    /// The plan always asks for a masked `/proc`; whether the kernel grants one is a runtime
3613    /// question the executor answers, and on a bare host the answer is no — see
3614    /// [`PROC_HIDEPID_OPTIONS`] for the `CAP_SYS_ADMIN`-over-the-PID-namespace rule that forces
3615    /// the bind fallback. What this pins is the *preference order*: masking is tried first, and
3616    /// the unmasked private `procfs` is the last of the three before the fallback, never the
3617    /// first.
3618    #[test]
3619    fn proc_is_planned_and_masking_is_preferred_over_an_unmasked_mount() {
3620        let plan = plan_for("/home/u/w");
3621        assert!(ops(&plan).contains(&RootOp::Proc {
3622            target: PathBuf::from("/tmp/proc")
3623        }));
3624        assert_eq!(PROC_HIDEPID_OPTIONS[0], "hidepid=2");
3625        assert_eq!(
3626            PROC_HIDEPID_OPTIONS.last(),
3627            Some(&""),
3628            "the unmasked spelling must be tried last, after every masked one",
3629        );
3630    }
3631
3632    #[test]
3633    fn manifest_derived_directories_are_whole_directory_binds_and_deduped() {
3634        let mut host = FakeHost::usrmerge();
3635        host.0
3636            .insert(PathBuf::from("/opt/python3.12"), PathKind::Dir);
3637        let plan = plan_composed_root(
3638            Path::new("/home/u/w"),
3639            &test_sources(),
3640            Path::new("/tmp"),
3641            &[PathBuf::from("/opt/python3.12"), PathBuf::from("/usr")],
3642            &[],
3643            &host,
3644        );
3645        let operations = ops(&plan);
3646
3647        assert!(operations.contains(&RootOp::Bind {
3648            source: PathBuf::from("/opt/python3.12"),
3649            target: PathBuf::from("/tmp/opt/python3.12"),
3650            read_only: true,
3651        }));
3652        // `/usr` was already bound by the fixed list; naming it again adds nothing.
3653        assert_eq!(
3654            operations
3655                .iter()
3656                .filter(
3657                    |op| matches!(op, RootOp::Bind { source, .. } if source == Path::new("/usr"))
3658                )
3659                .count(),
3660            1
3661        );
3662    }
3663
3664    /// The property the whole capability rests on: `extra_read_only` shrinks when the host is
3665    /// missing a path, `staged_runtime_read_only` fails. Both directions are asserted from the
3666    /// same plan so the contrast cannot drift apart.
3667    #[test]
3668    fn a_staged_runtime_path_absent_from_the_host_still_plans_a_required_bind() {
3669        // Neither path is in `FakeHost::usrmerge()` — the host has neither.
3670        let plan = plan_composed_root(
3671            Path::new("/home/u/w"),
3672            &test_sources(),
3673            Path::new("/tmp"),
3674            &[PathBuf::from("/opt/optional-tree")],
3675            &[PathBuf::from("/opt/staged-tree")],
3676            &FakeHost::usrmerge(),
3677        );
3678
3679        // The optional entry contributes nothing at all — not even a step that would fail. This
3680        // is `mirror`'s silent-skip-on-absence, and it is why `extra_read_only` could not be the
3681        // destination for a staged grant.
3682        assert!(
3683            !plan.steps.iter().any(|step| matches!(
3684                &step.op,
3685                RootOp::Bind { source, .. } if source == Path::new("/opt/optional-tree")
3686            )),
3687            "an absent extra_read_only path must plan no step",
3688        );
3689
3690        // The staged entry is planned regardless, and is required — so the real `mount(2)`
3691        // returning ENOENT aborts the construction before `pivot_root`.
3692        let staged = plan
3693            .steps
3694            .iter()
3695            .find(|step| {
3696                matches!(
3697                    &step.op,
3698                    RootOp::Bind { source, .. } if source == Path::new("/opt/staged-tree")
3699                )
3700            })
3701            .expect("an absent staged_runtime path must still plan a bind");
3702        assert_eq!(
3703            staged.op,
3704            RootOp::Bind {
3705                source: PathBuf::from("/opt/staged-tree"),
3706                target: PathBuf::from("/tmp/opt/staged-tree"),
3707                read_only: true,
3708            },
3709            "staged trees are re-based read-only binds at their own absolute path",
3710        );
3711        assert!(
3712            staged.required,
3713            "a staged_runtime bind must be required, so a missing source is session-fatal \
3714             rather than a silently narrower root",
3715        );
3716    }
3717
3718    /// Staged binds land before `/etc`, `/dev`, `/proc`, `/tmp` and the workdir — and therefore
3719    /// before the `pivot_root` that `construct_composed_root` performs after every planned step.
3720    #[test]
3721    fn staged_runtime_binds_are_planned_before_every_other_step() {
3722        let mut host = FakeHost::usrmerge();
3723        host.0
3724            .insert(PathBuf::from("/opt/staged-tree"), PathKind::Dir);
3725        let plan = plan_composed_root(
3726            Path::new("/home/u/w"),
3727            &test_sources(),
3728            Path::new("/tmp"),
3729            &[],
3730            &[PathBuf::from("/opt/staged-tree")],
3731            &host,
3732        );
3733
3734        let position = |predicate: &dyn Fn(&RootOp) -> bool| {
3735            plan.steps
3736                .iter()
3737                .position(|step| predicate(&step.op))
3738                .expect("step must be planned")
3739        };
3740
3741        let staged = position(
3742            &|op| matches!(op, RootOp::Bind { source, .. } if source == Path::new("/opt/staged-tree")),
3743        );
3744        let fixed_runtime_tree = position(
3745            &|op| matches!(op, RootOp::Bind { source, .. } if source == Path::new("/usr")),
3746        );
3747        let etc = position(
3748            &|op| matches!(op, RootOp::Bind { source, .. } if source == Path::new("/etc/ssl")),
3749        );
3750        let dev = position(
3751            &|op| matches!(op, RootOp::Tmpfs { target, .. } if target == Path::new("/tmp/dev")),
3752        );
3753        let proc = position(&|op| matches!(op, RootOp::Proc { .. }));
3754        let tmp = position(
3755            &|op| matches!(op, RootOp::BindVerified { target, .. } if target == Path::new("/tmp/tmp")),
3756        );
3757        let workdir = position(
3758            &|op| matches!(op, RootOp::Bind { source, .. } if source == Path::new("/home/u/w")),
3759        );
3760
3761        // Ahead of the fixed runtime tree / `extra_read_only` loop, so a coincidental target
3762        // collision is won by the required registration rather than the optional one.
3763        assert!(
3764            staged < fixed_runtime_tree,
3765            "staged binds precede the fixed runtime tree"
3766        );
3767        assert!(staged < etc, "staged binds precede /etc");
3768        assert!(staged < dev, "staged binds precede /dev");
3769        assert!(staged < proc, "staged binds precede /proc");
3770        assert!(staged < tmp, "staged binds precede /tmp");
3771        assert!(staged < workdir, "staged binds precede the workdir bind");
3772    }
3773
3774    /// The collision case the ordering above exists to protect: a path named by *both* parameters
3775    /// is registered once, by the required loop.
3776    #[test]
3777    fn a_path_named_both_staged_and_optional_stays_required() {
3778        let mut host = FakeHost::usrmerge();
3779        host.0.insert(PathBuf::from("/opt/shared"), PathKind::Dir);
3780        let plan = plan_composed_root(
3781            Path::new("/home/u/w"),
3782            &test_sources(),
3783            Path::new("/tmp"),
3784            &[PathBuf::from("/opt/shared")],
3785            &[PathBuf::from("/opt/shared")],
3786            &host,
3787        );
3788
3789        let binds: Vec<&RootStep> = plan
3790            .steps
3791            .iter()
3792            .filter(|step| {
3793                matches!(
3794                    &step.op,
3795                    RootOp::Bind { source, .. } if source == Path::new("/opt/shared")
3796                )
3797            })
3798            .collect();
3799        assert_eq!(
3800            binds.len(),
3801            1,
3802            "the shared target is registered exactly once"
3803        );
3804        assert!(
3805            binds[0].required,
3806            "and the required registration is the one that won"
3807        );
3808    }
3809
3810    // ---- `UsernsGrant` ---------------------------------------------------------------------
3811
3812    /// Every variant, produced from the combination of readings that must produce it. The three
3813    /// permitting cases are the point: they reach the same achieved class, so only the variant
3814    /// separates a `sealed` result on a host whose hardening is switched off from one obtained
3815    /// through the shipped profile.
3816    #[test]
3817    fn every_userns_grant_comes_from_its_own_combination_of_readings() {
3818        let cases = [
3819            // AppArmor off, or the knob file missing entirely: nothing restricts anything.
3820            ((None, None, None), UsernsGrant::ApparmorAbsent),
3821            (
3822                (Some("N"), Some("1"), Some("unconfined")),
3823                UsernsGrant::ApparmorAbsent,
3824            ),
3825            // Enabled, restriction explicitly off host-wide — the `/etc/sysctl.d` drop-in case.
3826            (
3827                (Some("Y"), Some("0"), Some("unconfined")),
3828                UsernsGrant::RestrictionDisabledHostWide,
3829            ),
3830            // Enabled and the knob unreadable: not restricted, so not withheld.
3831            (
3832                (Some("1"), None, Some("unconfined")),
3833                UsernsGrant::RestrictionDisabledHostWide,
3834            ),
3835            // Restriction on, and a profile whose name begins with `mur-sealed` is attached —
3836            // both the shipped profile and the checkout profile the dev script generates.
3837            (
3838                (Some("Y"), Some("Y"), Some("mur-sealed (unconfined)")),
3839                UsernsGrant::ProfileConfining,
3840            ),
3841            (
3842                (Some("Y"), Some("1"), Some("mur-sealed-home (unconfined)")),
3843                UsernsGrant::ProfileConfining,
3844            ),
3845            (
3846                (Some("Y"), Some("1"), Some("mur-sealed-dev (unconfined)")),
3847                UsernsGrant::ProfileConfining,
3848            ),
3849            // Restriction on and nothing attached, or something else attached.
3850            (
3851                (Some("Y"), Some("Y"), Some("unconfined")),
3852                UsernsGrant::Withheld,
3853            ),
3854            (
3855                (Some("Y"), Some("1"), Some("firefox (enforce)")),
3856                UsernsGrant::Withheld,
3857            ),
3858            ((Some("Y"), Some("1"), None), UsernsGrant::Withheld),
3859        ];
3860
3861        for ((enabled, restricted, current), expected) in cases {
3862            assert_eq!(
3863                UsernsGrant::from_readings(enabled, restricted, current),
3864                expected,
3865                "enabled={enabled:?} restricted={restricted:?} current={current:?}"
3866            );
3867        }
3868    }
3869
3870    /// The wire names are what `--explain-scope --json`, `session_start` and `mur doctor` all
3871    /// print, so two variants sharing one would re-collapse exactly the distinction this enum
3872    /// exists to make.
3873    #[test]
3874    fn every_grant_has_a_distinct_wire_name_and_summary() {
3875        let names: Vec<&str> = UsernsGrant::ALL
3876            .iter()
3877            .map(|grant| grant.wire_name())
3878            .collect();
3879        let mut unique = names.clone();
3880        unique.sort_unstable();
3881        unique.dedup();
3882        assert_eq!(unique.len(), names.len(), "two grants share a wire name");
3883        assert_eq!(
3884            names,
3885            [
3886                "apparmor_absent",
3887                "restriction_disabled_host_wide",
3888                "profile_confining",
3889                "withheld",
3890            ]
3891        );
3892
3893        for grant in UsernsGrant::ALL {
3894            assert_eq!(
3895                serde_json::to_value(grant).unwrap(),
3896                serde_json::Value::String(grant.wire_name().to_string()),
3897                "the serialized form and the wire name must be one string"
3898            );
3899        }
3900
3901        // The `mur doctor` line under each wire name has to say something different for each, or
3902        // the block repeats the wire name in longer words and tells an operator nothing.
3903        let mut summaries: Vec<&str> = UsernsGrant::ALL.iter().map(|g| g.summary()).collect();
3904        summaries.sort_unstable();
3905        summaries.dedup();
3906        assert_eq!(
3907            summaries.len(),
3908            UsernsGrant::ALL.len(),
3909            "two grants share a summary"
3910        );
3911
3912        // An unprobed host claims nothing, exactly as the `bool` field's `false` default did.
3913        assert_eq!(UsernsGrant::default(), UsernsGrant::Withheld);
3914    }
3915
3916    // ---- the installed profile ------------------------------------------------------------
3917
3918    /// All four outcomes, driven by a `Result` rather than by the filesystem, so a host with no
3919    /// AppArmor and a host with an unreadable `/etc/apparmor.d` are both covered without root and
3920    /// without touching a byte of `/etc`.
3921    #[test]
3922    fn the_installed_profile_classifier_separates_all_four_outcomes() {
3923        let shipped = std::fs::read(concat!(
3924            env!("CARGO_MANIFEST_DIR"),
3925            "/../../packaging/apparmor/mur-sealed"
3926        ))
3927        .expect("the shipped profile is in the workspace");
3928        assert_eq!(
3929            classify_installed_profile(Ok(shipped)),
3930            InstalledProfileState::Matches
3931        );
3932
3933        let drifted = classify_installed_profile(Ok(b"# an older revision\n".to_vec()));
3934        match drifted {
3935            InstalledProfileState::Drifted { installed_sha256 } => {
3936                assert_ne!(installed_sha256, SEALED_APPARMOR_PROFILE_SHA256);
3937                assert_eq!(installed_sha256.len(), 64);
3938            }
3939            other => panic!("edited bytes must read as drift, got {other:?}"),
3940        }
3941
3942        assert_eq!(
3943            classify_installed_profile(Err(std::io::Error::from(std::io::ErrorKind::NotFound))),
3944            InstalledProfileState::Absent
3945        );
3946
3947        // "I could not look" must never read as "it is not there".
3948        match classify_installed_profile(Err(std::io::Error::from(
3949            std::io::ErrorKind::PermissionDenied,
3950        ))) {
3951            InstalledProfileState::Unreadable { error } => assert!(!error.is_empty()),
3952            other => panic!("a read error must stay distinct from absence, got {other:?}"),
3953        }
3954    }
3955
3956    /// The probe child cannot compose a `CString`, so it carries NUL-terminated copies of the
3957    /// old-root parking directory's name: three spellings of one name.
3958    #[test]
3959    fn old_root_names_agree() {
3960        assert_eq!(OLD_ROOT_NAME_C.to_str().unwrap(), OLD_ROOT_NAME);
3961        assert_eq!(
3962            OLD_ROOT_PIVOTED_C.to_str().unwrap(),
3963            format!("/{OLD_ROOT_NAME}")
3964        );
3965    }
3966
3967    /// Four outcomes, none of them inferred from a path. `unconfined` and "I could not look" stay
3968    /// apart for the same reason [`InstalledProfileState`]'s two absences do.
3969    #[test]
3970    fn profile_attachment_reports_only_what_the_kernel_said() {
3971        assert_eq!(
3972            classify_profile_attachment(Some("mur-sealed (unconfined)")),
3973            ProfileAttachment::Sealed {
3974                profile: "mur-sealed".to_string()
3975            }
3976        );
3977        assert_eq!(
3978            classify_profile_attachment(Some("mur-sealed-home")),
3979            ProfileAttachment::Sealed {
3980                profile: "mur-sealed-home".to_string()
3981            }
3982        );
3983        assert_eq!(
3984            classify_profile_attachment(Some("snap.chromium.chromium (enforce)")),
3985            ProfileAttachment::Other {
3986                profile: "snap.chromium.chromium".to_string()
3987            }
3988        );
3989        assert_eq!(
3990            classify_profile_attachment(Some("unconfined")),
3991            ProfileAttachment::Unconfined
3992        );
3993        assert_eq!(
3994            classify_profile_attachment(Some("unconfined\n")),
3995            ProfileAttachment::Unconfined
3996        );
3997        assert_eq!(
3998            classify_profile_attachment(None),
3999            ProfileAttachment::NotReported
4000        );
4001        assert_eq!(
4002            classify_profile_attachment(Some("   ")),
4003            ProfileAttachment::NotReported
4004        );
4005    }
4006
4007    /// The attachment list is documentation of the shipped profile, never a verdict — nothing
4008    /// decides anything by matching a path against it. This keeps it honest about what the file
4009    /// actually declares.
4010    #[test]
4011    fn attachment_paths_are_the_ones_the_shipped_profile_declares() {
4012        let path = concat!(
4013            env!("CARGO_MANIFEST_DIR"),
4014            "/../../packaging/apparmor/mur-sealed"
4015        );
4016        let profile =
4017            std::fs::read_to_string(path).expect("the shipped profile is in the workspace");
4018        for attachment in SEALED_APPARMOR_ATTACHMENT_PATHS {
4019            assert!(
4020                profile.contains(attachment),
4021                "{attachment} is not an attachment in packaging/apparmor/mur-sealed"
4022            );
4023        }
4024    }
4025
4026    /// The digest constant is a literal because `capsule-runtime` is published to crates.io and
4027    /// `cargo package` would not carry a file from outside the crate directory. This test is what
4028    /// keeps the literal honest, and it does not run during `cargo package`.
4029    #[test]
4030    fn shipped_profile_digest_constant_matches_the_file() {
4031        let path = concat!(
4032            env!("CARGO_MANIFEST_DIR"),
4033            "/../../packaging/apparmor/mur-sealed"
4034        );
4035        let bytes = std::fs::read(path).expect("the shipped profile is in the workspace");
4036        let actual = murmur_artifact::sha256_hex(&bytes);
4037        assert_eq!(
4038            actual, SEALED_APPARMOR_PROFILE_SHA256,
4039            "packaging/apparmor/mur-sealed changed. Update SEALED_APPARMOR_PROFILE_SHA256 in \
4040             crates/capsule-runtime/src/sealed.rs to:\n    {actual}"
4041        );
4042    }
4043
4044    /// The profile is the fix and the sysctl is a fallback that costs the whole host its
4045    /// unprivileged-userns hardening. Both must be in the text — on a host where no profile can be
4046    /// loaded the sysctl is the right answer — but their order and the stated cost are what stop a
4047    /// reader taking them for peers.
4048    #[test]
4049    fn the_apparmor_reason_offers_the_profile_before_the_sysctl() {
4050        let reason = SealedBlocker::AppArmorProfileMissing.reason();
4051        let profile = reason
4052            .find("apparmor_parser -r")
4053            .expect("the profile install command must be named");
4054        let sysctl = reason
4055            .find("kernel.apparmor_restrict_unprivileged_userns=0")
4056            .expect("the fallback must still be named for hosts that cannot load a profile");
4057        assert!(
4058            profile < sysctl,
4059            "the profile install must come first: {reason}"
4060        );
4061        assert!(
4062            reason.contains("LAST RESORT"),
4063            "the sysctl must be labelled a fallback, not offered as a peer: {reason}"
4064        );
4065        assert!(
4066            reason.contains("every program on the machine"),
4067            "the sysctl's host-wide cost must be stated: {reason}"
4068        );
4069        assert!(
4070            reason.contains("scripts/install-dev-apparmor.sh"),
4071            "a checkout build must be told how to get the narrow grant: {reason}"
4072        );
4073    }
4074
4075    /// `SealedBlocker::ALL` is what `murmur-cli`'s containment test matches a refusal against, so a
4076    /// variant missing from it makes that test assert something false about the *host*. Adding a
4077    /// variant must break this test — loudly, here — rather than surface three crates away.
4078    #[test]
4079    fn every_blocker_is_listed_in_all_with_its_own_actionable_reason() {
4080        // The exhaustive match is the mechanism: a new variant fails to compile until it is added
4081        // here, and the `contains` below then forces it into `ALL` too.
4082        for blocker in SealedBlocker::ALL {
4083            let named = match blocker {
4084                SealedBlocker::NotLinux
4085                | SealedBlocker::AppArmorProfileMissing
4086                | SealedBlocker::NamespaceCreationDenied
4087                | SealedBlocker::IdMapDenied
4088                | SealedBlocker::MountDenied
4089                | SealedBlocker::PivotRootDenied
4090                | SealedBlocker::KernelUnsupported
4091                | SealedBlocker::LandlockUnavailable
4092                | SealedBlocker::NetworkNamespaceUnavailable
4093                | SealedBlocker::ProbeUnavailable => *blocker,
4094            };
4095            assert!(
4096                SealedBlocker::ALL.contains(&named),
4097                "{named:?} is missing from SealedBlocker::ALL",
4098            );
4099        }
4100
4101        let reasons: Vec<String> = SealedBlocker::ALL
4102            .iter()
4103            .map(|blocker| blocker.reason())
4104            .collect();
4105        for reason in &reasons {
4106            assert!(reason.starts_with("sealed "), "got: {reason}");
4107            assert!(!reason.contains('\n'), "got: {reason}");
4108            assert!(
4109                reason.len() > 80,
4110                "a blocker reason must name the mechanism and its remediation, not just fail: \
4111                 {reason}",
4112            );
4113        }
4114        let mut unique = reasons.clone();
4115        unique.sort();
4116        unique.dedup();
4117        assert_eq!(
4118            unique.len(),
4119            reasons.len(),
4120            "two blockers share a reason, so the refusal cannot say which one applies",
4121        );
4122    }
4123
4124    #[test]
4125    fn the_device_set_is_exactly_the_oci_default_and_excludes_shm() {
4126        let names: Vec<&str> = SEALED_DEVICE_NODES.iter().map(|d| d.name).collect();
4127        assert_eq!(names, ["null", "zero", "full", "random", "urandom", "tty"]);
4128        assert!(!names.contains(&"shm"));
4129        assert!(!names.contains(&"console"));
4130        // Every entry records the canonical major/minor the OCI spec names.
4131        assert_eq!(
4132            SEALED_DEVICE_NODES
4133                .iter()
4134                .map(|d| (d.path, d.major, d.minor, d.mode, d.writable))
4135                .collect::<Vec<_>>(),
4136            [
4137                ("/dev/null", 1, 3, 0o666, true),
4138                ("/dev/zero", 1, 5, 0o666, true),
4139                ("/dev/full", 1, 7, 0o666, true),
4140                ("/dev/random", 1, 8, 0o666, false),
4141                ("/dev/urandom", 1, 9, 0o666, false),
4142                ("/dev/tty", 5, 0, 0o666, true),
4143            ]
4144        );
4145    }
4146
4147    // ---- `sealed_shortfall` -----------------------------------------------------------------
4148
4149    mod shortfall {
4150        use super::super::*;
4151        use crate::namespace_probe::{
4152            MountAttempt, MountAttemptError, MountStep, NamespaceReading,
4153        };
4154        use crate::network_namespace::{CreationStep, NamespaceAttemptError};
4155
4156        fn docker() -> ProfileAttachment {
4157            ProfileAttachment::Other {
4158                profile: "docker-default".to_string(),
4159            }
4160        }
4161
4162        fn created(grant: UsernsGrant, attachment: ProfileAttachment) -> NamespaceReading {
4163            NamespaceReading {
4164                network: Ok(()),
4165                mount: MountAttempt::Created,
4166                sealed_root: MountAttempt::Created,
4167                userns_grant: grant,
4168                attachment,
4169            }
4170        }
4171
4172        fn network_failed(step: CreationStep, errno: i32) -> NamespaceReading {
4173            NamespaceReading {
4174                network: Err(NamespaceAttemptError::Step { step, errno }),
4175                mount: MountAttempt::NotAttempted,
4176                sealed_root: MountAttempt::NotAttempted,
4177                ..created(UsernsGrant::Withheld, ProfileAttachment::Unconfined)
4178            }
4179        }
4180
4181        fn root_failed(step: MountStep, errno: i32) -> NamespaceReading {
4182            NamespaceReading {
4183                sealed_root: MountAttempt::Failed(MountAttemptError::Step { step, errno }),
4184                ..created(UsernsGrant::Withheld, ProfileAttachment::Unconfined)
4185            }
4186        }
4187
4188        /// Every mapping of the design, with the blocker it decides and the rendering it carries:
4189        /// the class sentence, the failed call, the `errno` symbol and words, and the readings
4190        /// line last.
4191        #[test]
4192        fn every_failed_attempt_names_its_blocker_call_errno_and_readings() {
4193            use CreationStep as C;
4194            use MountStep as M;
4195            let docker_eperm = |step| NamespaceReading {
4196                attachment: docker(),
4197                ..network_failed(step, libc::EPERM)
4198            };
4199            let cases: Vec<(NamespaceReading, SealedBlocker, &str, i32)> = vec![
4200                (
4201                    network_failed(C::Unshare, libc::ENOSPC),
4202                    SealedBlocker::KernelUnsupported,
4203                    C::Unshare.describe(),
4204                    libc::ENOSPC,
4205                ),
4206                (
4207                    network_failed(C::Unshare, libc::EINVAL),
4208                    SealedBlocker::KernelUnsupported,
4209                    C::Unshare.describe(),
4210                    libc::EINVAL,
4211                ),
4212                (
4213                    network_failed(C::Unshare, libc::ENOSYS),
4214                    SealedBlocker::KernelUnsupported,
4215                    C::Unshare.describe(),
4216                    libc::ENOSYS,
4217                ),
4218                (
4219                    network_failed(C::Unshare, libc::EUSERS),
4220                    SealedBlocker::KernelUnsupported,
4221                    C::Unshare.describe(),
4222                    libc::EUSERS,
4223                ),
4224                (
4225                    network_failed(C::Unshare, libc::EPERM),
4226                    SealedBlocker::AppArmorProfileMissing,
4227                    C::Unshare.describe(),
4228                    libc::EPERM,
4229                ),
4230                (
4231                    network_failed(C::IdentityMaps, libc::EPERM),
4232                    SealedBlocker::AppArmorProfileMissing,
4233                    C::IdentityMaps.describe(),
4234                    libc::EPERM,
4235                ),
4236                (
4237                    network_failed(C::IdentityMaps, libc::EACCES),
4238                    SealedBlocker::AppArmorProfileMissing,
4239                    C::IdentityMaps.describe(),
4240                    libc::EACCES,
4241                ),
4242                (
4243                    docker_eperm(C::Unshare),
4244                    SealedBlocker::NamespaceCreationDenied,
4245                    C::Unshare.describe(),
4246                    libc::EPERM,
4247                ),
4248                (
4249                    docker_eperm(C::IdentityMaps),
4250                    SealedBlocker::IdMapDenied,
4251                    C::IdentityMaps.describe(),
4252                    libc::EPERM,
4253                ),
4254                (
4255                    network_failed(C::Loopback, libc::EPERM),
4256                    SealedBlocker::NetworkNamespaceUnavailable,
4257                    C::Loopback.describe(),
4258                    libc::EPERM,
4259                ),
4260                (
4261                    network_failed(C::LocalRoute, libc::EPERM),
4262                    SealedBlocker::NetworkNamespaceUnavailable,
4263                    C::LocalRoute.describe(),
4264                    libc::EPERM,
4265                ),
4266                (
4267                    network_failed(C::Resolver, libc::EADDRINUSE),
4268                    SealedBlocker::NetworkNamespaceUnavailable,
4269                    C::Resolver.describe(),
4270                    libc::EADDRINUSE,
4271                ),
4272                (
4273                    network_failed(C::HandOff, libc::EMFILE),
4274                    SealedBlocker::NetworkNamespaceUnavailable,
4275                    C::HandOff.describe(),
4276                    libc::EMFILE,
4277                ),
4278                (
4279                    root_failed(M::JoinSession, libc::EINVAL),
4280                    SealedBlocker::NetworkNamespaceUnavailable,
4281                    M::JoinSession.describe(),
4282                    libc::EINVAL,
4283                ),
4284                (
4285                    root_failed(M::UnshareUserMount, libc::EUSERS),
4286                    SealedBlocker::KernelUnsupported,
4287                    M::UnshareUserMount.describe(),
4288                    libc::EUSERS,
4289                ),
4290                (
4291                    root_failed(M::UnshareUserMount, libc::ENOSPC),
4292                    SealedBlocker::KernelUnsupported,
4293                    M::UnshareUserMount.describe(),
4294                    libc::ENOSPC,
4295                ),
4296                (
4297                    root_failed(M::UnshareUserMount, libc::EPERM),
4298                    SealedBlocker::NamespaceCreationDenied,
4299                    M::UnshareUserMount.describe(),
4300                    libc::EPERM,
4301                ),
4302                (
4303                    root_failed(M::IdentityMaps, libc::EPERM),
4304                    SealedBlocker::IdMapDenied,
4305                    M::IdentityMaps.describe(),
4306                    libc::EPERM,
4307                ),
4308                (
4309                    root_failed(M::MakePrivateNested, libc::EACCES),
4310                    SealedBlocker::MountDenied,
4311                    M::MakePrivateNested.describe(),
4312                    libc::EACCES,
4313                ),
4314                (
4315                    root_failed(M::Tmpfs, libc::EPERM),
4316                    SealedBlocker::MountDenied,
4317                    M::Tmpfs.describe(),
4318                    libc::EPERM,
4319                ),
4320                (
4321                    root_failed(M::PivotPrepare, libc::EACCES),
4322                    SealedBlocker::PivotRootDenied,
4323                    M::PivotPrepare.describe(),
4324                    libc::EACCES,
4325                ),
4326                (
4327                    root_failed(M::PivotRoot, libc::EPERM),
4328                    SealedBlocker::PivotRootDenied,
4329                    M::PivotRoot.describe(),
4330                    libc::EPERM,
4331                ),
4332                (
4333                    root_failed(M::DetachOldRoot, libc::EINVAL),
4334                    SealedBlocker::PivotRootDenied,
4335                    M::DetachOldRoot.describe(),
4336                    libc::EINVAL,
4337                ),
4338            ];
4339            for (reading, blocker, call, errno) in cases {
4340                for landlock in [true, false] {
4341                    let shortfall = sealed_shortfall(true, landlock, &reading)
4342                        .unwrap_or_else(|| panic!("{reading:?} must be refused"));
4343                    assert_eq!(shortfall.blocker, blocker, "{reading:?}");
4344                    assert_eq!(shortfall.failed_call.as_deref(), Some(call));
4345                    assert_eq!(shortfall.errno, Some(errno));
4346                    let reason = shortfall.reason();
4347                    let name = crate::network_namespace::errno_name(errno).unwrap();
4348                    let words = crate::network_namespace::errno_words(errno);
4349                    assert!(reason.starts_with(&blocker.reason()), "{reason}");
4350                    assert!(
4351                        reason
4352                            .contains(&format!("The probe's {call} failed with {name} ({words}).")),
4353                        "{reason}"
4354                    );
4355                    let readings = reason.find("Host readings:").expect("readings line");
4356                    assert!(reason[readings..].ends_with('.'), "{reason}");
4357                    assert!(
4358                        !reason[readings..].contains("The probe's"),
4359                        "the readings come last: {reason}"
4360                    );
4361                }
4362            }
4363        }
4364
4365        /// A probe that could not run is refused as unknown, with the runtime's own account and
4366        /// no errno.
4367        #[test]
4368        fn a_probe_that_could_not_run_is_refused_rather_than_assumed() {
4369            for reading in [
4370                NamespaceReading {
4371                    network: Err(NamespaceAttemptError::Creator(
4372                        "fork() for the namespace creator failed: EAGAIN".to_string(),
4373                    )),
4374                    mount: MountAttempt::NotAttempted,
4375                    sealed_root: MountAttempt::NotAttempted,
4376                    ..created(UsernsGrant::Withheld, ProfileAttachment::Unconfined)
4377                },
4378                NamespaceReading {
4379                    sealed_root: MountAttempt::Failed(MountAttemptError::Rehearsal(
4380                        "fork() for the sealed-root rehearsal failed: EAGAIN".to_string(),
4381                    )),
4382                    ..created(UsernsGrant::Withheld, ProfileAttachment::Unconfined)
4383                },
4384            ] {
4385                let shortfall = sealed_shortfall(true, true, &reading).unwrap();
4386                assert_eq!(shortfall.blocker, SealedBlocker::ProbeUnavailable);
4387                assert_eq!(shortfall.errno, None);
4388                let reason = shortfall.reason();
4389                assert!(
4390                    reason.contains("The probe could not run: fork() for the"),
4391                    "{reason}"
4392                );
4393                assert!(reason.contains("Host readings:"), "{reason}");
4394            }
4395        }
4396
4397        /// `AppArmorProfileMissing` comes only from a failed attempt: every grant and attachment
4398        /// over a reading whose attempts all created their namespaces is no shortfall at all.
4399        #[test]
4400        fn the_readings_never_refuse_a_host_whose_attempts_all_succeeded() {
4401            for grant in UsernsGrant::ALL {
4402                for attachment in [
4403                    ProfileAttachment::Unconfined,
4404                    docker(),
4405                    ProfileAttachment::NotReported,
4406                    ProfileAttachment::Sealed {
4407                        profile: "mur-sealed".to_string(),
4408                    },
4409                ] {
4410                    let reading = created(*grant, attachment);
4411                    assert_eq!(sealed_shortfall(true, true, &reading), None, "{reading:?}");
4412                    assert_eq!(
4413                        sealed_shortfall(true, false, &reading).map(|s| s.blocker),
4414                        Some(SealedBlocker::LandlockUnavailable)
4415                    );
4416                    assert_eq!(
4417                        sealed_shortfall(false, true, &reading).map(|s| s.blocker),
4418                        Some(SealedBlocker::NotLinux)
4419                    );
4420                }
4421            }
4422        }
4423
4424        /// An `EPERM` the readings cannot explain — a grant other than `withheld` — is the
4425        /// namespace or its map refused, never the missing `mur-sealed` profile.
4426        #[test]
4427        fn a_refusal_under_any_other_grant_is_not_blamed_on_the_profile() {
4428            for grant in [
4429                UsernsGrant::ApparmorAbsent,
4430                UsernsGrant::ProfileConfining,
4431                UsernsGrant::RestrictionDisabledHostWide,
4432            ] {
4433                for (step, blocker) in [
4434                    (
4435                        CreationStep::Unshare,
4436                        SealedBlocker::NamespaceCreationDenied,
4437                    ),
4438                    (CreationStep::IdentityMaps, SealedBlocker::IdMapDenied),
4439                ] {
4440                    let reading = NamespaceReading {
4441                        userns_grant: grant,
4442                        ..network_failed(step, libc::EPERM)
4443                    };
4444                    assert_eq!(
4445                        sealed_shortfall(true, true, &reading).map(|s| s.blocker),
4446                        Some(blocker),
4447                        "{reading:?}"
4448                    );
4449                }
4450            }
4451        }
4452
4453        /// The `/tmp` attempt is the mount namespace a `scoped` command uses; the composed root
4454        /// binds `/tmp` itself, so a refused `/tmp` mapping says nothing about `sealed`.
4455        #[test]
4456        fn the_tmp_attempt_never_affects_sealed() {
4457            let reading = NamespaceReading {
4458                mount: MountAttempt::Failed(MountAttemptError::Step {
4459                    step: MountStep::MakePrivate,
4460                    errno: libc::EACCES,
4461                }),
4462                ..created(UsernsGrant::Withheld, ProfileAttachment::Unconfined)
4463            };
4464            assert_eq!(sealed_shortfall(true, true, &reading), None);
4465        }
4466
4467        /// No attempt failed for `NotLinux` and `LandlockUnavailable`, so their reason is the
4468        /// class sentence alone, with no readings.
4469        #[test]
4470        fn a_shortfall_without_a_failed_attempt_carries_no_readings() {
4471            let reading = created(UsernsGrant::Withheld, ProfileAttachment::Unconfined);
4472            for shortfall in [
4473                sealed_shortfall(false, true, &reading).unwrap(),
4474                sealed_shortfall(true, false, &reading).unwrap(),
4475            ] {
4476                assert_eq!(shortfall.reason(), shortfall.blocker.reason());
4477            }
4478        }
4479
4480        /// The container case and the id-mapping case keep their own advice: a host that creates
4481        /// the namespace and refuses its map is not told to add a capability it is not missing,
4482        /// and a refused `pivot_root(2)` is not given the `mount(2)` remedy.
4483        #[test]
4484        fn each_blocker_keeps_its_own_remedy() {
4485            let container = SealedBlocker::NamespaceCreationDenied.reason();
4486            assert!(!container.contains("--cap-add"), "{container}");
4487            assert!(container.contains("seccomp profile"), "{container}");
4488            for call in [
4489                "unshare(CLONE_NEWUSER)",
4490                "setns",
4491                "mount",
4492                "umount2",
4493                "pivot_root",
4494            ] {
4495                assert!(container.contains(call), "{call}: {container}");
4496            }
4497            assert!(
4498                container.contains("--security-opt seccomp=<file>")
4499                    && container.contains("seccompProfile: Localhost"),
4500                "{container}"
4501            );
4502            assert!(container.contains("outside the container"), "{container}");
4503            let mount = SealedBlocker::MountDenied.reason();
4504            assert!(!mount.contains("--cap-add"), "{mount}");
4505            assert!(
4506                mount.contains("seccomp profile")
4507                    && mount.contains("mount, umount2 and pivot_root")
4508                    && mount.contains("--security-opt seccomp=<file>")
4509                    && mount.contains("apparmor_parser -r"),
4510                "{mount}"
4511            );
4512            let map = SealedBlocker::IdMapDenied.reason();
4513            assert!(map.contains("uid_map"), "{map}");
4514            assert!(!map.contains("--cap-add"), "{map}");
4515            let pivot = SealedBlocker::PivotRootDenied.reason();
4516            assert!(
4517                pivot.contains("pivot_root(2)") && pivot.contains("seccomp profile"),
4518                "{pivot}"
4519            );
4520            assert!(!pivot.contains("CAP_SYS_ADMIN"), "{pivot}");
4521            assert!(pivot.contains("seccompProfile: Localhost"), "{pivot}");
4522            assert_ne!(pivot, SealedBlocker::MountDenied.reason());
4523            let network = SealedBlocker::NetworkNamespaceUnavailable.reason();
4524            assert!(network.contains("E-CAP-005"), "{network}");
4525            let apparmor = SealedBlocker::AppArmorProfileMissing.reason();
4526            assert!(apparmor.contains("mur-sealed") && apparmor.contains("apparmor_parser -r"));
4527        }
4528    }
4529}