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