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