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