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