Skip to main content

capsule_runtime/
sealed.rs

1//! The `sealed` containment mechanism: a private mount namespace pivoted onto a composed root.
2//!
3//! `scoped` (Landlock + seccomp, see [`crate::sandbox`]) can deliver "nothing outside the workdir
4//! is readable or writable". It can never deliver "nothing outside the workdir *exists*", because
5//! Landlock is an access filter — it does not change what a process can see, name or traverse. A
6//! mount namespace can, and that is the whole of what this module adds.
7//!
8//! ## What the mechanism is
9//!
10//! Inside the forked child's `pre_exec` window — before fd hygiene, before the capability drop,
11//! before the seccomp filter, and before the Landlock ruleset — the child:
12//!
13//!   1. `unshare(CLONE_NEWUSER | CLONE_NEWNS)`, then maps its own uid/gid one-to-one into the new
14//!      user namespace (`/proc/self/uid_map`), which is what gives it `CAP_SYS_ADMIN` *inside that
15//!      namespace* and nowhere else. Nothing here needs the host's root.
16//!
17//!      On every kernel tier this now runs *after* [`crate::network_namespace`] has already
18//!      created a user+network namespace, so the one built here is **nested** inside it. That is
19//!      load-bearing rather than incidental: a task in a descendant user namespace holds no
20//!      capability in the ancestor, so a `sealed` capsule cannot reconfigure the network namespace
21//!      it is confined to.
22//!   2. Makes mount propagation private (`MS_REC | MS_PRIVATE` on `/`), so nothing it does leaks
23//!      back into the host's mount table.
24//!   3. Mounts a fresh `tmpfs` over a base directory ([`choose_root_base`]) and composes the new
25//!      root inside it: the host runtime directories ([`SEALED_RUNTIME_PATHS`]) and a fixed,
26//!      narrow `/etc` set ([`SEALED_ETC_PATHS`]) bind-mounted **read-only**, the session workdir
27//!      bind-mounted **read-write at its own absolute path**, a private `/dev` tmpfs carrying the
28//!      OCI default device set ([`SEALED_DEVICE_NODES`]), and a `/proc` — masked with `hidepid`
29//!      where the kernel permits it, bound from the host where it does not
30//!      ([`PROC_HIDEPID_OPTIONS`] documents which is which, and why).
31//!   4. `pivot_root`s onto it, detaches the old root, remounts the new root read-only, and
32//!      `chdir`s back into the workdir.
33//!
34//! Everything outside that root is then not merely access-denied — it is *absent*. There is no
35//! pathname for `/etc/shadow`, for a Docker socket, or for `/dev/sda`, so the escape classes that
36//! `scoped` has to enumerate and refuse (pathname unix sockets, device nodes, metadata visibility,
37//! stdlib directory enumeration) stop being reachable rather than being individually denied.
38//!
39//! `/proc` is the one exception to that sentence: mounting a private `procfs` unprivileged needs a
40//! PID namespace this module deliberately does not create, so on a bare host the composed root
41//! carries a bind of the host's `/proc` and
42//! host process metadata stays *visible* there (opens through it are still Landlock's to refuse).
43//! See [`PROC_HIDEPID_OPTIONS`].
44//!
45//! ## What it does *not* replace
46//!
47//! Landlock and seccomp still install afterwards, now scoped to the private root. `sealed` is
48//! defence in depth over `scoped`'s mechanism, not a substitute for it: the syscall allowlist is
49//! still the only thing standing between a capsule and `io_uring`, `bpf` or `ptrace`, none of
50//! which a mount namespace has an opinion about.
51//!
52//! ## Why the plan is precomputed
53//!
54//! Everything the child does is driven by [`SealedRootSpec`], a fully-resolved list of
55//! NUL-terminated paths built in the **parent**, before `fork()`. The `pre_exec` window permits
56//! only async-signal-safe work: allocating there (a `format!`, a `PathBuf::join`) can deadlock on
57//! the allocator lock a *different* thread of the parent held at fork time. Splitting the decision
58//! ([`plan_composed_root`], pure and unit-testable against a fake host layout) from the execution
59//! ([`construct_composed_root`], raw syscalls over already-built `CString`s) is what keeps the
60//! child side allocation-free on its success path — and it is what makes the composed-root layout
61//! testable at all on a host that cannot create a namespace.
62
63use std::path::{Path, PathBuf};
64
65// ---------------------------------------------------------------- AppArmor
66
67/// Name of the AppArmor profile this runtime ships, as it appears in
68/// `/sys/kernel/security/apparmor/profiles` and in `/proc/self/attr/current`.
69///
70/// On a host where AppArmor's `restrict_unprivileged_userns` knob is on (Ubuntu 23.10+ and
71/// derivatives), an unconfined process's `unshare(CLONE_NEWUSER)` is transitioned into the
72/// restricted `unprivileged_userns` profile, which denies `CAP_SYS_ADMIN` and therefore every
73/// `mount(2)` that follows. Loading this profile — which attaches to the `mur` binary and carries
74/// `userns,` — is what lifts that, exactly as Ubuntu's own shipped profiles do for Chrome and
75/// Firefox.
76pub const SEALED_APPARMOR_PROFILE_NAME: &str = "mur-sealed";
77
78/// Where [`scripts/install.sh`](../../../scripts/install.sh) installs the shipped profile, and the
79/// path the refusal message tells an operator to load.
80pub const SEALED_APPARMOR_PROFILE_PATH: &str = "/etc/apparmor.d/mur-sealed";
81
82// ---------------------------------------------------------------- blockers
83
84/// The one mechanism that stands between this host and `sealed`, named specifically enough that
85/// the refusal carries a command the operator can run.
86///
87/// Deliberately a small closed enum rather than a free-form string: `containment.rs` renders it,
88/// `sandbox.rs` derives it from the probe, and the manual-verification document asserts the exact
89/// text of each rendering.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)]
91#[serde(rename_all = "snake_case")]
92pub enum SealedBlocker {
93    /// Not Linux. Mount namespaces do not exist here and never will — the same permanence
94    /// `EnforcementTier::EnvironmentOnly` carries.
95    NotLinux,
96    /// AppArmor's unprivileged-user-namespace restriction is active and this binary is not
97    /// confined by the shipped [`SEALED_APPARMOR_PROFILE_NAME`] profile.
98    AppArmorProfileMissing,
99    /// `unshare(CLONE_NEWUSER | CLONE_NEWNS)` was refused outright — the container case: no
100    /// `CAP_SYS_ADMIN`, or the container's own seccomp filter blocking the syscall.
101    NamespaceCreationDenied,
102    /// The namespace was created, but writing the identity `uid_map`/`gid_map` was refused, so the
103    /// process cannot own it. Nothing to do with `CAP_SYS_ADMIN` or containers.
104    IdMapDenied,
105    /// The namespace was created, but `mount(2)` inside it was refused. The signature of a
106    /// confinement that permits `userns_create` and then denies `CAP_SYS_ADMIN`.
107    MountDenied,
108    /// The kernel has no unprivileged user namespace support at all (`CONFIG_USER_NS=n`, or
109    /// `user.max_user_namespaces=0`).
110    KernelUnsupported,
111    /// Namespaces are available but Landlock is not, so the defence-in-depth layer `sealed`
112    /// is required to keep would be missing. `sealed` is strictly stronger than `scoped`; a host
113    /// that cannot back `scoped` cannot back `sealed` either.
114    LandlockUnavailable,
115}
116
117impl SealedBlocker {
118    /// Every variant, so a caller that has to reason about "which refusal did this host give"
119    /// cannot silently miss one. Added because a hand-maintained list of the expected refusal
120    /// texts in `murmur-cli`'s CLI test drifted the moment a variant was added, and the drift
121    /// showed up as a failing assertion about the *host* rather than about the list.
122    pub const ALL: &'static [SealedBlocker] = &[
123        SealedBlocker::NotLinux,
124        SealedBlocker::AppArmorProfileMissing,
125        SealedBlocker::NamespaceCreationDenied,
126        SealedBlocker::IdMapDenied,
127        SealedBlocker::MountDenied,
128        SealedBlocker::KernelUnsupported,
129        SealedBlocker::LandlockUnavailable,
130    ];
131
132    /// One sentence naming the missing mechanism, plus the exact remediation. Rendered into
133    /// `RuntimeError::ContainmentFloorUnmet`'s `reason`, so this is the text an operator sees
134    /// under `E-CAP-003`.
135    #[must_use]
136    pub fn reason(self) -> String {
137        match self {
138            SealedBlocker::NotLinux => {
139                "sealed requires a Linux mount namespace + pivot_root; this \
140                 platform has no such primitive and never will (macOS and every non-Linux target \
141                 stay at advisory permanently)"
142                    .to_string()
143            }
144            SealedBlocker::AppArmorProfileMissing => format!(
145                "sealed requires an unprivileged user+mount namespace, and AppArmor's \
146                 unprivileged-userns restriction is active on this host while the '{name}' profile \
147                 is not confining this binary. Install and load the profile shipped with mur: \
148                 `sudo install -m 644 packaging/apparmor/{name} {path} && sudo apparmor_parser -r \
149                 {path}` (or re-run the mur installer as root), then re-run. To turn the \
150                 restriction off host-wide instead: `sudo sysctl -w \
151                 kernel.apparmor_restrict_unprivileged_userns=0`.",
152                name = SEALED_APPARMOR_PROFILE_NAME,
153                path = SEALED_APPARMOR_PROFILE_PATH,
154            ),
155            SealedBlocker::NamespaceCreationDenied => {
156                "sealed requires unshare(CLONE_NEWUSER | CLONE_NEWNS), which this host refused. \
157                 This is the usual answer inside a container: CAP_SYS_ADMIN is absent, or the \
158                 container's own seccomp filter blocks unshare(2). Either add `--cap-add \
159                 SYS_ADMIN` to the container invocation, or establish the mount namespace outside \
160                 the container and run mur inside it. The runtime will not fall back to a weaker \
161                 class."
162                    .to_string()
163            }
164            SealedBlocker::IdMapDenied => {
165                "sealed created a user+mount namespace, but writing the identity uid_map/gid_map \
166                 into it was refused, so the process cannot own the namespace it just made. This \
167                 is an id-mapping problem, not a missing capability: check that \
168                 /proc/sys/user/max_user_namespaces is non-zero, that no LSM policy blocks \
169                 uid_map writes for this binary, and that mur is not already running inside an \
170                 unmapped user namespace. The runtime will not fall back to a weaker class."
171                    .to_string()
172            }
173            SealedBlocker::MountDenied => format!(
174                "sealed created a user+mount namespace but mount(2) inside it was refused, which \
175                 is what a confinement that permits userns_create and then denies CAP_SYS_ADMIN \
176                 looks like. On an AppArmor host, load the shipped profile: `sudo apparmor_parser \
177                 -r {path}`. Inside a container, add `--cap-add SYS_ADMIN` to the container \
178                 invocation, or establish the mount namespace outside the container.",
179                path = SEALED_APPARMOR_PROFILE_PATH,
180            ),
181            SealedBlocker::KernelUnsupported => {
182                "sealed requires unprivileged user namespaces, which this kernel does not provide \
183                 (CONFIG_USER_NS=n, or user.max_user_namespaces=0). Raise \
184                 `sudo sysctl -w user.max_user_namespaces=10000` if the sysctl is merely zeroed, \
185                 otherwise run on a kernel built with CONFIG_USER_NS=y."
186                    .to_string()
187            }
188            SealedBlocker::LandlockUnavailable => {
189                "sealed keeps Landlock and seccomp inside the composed root as defence in depth, \
190                 and this host provides no usable Landlock ABI (Linux 5.13+ required). A host that \
191                 cannot back scoped cannot back sealed either."
192                    .to_string()
193            }
194        }
195    }
196}
197
198// ---------------------------------------------------------------- host probe result
199
200/// Outcome of really trying to create a user+mount namespace in a forked child.
201///
202/// The four states are not interchangeable: `Denied` and `MountDenied` both mean "no sealed here"
203/// but point at completely different remediations (container capability vs. AppArmor profile),
204/// which is the entire reason this is not a `bool`.
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
206pub(crate) enum NamespaceProbe {
207    /// `unshare` succeeded and a `mount(2)` inside the new namespace succeeded.
208    Ok,
209    /// `unshare` itself was refused (`EPERM`).
210    Denied,
211    /// `unshare` succeeded, but writing the identity `uid_map`/`gid_map` did not — the namespace
212    /// exists and cannot be owned. Distinct from [`Denied`](Self::Denied) because the remediation
213    /// is completely different: `Denied` points at `CAP_SYS_ADMIN` and container flags, while this
214    /// points at id-mapping policy. Collapsing the two sent an operator hunting a container
215    /// problem on a host that had created the namespace perfectly well.
216    MapDenied,
217    /// `unshare` succeeded, `mount(2)` inside the namespace did not.
218    MountDenied,
219    /// The kernel does not implement it (`ENOSYS`/`EINVAL`), or the probe could not run at all.
220    #[default]
221    Unsupported,
222}
223
224/// Everything the host probe learned about `sealed`, kept separate from the decision that uses it
225/// so `sandbox::tier_from_probe` stays pure.
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
227pub(crate) struct SealedProbe {
228    /// AppArmor does not stand between this binary and an unprivileged user namespace: either its
229    /// `restrict_unprivileged_userns` knob is off (or AppArmor is absent entirely), or the shipped
230    /// [`SEALED_APPARMOR_PROFILE_NAME`] profile is confining this process.
231    ///
232    /// Named for the question that actually matters rather than for "is the profile loaded",
233    /// because a Fedora or Arch host with no AppArmor at all needs no profile and must not be
234    /// refused for lacking one.
235    pub(crate) apparmor_permits_userns: bool,
236    /// What a real `unshare` + `mount` attempt in a forked child did.
237    pub(crate) namespace: NamespaceProbe,
238}
239
240/// Which single mechanism to blame, given the probe. Pure; the ordering is the order an operator
241/// has to fix things in.
242///
243/// `None` means every precondition holds and this host can back `sealed`.
244pub(crate) fn sealed_blocker(
245    is_linux: bool,
246    landlock_fully_enforced: bool,
247    probe: SealedProbe,
248) -> Option<SealedBlocker> {
249    if !is_linux {
250        return Some(SealedBlocker::NotLinux);
251    }
252    // Attribute to AppArmor before the namespace outcome: when the restriction is on and the
253    // shipped profile is not loaded, the namespace failure is a *consequence*, and naming
254    // `--cap-add SYS_ADMIN` on a bare host sends the operator somewhere useless.
255    if !probe.apparmor_permits_userns {
256        return Some(SealedBlocker::AppArmorProfileMissing);
257    }
258    match probe.namespace {
259        NamespaceProbe::Ok => {
260            if landlock_fully_enforced {
261                None
262            } else {
263                Some(SealedBlocker::LandlockUnavailable)
264            }
265        }
266        NamespaceProbe::Denied => Some(SealedBlocker::NamespaceCreationDenied),
267        NamespaceProbe::MapDenied => Some(SealedBlocker::IdMapDenied),
268        NamespaceProbe::MountDenied => Some(SealedBlocker::MountDenied),
269        NamespaceProbe::Unsupported => Some(SealedBlocker::KernelUnsupported),
270    }
271}
272
273// ---------------------------------------------------------------- the composed root, declared
274
275/// Host directories bind-mounted read-only into every composed root, in order.
276///
277/// Fixed, not derived: a whole-tree bind of `/usr` plus the non-usrmerge spellings of the same
278/// tree. On a usrmerge distro `/bin`, `/sbin`, `/lib`,
279/// `/lib64`… are *symlinks* into `/usr`; [`plan_composed_root`] recreates them as symlinks rather
280/// than bind-mounting through them, so the composed root has the same shape as the host and a
281/// hashbang like `#!/bin/sh` resolves.
282///
283/// Deliberately a whole-directory bind rather than a curated per-file set: the point of `sealed`
284/// is that it *deletes* the ELF-closure derivation `scoped` needs, along with its lock-time
285/// pinning problem. `capabilities.shell.staged_runtime` is **additive** to this list, not a
286/// replacement for it: a declared runtime tree arrives through
287/// [`plan_composed_root`]'s own `staged_runtime_read_only` parameter, as a *required* bind, while
288/// this fixed list stays exactly as it is. Anything a `shell.allow` binary needs at runtime must
289/// therefore be under one of these, under a staged runtime tree, or under a directory derived
290/// from the manifest — see `extra_read_only` in [`plan_composed_root`].
291pub const SEALED_RUNTIME_PATHS: &[&str] = &[
292    "/usr", "/bin", "/sbin", "/lib", "/lib32", "/lib64", "/libx32",
293];
294
295/// One entry of the fixed `/etc` allowlist: the host path to bind, plus whether it is a directory.
296///
297/// The `directory` bit exists because the same list drives two mechanisms with different needs.
298/// [`plan_composed_root`] does not need it — it asks the host what each path is via
299/// [`HostLayout::kind`] and mirrors accordingly. `sandbox::resolve_sealed_etc_landlock_grants` does:
300/// it must decide `ReadDir` per entry, and it is required to be pure and syscall-free (it runs on
301/// any platform, for paths that need not exist). Carrying the classification *in the list* rather
302/// than in a second parallel constant is what makes the bound set and the granted set structurally
303/// incapable of drifting apart — the same reason [`SealedDevice`] carries its own `writable` bit
304/// rather than deriving it from a separate table.
305///
306/// It is a static claim about mainstream Linux, not a fact read off this host: `/etc/ssl` and
307/// friends are directories on Debian, Ubuntu, Fedora, RHEL, Arch and Alpine alike. A host that
308/// disagrees costs the entry its enumerability and nothing else — `open_landlock_fds` narrows
309/// `list_dir` back to `false` for anything that does not `fstat` as a directory, because the kernel
310/// rejects (`EINVAL`) a `ReadDir` rule on a non-directory and that would otherwise refuse the whole
311/// launch.
312#[derive(Debug, Clone, Copy, PartialEq, Eq)]
313pub struct SealedEtcPath {
314    /// Absolute path inside the composed root. Also the host path bind-mounted there, except for
315    /// an entry carrying [`Self::synthetic`], whose content the parent writes itself.
316    pub path: &'static str,
317    /// Whether this path is a directory on a mainstream Linux host, and therefore whether its
318    /// Landlock rule carries `ReadDir`. `false` for regular files and for symlinks (the symlink is
319    /// followed when the rule's fd is opened, so `/etc/localtime`'s rule lands on the zoneinfo file
320    /// it points at).
321    pub directory: bool,
322    /// `Some` when [`Self::path`] is backed by a file the *parent* synthesises rather than by the
323    /// host's own file at that path — the account databases, and nothing else.
324    ///
325    /// Carried on the entry for the same reason as `directory`: the alternative is a second
326    /// parallel list of "the ones we do not mirror", which is free to drift out of step with this
327    /// one. Typed rather than a `bool` so the content each synthetic entry gets is decided by an
328    /// exhaustive `match` — adding a third one is then a compile error until it is given content,
329    /// instead of a launch-time failure to bind a file nobody wrote.
330    pub synthetic: Option<SyntheticEtcFile>,
331}
332
333/// An `/etc` file a composed root gets in synthesised form instead of the host's.
334///
335/// Both are account databases, and both are world-readable on every distribution — so binding the
336/// host's copy hands the capsule the machine's full account list for no benefit. What a capsule
337/// actually needs from them is that `getpwuid(3)`/`getgrgid(3)` resolve *its own* id: a shell's `~`
338/// expansion, `os.path.expanduser`, `whoami`, and anything reading `$HOME` through the password
339/// database. Two entries — `root` and the id the capsule runs as — satisfy all of that.
340#[derive(Debug, Clone, Copy, PartialEq, Eq)]
341pub enum SyntheticEtcFile {
342    /// `/etc/passwd`, in the seven colon-separated fields of `passwd(5)`.
343    Passwd,
344    /// `/etc/group`, in the four colon-separated fields of `group(5)`.
345    Group,
346}
347
348/// The uid/gid a capsule's subprocesses run as, and the `HOME` they are given.
349///
350/// Read off the session workdir the parent created rather than from `getuid(2)`: this crate is
351/// `#![deny(unsafe_code)]` and the workdir's owner *is* the spawning process's identity, so
352/// `std::fs::metadata(workdir).uid()` answers the question in safe std — the same pattern
353/// `crate::resources` already uses.
354#[derive(Debug, Clone, Copy, PartialEq, Eq)]
355pub(crate) struct SealedAccountIdentity<'a> {
356    pub(crate) uid: u32,
357    pub(crate) gid: u32,
358    /// The synthetic `HOME`, exactly as `crate::shell::build_shell_env` sets it in the subprocess
359    /// environment. Byte-identical or the whole exercise is pointless: a `pw_dir` that disagrees
360    /// with `$HOME` is worse than no entry at all, because tools silently pick one of the two.
361    pub(crate) home: &'a str,
362}
363
364/// Login shell recorded for both synthetic accounts. Present in the composed root
365/// (`/bin` is on [`SEALED_RUNTIME_PATHS`]) and *not* thereby made runnable — the exec allowlist is
366/// Landlock's, and a shell field is a string in a database, not a grant.
367const SYNTHETIC_LOGIN_SHELL: &str = "/bin/sh";
368
369/// Account and group name for the capsule's own entry. Deliberately not the host's login name:
370/// that name is itself part of the account list this file exists to stop leaking.
371const SYNTHETIC_ACCOUNT_NAME: &str = "capsule";
372
373impl SyntheticEtcFile {
374    /// File name under [`SEALED_ETC_STAGING_DIR_NAME`]. The basename of the target path, so the
375    /// staging directory reads like the `/etc` it stands in for.
376    pub(crate) fn file_name(self) -> &'static str {
377        match self {
378            Self::Passwd => "passwd",
379            Self::Group => "group",
380        }
381    }
382
383    /// The exact bytes written to the staging file, for one capsule identity.
384    ///
385    /// At most two lines: `root`, plus the capsule's own id when that is not already 0. A capsule
386    /// running as root gets **one** line — the `root` line, with `$HOME` as its home directory —
387    /// rather than a duplicate entry for uid 0, which `getpwuid(3)` would resolve to whichever came
388    /// first.
389    ///
390    /// Fails rather than emits a corrupt database when the home path contains a field separator or
391    /// a newline: `passwd(5)` has no escaping, so a workdir named with a `:` would silently shift
392    /// every field after it. Parent-side and synchronous, so it surfaces as a named launch failure.
393    pub(crate) fn render(self, identity: &SealedAccountIdentity) -> Result<String, String> {
394        let SealedAccountIdentity { uid, gid, home } = *identity;
395        match self {
396            Self::Passwd => {
397                if home.contains(':') || home.contains('\n') {
398                    return Err(format!(
399                        "sealed: the synthetic HOME {home} contains a character /etc/passwd cannot \
400                         quote (':' or a newline), so no valid passwd entry can name it"
401                    ));
402                }
403                // name:password:uid:gid:gecos:home:shell — `x` in the password field is the
404                // universal "look in shadow", and there is no shadow file in a composed root.
405                if uid == 0 {
406                    return Ok(format!(
407                        "root:x:0:{gid}:root:{home}:{SYNTHETIC_LOGIN_SHELL}\n"
408                    ));
409                }
410                Ok(format!(
411                    "root:x:0:0:root:/root:{SYNTHETIC_LOGIN_SHELL}\n\
412                     {SYNTHETIC_ACCOUNT_NAME}:x:{uid}:{gid}:Murmur capsule:{home}:\
413                     {SYNTHETIC_LOGIN_SHELL}\n"
414                ))
415            }
416            // name:password:gid:members — the member list is empty because membership of the
417            // capsule's own group comes from its passwd entry's gid field.
418            Self::Group => {
419                if gid == 0 {
420                    return Ok("root:x:0:\n".to_string());
421                }
422                Ok(format!("root:x:0:\n{SYNTHETIC_ACCOUNT_NAME}:x:{gid}:\n"))
423            }
424        }
425    }
426}
427
428/// The only `/etc` entries that exist inside a composed root, each bind-mounted read-only and each
429/// silently skipped when the host does not have it.
430///
431/// An allowlist, never `/etc` wholesale, and never a denylist of sensitive names: `/etc/shadow`,
432/// `/etc/sudoers`, `/etc/ssh`, cloud-init credentials and every future addition to `/etc` are
433/// absent by construction rather than by enumeration. The entries here are the ones without which
434/// ordinary tooling misreports rather than fails cleanly — the dynamic loader's cache, the TLS
435/// trust store, DNS configuration, the timezone, and the passwd/group databases `getpwuid(3)`
436/// needs.
437///
438/// `/etc/passwd` and `/etc/group` are the two entries the host does *not* supply. They are
439/// world-readable on every distribution, so binding the host's copies would hand the capsule the
440/// machine's full account list; instead the parent writes a two-line database naming only `root`
441/// and the capsule's own id, and the composed root binds *that* at the same path, read-only. See
442/// [`SyntheticEtcFile`]. It stays on this list because the list is also the grant list — an entry
443/// removed from here loses its Landlock rule and becomes unreadable, which is the opposite of what
444/// this narrowing is for.
445///
446/// Binding these is only half the job: Landlock still mediates *inside* the composed root, so an
447/// entry bound here but absent from the ruleset exists and is unopenable — a trust store present
448/// at `/etc/ssl/certs/ca-certificates.crt` but `EACCES` on open fails TLS verification.
449/// `sandbox::resolve_sealed_etc_landlock_grants` turns this list into the matching read grants.
450/// Keep the two sets aligned. A Landlock rule names the *inode* an fd resolved to, not the path
451/// string it was opened by, so an entry whose bind source is not the host path of the same name
452/// needs its rule taken on the file actually bound — see `sandbox::LandlockChildFds`'s
453/// `sealed_identity_fds`, which does that for the two synthetic entries.
454pub const SEALED_ETC_PATHS: &[SealedEtcPath] = &[
455    SealedEtcPath {
456        path: "/etc/ld.so.cache",
457        directory: false,
458        synthetic: None,
459    },
460    SealedEtcPath {
461        path: "/etc/ld.so.conf",
462        directory: false,
463        synthetic: None,
464    },
465    SealedEtcPath {
466        path: "/etc/ld.so.conf.d",
467        directory: true,
468        synthetic: None,
469    },
470    SealedEtcPath {
471        path: "/etc/alternatives",
472        directory: true,
473        synthetic: None,
474    },
475    SealedEtcPath {
476        path: "/etc/ssl",
477        directory: true,
478        synthetic: None,
479    },
480    SealedEtcPath {
481        path: "/etc/pki",
482        directory: true,
483        synthetic: None,
484    },
485    SealedEtcPath {
486        path: "/etc/ca-certificates",
487        directory: true,
488        synthetic: None,
489    },
490    SealedEtcPath {
491        path: "/etc/ca-certificates.conf",
492        directory: false,
493        synthetic: None,
494    },
495    SealedEtcPath {
496        path: "/etc/resolv.conf",
497        directory: false,
498        synthetic: None,
499    },
500    SealedEtcPath {
501        path: "/etc/hosts",
502        directory: false,
503        synthetic: None,
504    },
505    SealedEtcPath {
506        path: "/etc/nsswitch.conf",
507        directory: false,
508        synthetic: None,
509    },
510    SealedEtcPath {
511        path: "/etc/localtime",
512        directory: false,
513        synthetic: None,
514    },
515    SealedEtcPath {
516        path: "/etc/timezone",
517        directory: false,
518        synthetic: None,
519    },
520    SealedEtcPath {
521        path: "/etc/terminfo",
522        directory: true,
523        synthetic: None,
524    },
525    SealedEtcPath {
526        path: "/etc/passwd",
527        directory: false,
528        synthetic: Some(SyntheticEtcFile::Passwd),
529    },
530    SealedEtcPath {
531        path: "/etc/group",
532        directory: false,
533        synthetic: Some(SyntheticEtcFile::Group),
534    },
535];
536
537/// One entry of the private `/dev` tmpfs.
538///
539/// `major`/`minor`/`mode` are recorded for the record and for the manual-verification document to
540/// assert against — they are **not** used to create the node. Device nodes cannot be `mknod`ed
541/// from inside a non-initial user namespace at all (the kernel refuses regardless of
542/// `CAP_MKNOD`), so each entry is created as an empty file in the tmpfs and the host's node is
543/// bind-mounted over it. This is what bubblewrap and rootless podman do, and it means the numbers
544/// a capsule actually sees are the host's — which on every mainstream distribution are exactly the
545/// ones below.
546#[derive(Debug, Clone, Copy, PartialEq, Eq)]
547pub struct SealedDevice {
548    /// Name under `/dev`, without the directory.
549    pub name: &'static str,
550    /// The host path bind-mounted over the tmpfs entry — `/dev/` + [`Self::name`], spelled out
551    /// because it is also what the composed root's Landlock rule set is built from.
552    pub path: &'static str,
553    pub major: u32,
554    pub minor: u32,
555    pub mode: u32,
556    /// Whether the composed root's Landlock rule for this device carries `WriteFile` on top of
557    /// `ReadFile`.
558    ///
559    /// Landlock still mediates inside a composed root — that is the whole point of keeping it as
560    /// defence in depth — so a device present in `/dev` but absent from the rule set would exist
561    /// and be unopenable, which is worse than not being there. The read-only pair is the two
562    /// entropy sources, where nothing legitimate writes; the rest carry write because their write
563    /// side is defined to discard (`null`, `zero`), to report `ENOSPC` (`full`), or to be a
564    /// terminal (`tty`).
565    pub writable: bool,
566}
567
568/// The OCI runtime-spec default device set, which is what a `sealed` capsule's `/dev` contains and
569/// all it contains.
570///
571/// This is deliberately *independent* of `sandbox::CAPSULE_DEVICE_GRANTS`, the three-device
572/// Landlock grant set that services `scoped`. That set is a rule list layered over the host's real
573/// `/dev`; this one is the entire contents of a private tmpfs. They answer different questions and
574/// neither should be derived from the other.
575///
576/// `/dev/console` is omitted (the OCI spec creates it only for a terminal-attached container, and
577/// a capsule subprocess has pipes, not a controlling terminal). `/dev/ptmx` and `/dev/pts` come
578/// from a `devpts` mount rather than from this list, and `/dev/shm` is omitted deliberately: it is
579/// writable, and the session workdir is the only writable path in a composed root.
580pub const SEALED_DEVICE_NODES: &[SealedDevice] = &[
581    SealedDevice {
582        name: "null",
583        path: "/dev/null",
584        major: 1,
585        minor: 3,
586        mode: 0o666,
587        writable: true,
588    },
589    SealedDevice {
590        name: "zero",
591        path: "/dev/zero",
592        major: 1,
593        minor: 5,
594        mode: 0o666,
595        writable: true,
596    },
597    SealedDevice {
598        name: "full",
599        path: "/dev/full",
600        major: 1,
601        minor: 7,
602        mode: 0o666,
603        writable: true,
604    },
605    SealedDevice {
606        name: "random",
607        path: "/dev/random",
608        major: 1,
609        minor: 8,
610        mode: 0o666,
611        writable: false,
612    },
613    SealedDevice {
614        name: "urandom",
615        path: "/dev/urandom",
616        major: 1,
617        minor: 9,
618        mode: 0o666,
619        writable: false,
620    },
621    SealedDevice {
622        name: "tty",
623        path: "/dev/tty",
624        major: 5,
625        minor: 0,
626        mode: 0o666,
627        writable: true,
628    },
629];
630
631/// The OCI default `/dev` symlinks, `(link name under /dev, target)`.
632pub const SEALED_DEVICE_SYMLINKS: &[(&str, &str)] = &[
633    ("fd", "/proc/self/fd"),
634    ("stdin", "/proc/self/fd/0"),
635    ("stdout", "/proc/self/fd/1"),
636    ("stderr", "/proc/self/fd/2"),
637    ("ptmx", "pts/ptmx"),
638];
639
640/// Directory created inside the session workdir and bind-mounted at `/tmp` in the composed root.
641///
642/// `/tmp` has to exist — bash heredocs, `mktemp`, compilers and package managers all fail without
643/// it in ways that read as runtime bugs rather than as policy. Backing it with a directory *inside
644/// the workdir* is what keeps "the session workdir is the only writable path" literally true: the
645/// bytes written to `/tmp` land in the workdir, are counted by the existing workdir-size guard
646/// (`capabilities.resources.workdir_max_bytes`), and are discarded with the session.
647pub const SEALED_TMP_DIR_NAME: &str = ".mur-tmp";
648
649/// Directory created inside the session workdir holding the synthetic `/etc` files, each of which
650/// the composed root binds read-only over its [`SEALED_ETC_PATHS`] target.
651///
652/// Inside the workdir for the same reason as [`SEALED_TMP_DIR_NAME`]: it is the one host directory
653/// the parent already owns, already creates per session, and already discards with it. The capsule
654/// can read — and, since the workdir is its one writable path, rewrite — its own copy here; that
655/// buys it nothing, because the file is never read by anything outside the capsule and names no
656/// account the capsule is not already running as.
657pub const SEALED_ETC_STAGING_DIR_NAME: &str = ".mur-etc";
658
659/// Host path of the staging file backing one synthetic `/etc` entry.
660///
661/// The single place this path is composed. Three callers need to agree on it byte for byte — the
662/// planner that binds it, the parent that writes it, and the Landlock fd that grants it — and two
663/// of them run in different phases of the launch.
664pub(crate) fn synthetic_etc_source(workdir: &Path, file: SyntheticEtcFile) -> PathBuf {
665    workdir
666        .join(SEALED_ETC_STAGING_DIR_NAME)
667        .join(file.file_name())
668}
669
670/// Directory the old root is parked in between `pivot_root(2)` and `umount2(MNT_DETACH)`.
671/// Removed immediately afterwards, so it does not exist for any process the capsule can run.
672const OLD_ROOT_NAME: &str = ".mur-oldroot";
673
674/// Base directories the new-root `tmpfs` may be mounted over, most preferred first.
675///
676/// The base is overmounted inside the private namespace, so whatever the host has there becomes
677/// unreachable for the rest of the setup — which is why the chosen base must not be an ancestor of
678/// the session workdir (a workdir under `/tmp` is entirely normal) and must not be one of the
679/// directories the composed root goes on to bind-mount from.
680pub const SEALED_ROOT_BASE_CANDIDATES: &[&str] = &["/tmp", "/run", "/var/tmp", "/mnt", "/media"];
681
682/// `hidepid` spellings tried, in order, when mounting the composed root's `/proc` with
683/// `mount -t proc`. The numeric form is the legacy parser's; `invisible` is the Linux 5.8+
684/// spelling; the empty string is a plain private `procfs` with no masking.
685///
686/// **On a bare host none of the three succeeds, and that is a kernel rule.**
687/// `proc_fill_super` requires `CAP_SYS_ADMIN` over the user namespace *owning the PID namespace*.
688/// `unshare(CLONE_NEWUSER | CLONE_NEWNS)` leaves the process in the host's initial PID namespace,
689/// whose owner is the initial user namespace — where an unprivileged process has no capabilities
690/// at all — so every `mount -t proc` returns `EPERM`. Adding `CLONE_NEWPID` does not help either:
691/// `unshare` moves only *future children* into the new PID namespace, so the mounting process is
692/// still judged against the old one. Mounting a private `procfs` unprivileged genuinely requires
693/// forking so the child becomes PID 1, which changes reaping and signal semantics for the whole
694/// capsule subprocess tree and is out of scope here (see the PID-namespace note in
695/// `docs/content/reference/sealed-containment-manual-verification.md`).
696///
697/// They are still tried first, and in this order, because they *do* succeed where the process has
698/// that privilege — as root, and inside a container run with `--cap-add SYS_ADMIN` — and because
699/// a later PID-namespace slice makes them succeed everywhere without touching this list.
700///
701/// When they all fail the executor binds the host's `/proc` instead. The capsule can then
702/// enumerate host PIDs and read the `/proc/<pid>/root` and `/proc/<pid>/cwd` symlinks, so `/proc`
703/// is the one part of the composed root where "outside does not exist" degrades to `scoped`'s
704/// "outside is denied" — Landlock's ruleset covers no path under `/proc`, so opens through it are
705/// refused, and `ptrace_may_access` gates the rest. Every other axis of the root stays absolute. The alternative is a capsule with no `/proc` at all, which
706/// breaks `/dev/fd`, process substitution and every runtime that reads `/proc/self/*`.
707const PROC_HIDEPID_OPTIONS: &[&str] = &["hidepid=2", "hidepid=invisible", ""];
708
709// ---------------------------------------------------------------- host layout (test seam)
710
711/// What a path is on the host, as far as composing a root cares.
712#[derive(Debug, Clone, PartialEq, Eq)]
713pub(crate) enum PathKind {
714    Dir,
715    /// Any non-directory: a regular file, a device node, a socket. All are bind-mount targets that
716    /// need an empty *file* created for them rather than a directory.
717    File,
718    /// A symlink, with its literal (un-resolved) target.
719    Symlink(PathBuf),
720}
721
722/// The host filesystem facts [`plan_composed_root`] reads. A trait purely so the planner can be
723/// unit-tested against a synthetic usrmerge / non-usrmerge layout on any machine — production has
724/// exactly one implementation, [`RealHostLayout`].
725pub(crate) trait HostLayout {
726    fn kind(&self, path: &Path) -> Option<PathKind>;
727}
728
729/// The real filesystem, read with `symlink_metadata` so a usrmerge `/bin -> usr/bin` reports as a
730/// symlink rather than as the directory it points at.
731pub(crate) struct RealHostLayout;
732
733impl HostLayout for RealHostLayout {
734    fn kind(&self, path: &Path) -> Option<PathKind> {
735        let meta = std::fs::symlink_metadata(path).ok()?;
736        if meta.file_type().is_symlink() {
737            return Some(PathKind::Symlink(std::fs::read_link(path).ok()?));
738        }
739        if meta.is_dir() {
740            return Some(PathKind::Dir);
741        }
742        Some(PathKind::File)
743    }
744}
745
746// ---------------------------------------------------------------- the plan
747
748/// One filesystem operation in the composed-root plan.
749#[derive(Debug, Clone, PartialEq, Eq)]
750pub(crate) enum RootOp {
751    /// `mkdir(path, 0755)`. `EEXIST` is success.
752    MkDir(PathBuf),
753    /// Create an empty regular file, to be bind-mounted over.
754    MkFile(PathBuf),
755    /// `symlink(target, link)` — used to reproduce the host's usrmerge shape and the OCI `/dev`
756    /// symlinks.
757    Symlink { target: PathBuf, link: PathBuf },
758    /// `mount(source, target, MS_BIND | MS_REC)`, followed by a second
759    /// `MS_REMOUNT | MS_BIND | MS_RDONLY` call when `read_only`. The second call is required: a
760    /// single `MS_BIND | MS_RDONLY` mount does *not* produce a read-only bind.
761    Bind {
762        source: PathBuf,
763        target: PathBuf,
764        read_only: bool,
765    },
766    /// A fresh `tmpfs`.
767    Tmpfs {
768        target: PathBuf,
769        options: &'static str,
770    },
771    /// A `/proc`: a fresh `procfs` masked with `hidepid` where the kernel permits one, a bind of
772    /// the host's where it does not. Which of the two a host gets is decided at execution time, not
773    /// here — see [`PROC_HIDEPID_OPTIONS`].
774    Proc { target: PathBuf },
775    /// A fresh `devpts` instance, so the capsule can allocate its own ptys without seeing the
776    /// host's.
777    DevPts { target: PathBuf },
778    /// `mount(NULL, target, NULL, MS_REMOUNT | MS_BIND | MS_RDONLY)` — used to seal `/dev` after
779    /// its device nodes have been bound into it.
780    RemountReadOnly(PathBuf),
781}
782
783/// One planned operation plus whether failing it aborts the launch.
784///
785/// `required: false` is the "shrink, do not fail" convention this crate already uses for optional
786/// device grants in `sandbox::open_landlock_fds`: losing an optional bind makes the composed root
787/// *narrower*, never wider, so refusing the launch over it would trade a working capsule for no
788/// security gain.
789#[derive(Debug, Clone, PartialEq, Eq)]
790pub(crate) struct RootStep {
791    pub(crate) op: RootOp,
792    pub(crate) required: bool,
793}
794
795/// The full, host-resolved recipe for one composed root.
796#[derive(Debug, Clone, PartialEq, Eq)]
797pub(crate) struct ComposedRootPlan {
798    /// Directory the new-root `tmpfs` is mounted over, and the directory `pivot_root` is handed.
799    pub(crate) base: PathBuf,
800    pub(crate) steps: Vec<RootStep>,
801    /// The workdir's path *inside* the composed root. Identical to its host path by design: env
802    /// vars, `PWD`, log paths and anything a previous turn recorded keep working, and the capsule
803    /// never has to be told it moved.
804    pub(crate) workdir_in_root: PathBuf,
805}
806
807/// Picks the directory the new-root `tmpfs` is mounted over.
808///
809/// Rejects any candidate that is the workdir or an ancestor of it — overmounting an ancestor of
810/// the workdir would hide the very directory the plan then has to bind-mount — and any candidate
811/// the host does not have. Pure apart from the injected `exists` predicate.
812pub(crate) fn choose_root_base(
813    workdir: &Path,
814    candidates: &[&str],
815    exists: impl Fn(&Path) -> bool,
816) -> Option<PathBuf> {
817    candidates
818        .iter()
819        .map(Path::new)
820        .find(|candidate| !workdir.starts_with(candidate) && exists(candidate))
821        .map(Path::to_path_buf)
822}
823
824/// Builds the ordered operation list for one composed root. Pure with respect to `host`.
825///
826/// `extra_read_only` carries the host directories this *particular* capsule needs beyond
827/// [`SEALED_RUNTIME_PATHS`] — the containing directory of each resolved `shell.allow` binary and
828/// each `capabilities.shell.interpreter_runtime` directory. They are whole-directory binds, not a
829/// per-file grant set: a bind carries a directory's whole contents, so this must not grow into a
830/// second ELF-closure derivation. Entries already covered by a fixed path are dropped.
831///
832/// `staged_runtime_read_only` carries the `source_path` of each declared
833/// `capabilities.shell.staged_runtime` grant. It is a *separate* parameter from `extra_read_only`
834/// rather than more entries in it, because the two have opposite failure semantics and the
835/// difference is the whole point of the capability. See [`PlanBuilder::require_bind`].
836pub(crate) fn plan_composed_root(
837    workdir: &Path,
838    base: &Path,
839    extra_read_only: &[PathBuf],
840    staged_runtime_read_only: &[PathBuf],
841    host: &dyn HostLayout,
842) -> ComposedRootPlan {
843    let mut builder = PlanBuilder::new(base);
844
845    // 0. Declared `staged_runtime` trees, read-only and REQUIRED — before everything else.
846    //
847    //    Ordering is load-bearing, not tidiness. `mirror` below deduplicates by target path via
848    //    the `made` set, so whichever loop registers a target first wins it and any later loop
849    //    silently no-ops on the same target. Running the required binds first means a staged path
850    //    that happens to collide with an optional one can only ever be *upgraded* to required,
851    //    never silently downgraded to optional.
852    //
853    //    These are also planned ahead of /dev, /proc, /tmp and the workdir bind — and therefore
854    //    ahead of `pivot_root`, which `construct_composed_root` performs only after every planned
855    //    step has run. A capsule never reaches a shell tool call inside a root that is missing a
856    //    runtime tree it declared.
857    for path in staged_runtime_read_only {
858        builder.require_bind(path);
859    }
860
861    // 1. The host runtime tree, read-only. A usrmerge symlink is reproduced as a symlink so the
862    //    composed root has the host's shape; a real directory is bind-mounted.
863    for path in SEALED_RUNTIME_PATHS
864        .iter()
865        .map(Path::new)
866        .chain(extra_read_only.iter().map(PathBuf::as_path))
867    {
868        builder.mirror(path, host, /* required */ false);
869    }
870
871    // 2. The narrow /etc allowlist, read-only. Everything else under /etc simply does not exist.
872    //
873    //    An entry marked synthetic is not mirrored from the host at all: its target is bound from
874    //    the staging file the parent wrote inside the workdir, exactly as /tmp is in step 5 below.
875    //    `required: true` there, unlike the host-mirrored entries' `required: false` — those are
876    //    optional because a distribution may genuinely not have them, whereas the parent wrote
877    //    these two itself, so a bind that fails is a broken runtime rather than a narrower one, and
878    //    the empty `MkFile` left in its place would be a passwd database with no accounts in it.
879    for entry in SEALED_ETC_PATHS {
880        let path = Path::new(entry.path);
881        match entry.synthetic {
882            None => builder.mirror(path, host, /* required */ false),
883            Some(file) => builder.bind_file(
884                synthetic_etc_source(workdir, file),
885                rebase(base, path),
886                /* required */ true,
887            ),
888        }
889    }
890
891    // 3. A private /dev tmpfs carrying the OCI default device set, sealed read-only once it is
892    //    populated. Device nodes are bind-mounted from the host because `mknod` of a device is
893    //    refused inside a user namespace — see `SealedDevice`.
894    let dev = base.join("dev");
895    builder.mkdir_p(&dev);
896    builder.push(
897        RootOp::Tmpfs {
898            target: dev.clone(),
899            options: "mode=0755,size=1m",
900        },
901        true,
902    );
903    for device in SEALED_DEVICE_NODES {
904        let source = PathBuf::from(device.path);
905        if host.kind(&source).is_none() {
906            continue;
907        }
908        let target = dev.join(device.name);
909        builder.push(RootOp::MkFile(target.clone()), false);
910        builder.push(
911            RootOp::Bind {
912                source,
913                target,
914                read_only: false,
915            },
916            false,
917        );
918    }
919    let pts = dev.join("pts");
920    builder.push(RootOp::MkDir(pts.clone()), false);
921    builder.push(RootOp::DevPts { target: pts }, false);
922    for (link, target) in SEALED_DEVICE_SYMLINKS {
923        builder.push(
924            RootOp::Symlink {
925                target: PathBuf::from(*target),
926                link: dev.join(link),
927            },
928            false,
929        );
930    }
931    builder.push(RootOp::RemountReadOnly(dev), false);
932
933    // 4. /proc — masked with hidepid where the kernel allows it, bound from the host where it does
934    //    not. See `PROC_HIDEPID_OPTIONS`.
935    let proc = base.join("proc");
936    builder.push(RootOp::MkDir(proc.clone()), true);
937    builder.push(RootOp::Proc { target: proc }, true);
938
939    // 5. /tmp, backed by a directory inside the workdir so it stays inside the one writable path
940    //    and inside the workdir size budget.
941    //
942    //    Before the workdir, not after, and the ordering is load-bearing rather than tidy. A
943    //    session workdir under `/tmp` is not an edge case — it is `mur run`'s default, since
944    //    `--workdir` falls back to a temporary directory. With the workdir reproduced first, this
945    //    mount landed *on top of* the path leading to it, the workdir bind vanished under it, and
946    //    the construction died at `chdir into the workdir inside the root: ENOENT` — after the
947    //    pivot, with the host root already detached. Mounting `/tmp` first means the workdir's path
948    //    components are created inside it, so the deeper mount is the one that survives.
949    let tmp = base.join("tmp");
950    builder.push(RootOp::MkDir(tmp.clone()), true);
951    builder.push(
952        RootOp::Bind {
953            source: workdir.join(SEALED_TMP_DIR_NAME),
954            target: tmp,
955            read_only: false,
956        },
957        true,
958    );
959
960    // 6. The session workdir, at its own absolute path, read-write — the only writable path in the
961    //    composed root, and the backing store for /tmp above.
962    let workdir_target = rebase(base, workdir);
963    builder.mkdir_p(&workdir_target);
964    builder.push(
965        RootOp::Bind {
966            source: workdir.to_path_buf(),
967            target: workdir_target,
968            read_only: false,
969        },
970        true,
971    );
972
973    ComposedRootPlan {
974        base: base.to_path_buf(),
975        steps: builder.steps,
976        workdir_in_root: workdir.to_path_buf(),
977    }
978}
979
980/// `base` + `path`, where `path` is absolute: `/tmp` + `/usr/lib` → `/tmp/usr/lib`.
981///
982/// `pub(crate)` so [`crate::staged_runtime::target_under_root`] can reuse this instead of
983/// reimplementing the same re-basing rule.
984pub(crate) fn rebase(base: &Path, path: &Path) -> PathBuf {
985    let relative = path.strip_prefix("/").unwrap_or(path);
986    base.join(relative)
987}
988
989struct PlanBuilder {
990    base: PathBuf,
991    steps: Vec<RootStep>,
992    /// Directories already scheduled for creation, so a shared prefix (`/usr`, `/etc`) is not
993    /// `mkdir`ed once per entry under it.
994    made: std::collections::HashSet<PathBuf>,
995}
996
997impl PlanBuilder {
998    fn new(base: &Path) -> Self {
999        let mut made = std::collections::HashSet::new();
1000        made.insert(base.to_path_buf());
1001        Self {
1002            base: base.to_path_buf(),
1003            steps: Vec::new(),
1004            made,
1005        }
1006    }
1007
1008    fn push(&mut self, op: RootOp, required: bool) {
1009        self.steps.push(RootStep { op, required });
1010    }
1011
1012    /// Schedules `mkdir` for every not-yet-scheduled component of `target` under the base.
1013    fn mkdir_p(&mut self, target: &Path) {
1014        let mut current = self.base.clone();
1015        let Ok(relative) = target.strip_prefix(&self.base) else {
1016            return;
1017        };
1018        for component in relative.components() {
1019            current = current.join(component);
1020            if self.made.insert(current.clone()) {
1021                self.steps.push(RootStep {
1022                    op: RootOp::MkDir(current.clone()),
1023                    required: true,
1024                });
1025            }
1026        }
1027    }
1028
1029    /// Schedules an unconditional, **required** read-only directory bind of `source`.
1030    ///
1031    /// The deliberate difference from [`Self::mirror`] is that this never consults [`HostLayout`].
1032    /// `mirror` asks the host whether a path exists and, when it does not, returns having
1033    /// scheduled *nothing at all* — not even a step that would fail. That is right for
1034    /// `extra_read_only`, where a missing path should make the composed root narrower rather than
1035    /// refuse the launch. It is exactly wrong for `staged_runtime`, where a missing source must
1036    /// fail the launch: routed through `mirror`, a capsule declaring a runtime tree the host does
1037    /// not have would launch successfully into a root silently lacking it.
1038    ///
1039    /// So no existence pre-check happens here, or anywhere else in this module. The plan always
1040    /// carries the step, `required: true`, and the real `mount(2)` in `execute_step`'s
1041    /// [`CStepKind::Bind`] arm is the single source of truth for "does this exist" — a missing
1042    /// source fails with `ENOENT` at construction time, in the child, which
1043    /// `construct_composed_root`'s `step.required` check turns into a `SealedRootFailure`. That
1044    /// path already reaches the operator as `RuntimeError::SealedRootConstructionFailed`
1045    /// (`E-RUN-014`), already names the offending path via [`SealedRootSpec::describe`], and is
1046    /// already session-fatal, so no error variant of its own is needed.
1047    ///
1048    /// Always a directory bind: a staged runtime tree is a tree. `mkdir_p` registers the target in
1049    /// `made`, so a later `mirror` of the same path is the one that no-ops, never this.
1050    fn require_bind(&mut self, source: &Path) {
1051        let target = rebase(&self.base, source);
1052        if self.made.contains(&target) {
1053            return;
1054        }
1055        self.mkdir_p(&target);
1056        self.push(
1057            RootOp::Bind {
1058                source: source.to_path_buf(),
1059                target,
1060                read_only: true,
1061            },
1062            /* required */ true,
1063        );
1064    }
1065
1066    /// Reproduces one host path inside the composed root: a symlink as a symlink, a directory or
1067    /// file as a read-only bind mount. A path the host does not have contributes nothing.
1068    fn mirror(&mut self, source: &Path, host: &dyn HostLayout, required: bool) {
1069        let Some(kind) = host.kind(source) else {
1070            return;
1071        };
1072        let target = rebase(&self.base, source);
1073        if self.made.contains(&target) {
1074            return;
1075        }
1076        match kind {
1077            PathKind::Symlink(link_target) => {
1078                if let Some(parent) = target.parent() {
1079                    self.mkdir_p(parent);
1080                }
1081                self.made.insert(target.clone());
1082                self.push(
1083                    RootOp::Symlink {
1084                        target: link_target,
1085                        link: target,
1086                    },
1087                    required,
1088                );
1089            }
1090            PathKind::Dir => {
1091                self.mkdir_p(&target);
1092                self.push(
1093                    RootOp::Bind {
1094                        source: source.to_path_buf(),
1095                        target,
1096                        read_only: true,
1097                    },
1098                    required,
1099                );
1100            }
1101            PathKind::File => self.bind_file(source.to_path_buf(), target, required),
1102        }
1103    }
1104
1105    /// Schedules the mountpoint-then-bind pair one file bind needs: `mkdir -p` of its parent, an
1106    /// empty file to mount over (a bind needs an existing target of the right kind), then the
1107    /// read-only bind itself.
1108    ///
1109    /// Takes `source` and `target` separately rather than deriving one from the other, which is the
1110    /// whole reason it is a method: [`Self::mirror`] binds a host path at its own path, while a
1111    /// synthetic `/etc` entry binds a file from inside the workdir at a path that names something
1112    /// else entirely.
1113    fn bind_file(&mut self, source: PathBuf, target: PathBuf, required: bool) {
1114        if self.made.contains(&target) {
1115            return;
1116        }
1117        if let Some(parent) = target.parent() {
1118            self.mkdir_p(parent);
1119        }
1120        self.made.insert(target.clone());
1121        self.push(RootOp::MkFile(target.clone()), required);
1122        self.push(
1123            RootOp::Bind {
1124                source,
1125                target,
1126                read_only: true,
1127            },
1128            required,
1129        );
1130    }
1131}
1132
1133// ---------------------------------------------------------------- Linux: probe + execution
1134
1135/// Marker every composed-root failure message carries, so `shell.rs` can tell a `pre_exec`
1136/// sealed-root failure apart from a Landlock or seccomp one after reading it back off the
1137/// diagnostic pipe — the pipe carries one flat string, so the classification has to live in the
1138/// text. Declared here rather than inside the Linux submodule because the reader is
1139/// cross-platform code.
1140pub(crate) const SEALED_ROOT_FAILURE_PREFIX: &str = "sealed-root:";
1141
1142#[cfg(target_os = "linux")]
1143pub(crate) use linux::{
1144    build_sealed_root_spec, construct_composed_root, probe_sealed_support, SealedRootSpec,
1145};
1146
1147/// The post-`fork()` namespace primitives [`crate::network_namespace`] reuses rather than
1148/// reimplementing.
1149///
1150/// The `dumpable` pair is shared because a non-dumpable task's `/proc/self/uid_map` is root-owned
1151/// and therefore unopenable by the task itself, and every namespace `mur` creates hits that trap
1152/// identically — see [`linux::make_dumpable_for_map_writes`].
1153///
1154/// `apparmor_permits_userns` is shared because it answers whether AppArmor stands between this
1155/// binary and `unshare(CLONE_NEWUSER)`, which `sealed` and the capsule network namespace both
1156/// need. Two implementations of one host question could return two answers, and the operator would
1157/// be told to fix two different things.
1158#[cfg(target_os = "linux")]
1159pub(crate) use linux::{
1160    apparmor_permits_userns, make_dumpable_for_map_writes, restore_dumpable, write_decimal_map,
1161};
1162
1163/// Non-Linux stub: nothing here can be probed, so nothing is claimed.
1164#[cfg(not(target_os = "linux"))]
1165pub(crate) fn probe_sealed_support() -> SealedProbe {
1166    SealedProbe::default()
1167}
1168
1169#[cfg(target_os = "linux")]
1170#[allow(unsafe_code)]
1171mod linux {
1172    use std::ffi::CString;
1173    use std::os::unix::ffi::OsStrExt;
1174    use std::path::Path;
1175
1176    use super::{
1177        ComposedRootPlan, NamespaceProbe, RootOp, RootStep, SealedProbe, OLD_ROOT_NAME,
1178        PROC_HIDEPID_OPTIONS, SEALED_APPARMOR_PROFILE_NAME, SEALED_ROOT_FAILURE_PREFIX,
1179    };
1180
1181    // ------------------------------------------------------------ probe
1182
1183    /// Reads AppArmor's unprivileged-userns restriction and, if it is not in the way, really
1184    /// creates a user+mount namespace in a forked child.
1185    ///
1186    /// Forking rather than testing in-process is required, not defensive: `unshare(CLONE_NEWUSER)`
1187    /// is irreversible for the calling process, and putting the whole runtime into a user
1188    /// namespace as a side effect of a capability check would be exactly the "probe that changes
1189    /// the thing it measures" mistake `probe_landlock_full_access` documents avoiding.
1190    pub(crate) fn probe_sealed_support() -> SealedProbe {
1191        SealedProbe {
1192            apparmor_permits_userns: apparmor_permits_userns(),
1193            namespace: probe_namespace(),
1194        }
1195    }
1196
1197    /// `true` when AppArmor is not standing between this binary and an unprivileged user
1198    /// namespace.
1199    ///
1200    /// Three questions in order: is AppArmor even enabled; is its `restrict_unprivileged_userns`
1201    /// knob on; and — only if both — is this process confined by the shipped `mur-sealed` profile.
1202    /// Reading `/proc/self/attr/current` rather than the loaded-profile list is deliberate: a
1203    /// profile that is loaded but does not *attach* to the path `mur` was installed at helps
1204    /// nobody, and this asks the question that decides the outcome.
1205    pub(crate) fn apparmor_permits_userns() -> bool {
1206        let enabled = read_trimmed("/sys/module/apparmor/parameters/enabled");
1207        if !matches!(enabled.as_deref(), Some("Y") | Some("1")) {
1208            return true;
1209        }
1210
1211        let restricted =
1212            read_trimmed("/sys/module/apparmor/parameters/restrict_unprivileged_userns")
1213                .or_else(|| read_trimmed("/proc/sys/kernel/apparmor_restrict_unprivileged_userns"));
1214        if !matches!(restricted.as_deref(), Some("Y") | Some("1")) {
1215            return true;
1216        }
1217
1218        read_trimmed("/proc/self/attr/current")
1219            .is_some_and(|current| current.starts_with(SEALED_APPARMOR_PROFILE_NAME))
1220    }
1221
1222    fn read_trimmed(path: &str) -> Option<String> {
1223        std::fs::read_to_string(path)
1224            .ok()
1225            .map(|value| value.trim().to_string())
1226            .filter(|value| !value.is_empty())
1227    }
1228
1229    /// Flips `PR_SET_DUMPABLE` for the duration of the identity-map writes, and back again.
1230    ///
1231    /// Not optional. `mur` marks itself
1232    /// non-dumpable at startup (`security::harden_process_dumpable`, `prctl(PR_SET_DUMPABLE, 0)`)
1233    /// so that no same-uid process can read its `/proc/<pid>/environ`. That flag is inherited
1234    /// across `fork()`, and the kernel's `task_dump_owner()` reassigns *every* `/proc/<pid>/*`
1235    /// entry of a non-dumpable task to root — including `setgroups`, `uid_map` and `gid_map`. An
1236    /// unprivileged process therefore cannot open its own `uid_map` for writing, and the namespace
1237    /// it just created is one it can never own.
1238    ///
1239    /// The symptom is misleading: `unshare` succeeds, the map write fails with `EACCES`, and the
1240    /// refusal blames the host's id-mapping policy — on a host whose id-mapping policy is fine,
1241    /// and where the identical syscall sequence run from any other program succeeds. Without this
1242    /// flip, `sealed` is unreachable on every host for that reason alone.
1243    ///
1244    /// The window is reopened for exactly the three `open`/`write`/`close` pairs that need it and
1245    /// closed again before the first mount, so the child spends no longer readable than the map
1246    /// writes take.
1247    ///
1248    /// [`restore_dumpable`] puts back whatever the flag *was*, read here and returned, rather than
1249    /// assuming `0`. Hardcoding `0` looked equivalent — `mur` always runs non-dumpable — and was
1250    /// not: it also cleared the flag for every process that had *not* hardened itself, which
1251    /// includes this crate's own integration tests, and a non-dumpable child is one whose
1252    /// `/proc/<pid>/mem` the seccomp-notify supervisor could not read — every allowlisted `execve`
1253    /// then failed closed to `EACCES`.
1254    ///
1255    /// # Safety
1256    /// Post-`fork()`, pre-exec child context only — `prctl(2)` is async-signal-safe, but the
1257    /// dumpable window this reopens is only sound while this process is the single-threaded child
1258    /// that is about to `execve` a sandboxed binary.
1259    pub(crate) unsafe fn make_dumpable_for_map_writes() -> libc::c_int {
1260        // SAFETY: both `prctl` calls take four integer arguments; no pointers cross the boundary.
1261        // `PR_GET_DUMPABLE` returning `-1` means the query failed, in which case the restore below
1262        // is skipped rather than guessing.
1263        unsafe {
1264            let previous = libc::prctl(libc::PR_GET_DUMPABLE, 0, 0, 0, 0);
1265            libc::prctl(libc::PR_SET_DUMPABLE, 1 as libc::c_int, 0, 0, 0);
1266            previous
1267        }
1268    }
1269
1270    /// Undoes [`make_dumpable_for_map_writes`], restoring the exact value it found.
1271    ///
1272    /// # Safety
1273    /// Same window and same constraints as [`make_dumpable_for_map_writes`]; `previous` must be
1274    /// the value that call returned.
1275    pub(crate) unsafe fn restore_dumpable(previous: libc::c_int) {
1276        if previous < 0 {
1277            return;
1278        }
1279        // SAFETY: integer-only `prctl`, in the post-fork child.
1280        unsafe {
1281            libc::prctl(libc::PR_SET_DUMPABLE, previous, 0, 0, 0);
1282        }
1283    }
1284
1285    // Exit codes the probe child reports. Kept as small distinct integers so the parent learns the
1286    // *step* that failed without any IPC beyond `waitpid`.
1287    const PROBE_OK: i32 = 0;
1288    const PROBE_UNSHARE_DENIED: i32 = 1;
1289    const PROBE_UNSHARE_UNSUPPORTED: i32 = 2;
1290    const PROBE_MOUNT_DENIED: i32 = 3;
1291    const PROBE_MAP_DENIED: i32 = 4;
1292
1293    fn probe_namespace() -> NamespaceProbe {
1294        // Read before the fork, and therefore before `unshare`: inside a fresh user namespace
1295        // with no mapping written yet, `getuid()`/`getgid()` report the *overflow* ids (65534),
1296        // not the real ones. Writing `65534 65534 1` into `uid_map` is refused with `EPERM` on
1297        // every host, so reading them after the unshare made the probe fail everywhere and
1298        // report the host as the culprit. This is the one ordering in this function that is
1299        // load-bearing.
1300        // SAFETY: getuid/getgid take no arguments, dereference nothing and cannot fail.
1301        let uid = unsafe { libc::getuid() };
1302        let gid = unsafe { libc::getgid() };
1303
1304        // SAFETY: `fork()` from a possibly-multithreaded process is sound as long as the child
1305        // touches nothing but async-signal-safe primitives. The child below calls `unshare`,
1306        // `prctl`, `mount`, the `open`/`write`/`close` triples that write the identity maps, and
1307        // `_exit` — every one of them async-signal-safe, and no allocation, no locks, no stdio.
1308        let pid = unsafe { libc::fork() };
1309        if pid < 0 {
1310            return NamespaceProbe::Unsupported;
1311        }
1312        if pid == 0 {
1313            // SAFETY: forked-child context, syscalls only; every branch ends in `_exit`.
1314            unsafe {
1315                if libc::unshare(libc::CLONE_NEWUSER | libc::CLONE_NEWNS) != 0 {
1316                    let errno = *libc::__errno_location();
1317                    libc::_exit(match errno {
1318                        libc::EPERM => PROBE_UNSHARE_DENIED,
1319                        _ => PROBE_UNSHARE_UNSUPPORTED,
1320                    });
1321                }
1322                // Rehearse the identity uid/gid mapping too, rather than stopping at
1323                // `unshare`. A probe that skips it is not measuring what the real construction
1324                // does: a host can allow the namespace and still refuse to let the process own
1325                // it, and finding that out at spawn time instead of at launch time is exactly
1326                // the surprise this probe exists to prevent.
1327                //
1328                // `uid`/`gid` come from before the fork — see `probe_namespace`.
1329                //
1330                // Dumpable for the map writes only, and restored immediately: see
1331                // `make_dumpable_for_map_writes` for why an inherited non-dumpable flag makes
1332                // these three files root-owned.
1333                let previous_dumpable = make_dumpable_for_map_writes();
1334                let _ = write_decimal_map(c"/proc/self/setgroups", None, 0);
1335                let mapped = write_decimal_map(c"/proc/self/uid_map", Some(uid), uid).is_ok()
1336                    && write_decimal_map(c"/proc/self/gid_map", Some(gid), gid).is_ok();
1337                restore_dumpable(previous_dumpable);
1338                if !mapped {
1339                    libc::_exit(PROBE_MAP_DENIED);
1340                }
1341
1342                // The first mount any composed root performs, and the one AppArmor's
1343                // `unprivileged_userns` profile denies: if this works, `CAP_SYS_ADMIN` is real
1344                // inside the new namespace.
1345                let rc = libc::mount(
1346                    std::ptr::null(),
1347                    c"/".as_ptr(),
1348                    std::ptr::null(),
1349                    libc::MS_REC | libc::MS_PRIVATE,
1350                    std::ptr::null(),
1351                );
1352                libc::_exit(if rc == 0 {
1353                    PROBE_OK
1354                } else {
1355                    PROBE_MOUNT_DENIED
1356                });
1357            }
1358        }
1359
1360        let mut status: libc::c_int = 0;
1361        // SAFETY: `pid` is the child just forked; `status` is a live local.
1362        let waited = unsafe { libc::waitpid(pid, &mut status, 0) };
1363        if waited != pid {
1364            return NamespaceProbe::Unsupported;
1365        }
1366        if !libc::WIFEXITED(status) {
1367            return NamespaceProbe::Unsupported;
1368        }
1369        match libc::WEXITSTATUS(status) {
1370            PROBE_OK => NamespaceProbe::Ok,
1371            PROBE_UNSHARE_DENIED => NamespaceProbe::Denied,
1372            // Kept apart from `Denied`: the child already told us which step failed, and
1373            // throwing that away made a map failure indistinguishable from the kernel refusing
1374            // the namespace outright — so the reported cause, and the remediation offered with
1375            // it, named the wrong thing entirely.
1376            PROBE_MAP_DENIED => NamespaceProbe::MapDenied,
1377            PROBE_MOUNT_DENIED => NamespaceProbe::MountDenied,
1378            _ => NamespaceProbe::Unsupported,
1379        }
1380    }
1381
1382    /// Writes either the literal `deny` (when `id` is `None`) or an identity map line
1383    /// `"<id> <id> 1\n"`, formatting the number into a stack buffer.
1384    ///
1385    /// Hand-rolled rather than `format!` because this runs in a forked child: the parent may have
1386    /// been mid-allocation on another thread when `fork()` returned, so touching the allocator
1387    /// here can deadlock. `id` never exceeds `u32::MAX`, so ten digits is the whole range.
1388    ///
1389    /// # Safety
1390    /// Must only be called from the post-`fork()`, pre-exec window in the child, before any other
1391    /// thread could exist in this process — the same async-signal-safety constraint documented on
1392    /// [`construct_composed_root`]'s `unsafe` block, which this function's callers all sit inside.
1393    pub(crate) unsafe fn write_decimal_map(
1394        path: &std::ffi::CStr,
1395        id: Option<libc::uid_t>,
1396        value: libc::uid_t,
1397    ) -> Result<(), ()> {
1398        let Some(_) = id else {
1399            return write_file(path, b"deny");
1400        };
1401
1402        let mut buf = [0u8; 32];
1403        let mut len = 0usize;
1404        let write_number = |buf: &mut [u8; 32], len: &mut usize, mut n: u32| {
1405            let mut digits = [0u8; 10];
1406            let mut count = 0;
1407            loop {
1408                digits[count] = b'0' + (n % 10) as u8;
1409                count += 1;
1410                n /= 10;
1411                if n == 0 {
1412                    break;
1413                }
1414            }
1415            while count > 0 {
1416                count -= 1;
1417                buf[*len] = digits[count];
1418                *len += 1;
1419            }
1420        };
1421
1422        write_number(&mut buf, &mut len, value);
1423        buf[len] = b' ';
1424        len += 1;
1425        write_number(&mut buf, &mut len, value);
1426        buf[len] = b' ';
1427        len += 1;
1428        buf[len] = b'1';
1429        len += 1;
1430        buf[len] = b'\n';
1431        len += 1;
1432
1433        write_file(path, &buf[..len])
1434    }
1435
1436    // ------------------------------------------------------------ spec (parent side)
1437
1438    /// One planned operation, lowered to NUL-terminated C strings plus a pre-rendered label.
1439    ///
1440    /// The label is built here, in the parent, precisely so the child never has to: a failure
1441    /// message that names its step costs one `String` per step at spawn time and zero allocation
1442    /// inside `pre_exec`.
1443    struct CStep {
1444        kind: CStepKind,
1445        required: bool,
1446        label: String,
1447    }
1448
1449    enum CStepKind {
1450        MkDir(CString),
1451        MkFile(CString),
1452        Symlink {
1453            target: CString,
1454            link: CString,
1455        },
1456        Bind {
1457            source: CString,
1458            target: CString,
1459            read_only: bool,
1460        },
1461        Tmpfs {
1462            target: CString,
1463            options: CString,
1464        },
1465        Proc {
1466            target: CString,
1467        },
1468        DevPts {
1469            target: CString,
1470        },
1471        RemountReadOnly(CString),
1472    }
1473
1474    /// Everything the child needs to build a composed root, fully resolved before `fork()`.
1475    pub(crate) struct SealedRootSpec {
1476        uid_map: Vec<u8>,
1477        gid_map: Vec<u8>,
1478        base: CString,
1479        /// `<base>/.mur-oldroot` — created before the pivot.
1480        old_root_full: CString,
1481        /// `.mur-oldroot`, relative, as `pivot_root(2)` wants it with `cwd == base`.
1482        old_root_relative: CString,
1483        /// `/.mur-oldroot`, as it is named after the pivot, for the detach and the rmdir.
1484        old_root_pivoted: CString,
1485        steps: Vec<CStep>,
1486        workdir_in_root: CString,
1487    }
1488
1489    /// Where a composed root's construction failed. `Copy` and allocation-free by construction —
1490    /// it is produced inside `pre_exec`; the message is rendered by the caller, afterwards.
1491    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1492    pub(crate) struct SealedRootFailure {
1493        /// Index into the spec's step list, or `usize::MAX` for one of the fixed
1494        /// unshare/pivot/chdir stages.
1495        pub(crate) step: usize,
1496        /// A fixed stage name when `step` is `usize::MAX`; otherwise unused.
1497        pub(crate) stage: &'static str,
1498        pub(crate) errno: i32,
1499    }
1500
1501    impl SealedRootFailure {
1502        fn stage(stage: &'static str) -> Self {
1503            Self {
1504                step: usize::MAX,
1505                stage,
1506                errno: std::io::Error::last_os_error().raw_os_error().unwrap_or(0),
1507            }
1508        }
1509
1510        fn step(index: usize) -> Self {
1511            Self {
1512                step: index,
1513                stage: "",
1514                errno: std::io::Error::last_os_error().raw_os_error().unwrap_or(0),
1515            }
1516        }
1517    }
1518
1519    impl SealedRootSpec {
1520        /// Renders a failure into the message that reaches the operator, naming both the step and
1521        /// the errno. Runs in the *parent's* error path (via the diagnostic pipe) or in the
1522        /// child's, where an allocation is already unavoidable because the launch is over.
1523        pub(crate) fn describe(&self, failure: SealedRootFailure) -> String {
1524            let what = if failure.step == usize::MAX {
1525                failure.stage.to_string()
1526            } else {
1527                self.steps
1528                    .get(failure.step)
1529                    .map(|step| step.label.clone())
1530                    .unwrap_or_else(|| format!("step {}", failure.step))
1531            };
1532            format!(
1533                "{SEALED_ROOT_FAILURE_PREFIX} {what} failed: {}",
1534                std::io::Error::from_raw_os_error(failure.errno)
1535            )
1536        }
1537    }
1538
1539    /// Lowers a [`ComposedRootPlan`] into the C-string form the child executes. Fails only on a
1540    /// path containing an interior NUL, which no real path does.
1541    pub(crate) fn build_sealed_root_spec(
1542        plan: &ComposedRootPlan,
1543    ) -> Result<SealedRootSpec, String> {
1544        // SAFETY: `getuid`/`getgid` take no arguments, dereference nothing and cannot fail.
1545        let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
1546
1547        let mut steps = Vec::with_capacity(plan.steps.len());
1548        for RootStep { op, required } in &plan.steps {
1549            let (kind, label) = match op {
1550                RootOp::MkDir(path) => (
1551                    CStepKind::MkDir(cstr(path)?),
1552                    format!("mkdir {}", path.display()),
1553                ),
1554                RootOp::MkFile(path) => (
1555                    CStepKind::MkFile(cstr(path)?),
1556                    format!("create {}", path.display()),
1557                ),
1558                RootOp::Symlink { target, link } => (
1559                    CStepKind::Symlink {
1560                        target: cstr(target)?,
1561                        link: cstr(link)?,
1562                    },
1563                    format!("symlink {} -> {}", link.display(), target.display()),
1564                ),
1565                RootOp::Bind {
1566                    source,
1567                    target,
1568                    read_only,
1569                } => (
1570                    CStepKind::Bind {
1571                        source: cstr(source)?,
1572                        target: cstr(target)?,
1573                        read_only: *read_only,
1574                    },
1575                    format!(
1576                        "bind{} {} -> {}",
1577                        if *read_only { " (ro)" } else { "" },
1578                        source.display(),
1579                        target.display()
1580                    ),
1581                ),
1582                RootOp::Tmpfs { target, options } => (
1583                    CStepKind::Tmpfs {
1584                        target: cstr(target)?,
1585                        options: CString::new(*options).map_err(|error| error.to_string())?,
1586                    },
1587                    format!("tmpfs on {}", target.display()),
1588                ),
1589                RootOp::Proc { target } => (
1590                    CStepKind::Proc {
1591                        target: cstr(target)?,
1592                    },
1593                    format!("proc on {}", target.display()),
1594                ),
1595                RootOp::DevPts { target } => (
1596                    CStepKind::DevPts {
1597                        target: cstr(target)?,
1598                    },
1599                    format!("devpts on {}", target.display()),
1600                ),
1601                RootOp::RemountReadOnly(path) => (
1602                    CStepKind::RemountReadOnly(cstr(path)?),
1603                    format!("remount read-only {}", path.display()),
1604                ),
1605            };
1606            steps.push(CStep {
1607                kind,
1608                required: *required,
1609                label,
1610            });
1611        }
1612
1613        Ok(SealedRootSpec {
1614            // Identity maps: the capsule keeps the uid it already had, so files it writes into the
1615            // bind-mounted workdir are owned by the real user rather than by `nobody`.
1616            uid_map: format!("{uid} {uid} 1\n").into_bytes(),
1617            gid_map: format!("{gid} {gid} 1\n").into_bytes(),
1618            base: cstr(&plan.base)?,
1619            old_root_full: cstr(&plan.base.join(OLD_ROOT_NAME))?,
1620            old_root_relative: CString::new(OLD_ROOT_NAME).map_err(|e| e.to_string())?,
1621            old_root_pivoted: CString::new(format!("/{OLD_ROOT_NAME}"))
1622                .map_err(|e| e.to_string())?,
1623            steps,
1624            workdir_in_root: cstr(&plan.workdir_in_root)?,
1625        })
1626    }
1627
1628    fn cstr(path: &Path) -> Result<CString, String> {
1629        CString::new(path.as_os_str().as_bytes())
1630            .map_err(|_| format!("path contains an interior NUL: {}", path.display()))
1631    }
1632
1633    // ------------------------------------------------------------ execution (child side)
1634
1635    /// Builds the composed root and pivots onto it. Runs inside the forked child's `pre_exec`
1636    /// window, before fd hygiene, the capability drop, seccomp and Landlock — which is the only
1637    /// window in which `unshare`/`mount`/`pivot_root` are available at all, since
1638    /// `sandbox::SECCOMP_MUST_STAY_DENIED` denies all three to every process the filter covers.
1639    ///
1640    /// Allocation-free on the success path: every path was turned into a `CString` by
1641    /// [`build_sealed_root_spec`] before `fork()`.
1642    pub(crate) fn construct_composed_root(spec: &SealedRootSpec) -> Result<(), SealedRootFailure> {
1643        // SAFETY: every call below is a bare syscall over pointers into `spec`, which outlives
1644        // this function. No allocation, no locks, no reentrancy — the constraints of the
1645        // post-fork/pre-exec window.
1646        unsafe {
1647            // 1. The namespaces. `CLONE_NEWUSER` is what makes `CLONE_NEWNS` available without
1648            //    host root; asking for both in one call means the mount namespace is created with
1649            //    the new user namespace's credentials already in force.
1650            if libc::unshare(libc::CLONE_NEWUSER | libc::CLONE_NEWNS) != 0 {
1651                return Err(SealedRootFailure::stage(
1652                    "unshare(CLONE_NEWUSER|CLONE_NEWNS)",
1653                ));
1654            }
1655
1656            // 2. Identity uid/gid maps. `setgroups=deny` first, which the kernel requires before
1657            //    an unprivileged process may write `gid_map`. A host without `setgroups` (pre-3.19)
1658            //    is tolerated; a failing `uid_map` is not, because running as the overflow uid
1659            //    would leave the capsule unable to write its own workdir.
1660            //
1661            //    Dumpable is flipped on for exactly these three writes and put back before the
1662            //    first mount: `mur` runs non-dumpable, and a non-dumpable task's `/proc/self/*`
1663            //    entries are owned by root, which makes its own `uid_map` unopenable. See
1664            //    [`make_dumpable_for_map_writes`].
1665            let previous_dumpable = make_dumpable_for_map_writes();
1666            let _ = write_file(c"/proc/self/setgroups", b"deny");
1667            let uid_mapped = write_file(c"/proc/self/uid_map", &spec.uid_map).is_ok();
1668            let gid_mapped = uid_mapped && write_file(c"/proc/self/gid_map", &spec.gid_map).is_ok();
1669            restore_dumpable(previous_dumpable);
1670            if !uid_mapped {
1671                return Err(SealedRootFailure::stage("write /proc/self/uid_map"));
1672            }
1673            if !gid_mapped {
1674                return Err(SealedRootFailure::stage("write /proc/self/gid_map"));
1675            }
1676
1677            // 3. Private propagation, so none of what follows escapes back to the host's mounts.
1678            if libc::mount(
1679                std::ptr::null(),
1680                c"/".as_ptr(),
1681                std::ptr::null(),
1682                libc::MS_REC | libc::MS_PRIVATE,
1683                std::ptr::null(),
1684            ) != 0
1685            {
1686                return Err(SealedRootFailure::stage("mount --make-rprivate /"));
1687            }
1688
1689            // 4. The new root: an empty tmpfs over the chosen base. It is a mount point, which is
1690            //    what `pivot_root(2)` requires of its new root.
1691            if libc::mount(
1692                c"tmpfs".as_ptr(),
1693                spec.base.as_ptr(),
1694                c"tmpfs".as_ptr(),
1695                libc::MS_NOSUID | libc::MS_NODEV,
1696                c"mode=0755".as_ptr() as *const libc::c_void,
1697            ) != 0
1698            {
1699                return Err(SealedRootFailure::stage(
1700                    "mount tmpfs for the composed root",
1701                ));
1702            }
1703
1704            // 5. The plan.
1705            for (index, step) in spec.steps.iter().enumerate() {
1706                if execute_step(step).is_err() && step.required {
1707                    return Err(SealedRootFailure::step(index));
1708                }
1709            }
1710
1711            // 6. Pivot. `chdir` into the new root first so `pivot_root` can be handed the pair of
1712            //    relative paths the syscall is happiest with.
1713            if libc::mkdir(spec.old_root_full.as_ptr(), 0o700) != 0 {
1714                return Err(SealedRootFailure::stage(
1715                    "mkdir the old-root parking directory",
1716                ));
1717            }
1718            if libc::chdir(spec.base.as_ptr()) != 0 {
1719                return Err(SealedRootFailure::stage("chdir into the composed root"));
1720            }
1721            if libc::syscall(
1722                libc::SYS_pivot_root,
1723                c".".as_ptr(),
1724                spec.old_root_relative.as_ptr(),
1725            ) != 0
1726            {
1727                return Err(SealedRootFailure::stage("pivot_root"));
1728            }
1729            if libc::chdir(c"/".as_ptr()) != 0 {
1730                return Err(SealedRootFailure::stage("chdir / after pivot_root"));
1731            }
1732
1733            // 7. Detach and remove the old root. After this there is no pathname anywhere in this
1734            //    namespace that reaches the host filesystem.
1735            if libc::umount2(spec.old_root_pivoted.as_ptr(), libc::MNT_DETACH) != 0 {
1736                return Err(SealedRootFailure::stage("umount2 the old root"));
1737            }
1738            // Best-effort, deliberately: `MNT_DETACH` already removed the old root from this
1739            // namespace's tree, so a leftover empty directory here is cosmetic. Failing a launch
1740            // over it would trade a working capsule for nothing.
1741            libc::rmdir(spec.old_root_pivoted.as_ptr());
1742
1743            // 8. Seal the root tmpfs itself. `MS_REMOUNT | MS_BIND` changes per-mount flags only,
1744            //    which is the well-defined way to make an existing mount read-only; the workdir
1745            //    bind underneath it keeps its own (writable) flags.
1746            if libc::mount(
1747                std::ptr::null(),
1748                c"/".as_ptr(),
1749                std::ptr::null(),
1750                libc::MS_REMOUNT
1751                    | libc::MS_BIND
1752                    | libc::MS_RDONLY
1753                    | libc::MS_NOSUID
1754                    | libc::MS_NODEV,
1755                std::ptr::null(),
1756            ) != 0
1757            {
1758                return Err(SealedRootFailure::stage(
1759                    "remount the composed root read-only",
1760                ));
1761            }
1762
1763            // 9. Back into the workdir, at the same absolute path it had on the host. `Command`
1764            //    already `chdir`ed here before this closure ran, but that was in the old root.
1765            if libc::chdir(spec.workdir_in_root.as_ptr()) != 0 {
1766                return Err(SealedRootFailure::stage(
1767                    "chdir into the workdir inside the root",
1768                ));
1769            }
1770        }
1771
1772        Ok(())
1773    }
1774
1775    /// Executes one planned step. `Err(())` carries no detail — the caller pairs the index with
1776    /// the spec's pre-rendered label and the live errno.
1777    ///
1778    /// # Safety
1779    /// Must only be called from the post-`fork()`, pre-exec window: every branch is a bare
1780    /// syscall over `CStr` pointers borrowed from `step`, which the caller guarantees outlives
1781    /// the call, and none may allocate or take a lock that a sibling thread could hold frozen at
1782    /// `fork()` time.
1783    unsafe fn execute_step(step: &CStep) -> Result<(), ()> {
1784        match &step.kind {
1785            CStepKind::MkDir(path) => {
1786                if libc::mkdir(path.as_ptr(), 0o755) != 0
1787                    && *libc::__errno_location() != libc::EEXIST
1788                {
1789                    return Err(());
1790                }
1791            }
1792            CStepKind::MkFile(path) => {
1793                let fd = libc::open(
1794                    path.as_ptr(),
1795                    libc::O_CREAT | libc::O_WRONLY | libc::O_CLOEXEC,
1796                    libc::mode_t::from(0o644u16),
1797                );
1798                if fd < 0 {
1799                    return Err(());
1800                }
1801                libc::close(fd);
1802            }
1803            CStepKind::Symlink { target, link } => {
1804                if libc::symlink(target.as_ptr(), link.as_ptr()) != 0 {
1805                    return Err(());
1806                }
1807            }
1808            CStepKind::Bind {
1809                source,
1810                target,
1811                read_only,
1812            } => {
1813                if libc::mount(
1814                    source.as_ptr(),
1815                    target.as_ptr(),
1816                    std::ptr::null(),
1817                    libc::MS_BIND | libc::MS_REC,
1818                    std::ptr::null(),
1819                ) != 0
1820                {
1821                    return Err(());
1822                }
1823                if *read_only
1824                    && libc::mount(
1825                        std::ptr::null(),
1826                        target.as_ptr(),
1827                        std::ptr::null(),
1828                        libc::MS_REMOUNT | libc::MS_BIND | libc::MS_RDONLY | libc::MS_NOSUID,
1829                        std::ptr::null(),
1830                    ) != 0
1831                {
1832                    return Err(());
1833                }
1834            }
1835            CStepKind::Tmpfs { target, options } => {
1836                if libc::mount(
1837                    c"tmpfs".as_ptr(),
1838                    target.as_ptr(),
1839                    c"tmpfs".as_ptr(),
1840                    libc::MS_NOSUID,
1841                    options.as_ptr() as *const libc::c_void,
1842                ) != 0
1843                {
1844                    return Err(());
1845                }
1846            }
1847            CStepKind::Proc { target } => {
1848                // Try each `hidepid` spelling in turn, then a recursive bind of the host's
1849                // `/proc` — see `PROC_HIDEPID_OPTIONS` for the bind's rationale and exposure.
1850                for option in PROC_HIDEPID_OPTIONS {
1851                    let mut buffer = [0u8; 32];
1852                    let bytes = option.as_bytes();
1853                    if bytes.len() >= buffer.len() {
1854                        continue;
1855                    }
1856                    buffer[..bytes.len()].copy_from_slice(bytes);
1857                    let data = if bytes.is_empty() {
1858                        std::ptr::null()
1859                    } else {
1860                        buffer.as_ptr() as *const libc::c_void
1861                    };
1862                    if libc::mount(
1863                        c"proc".as_ptr(),
1864                        target.as_ptr(),
1865                        c"proc".as_ptr(),
1866                        libc::MS_NOSUID | libc::MS_NODEV | libc::MS_NOEXEC,
1867                        data,
1868                    ) == 0
1869                    {
1870                        return Ok(());
1871                    }
1872                }
1873
1874                // Last resort: bind the host's `/proc` in. `mount -t proc` needs `CAP_SYS_ADMIN`
1875                // over the *PID* namespace's user namespace, which an unprivileged user namespace
1876                // never has, so on a bare host every spelling above fails with `EPERM` and this is
1877                // the branch that actually runs. A bind of an existing mount needs no such
1878                // privilege. Recursive so the `/proc` submounts a modern host carries come with it
1879                // rather than leaving empty stubs.
1880                if libc::mount(
1881                    c"/proc".as_ptr(),
1882                    target.as_ptr(),
1883                    std::ptr::null(),
1884                    libc::MS_BIND | libc::MS_REC,
1885                    std::ptr::null(),
1886                ) != 0
1887                {
1888                    return Err(());
1889                }
1890                // Best-effort hardening of the bind. `MS_REMOUNT | MS_BIND` changes per-mount
1891                // flags only — unlike `hidepid`, which is a superblock option and stays refused —
1892                // so this narrows what the bind can do without touching the host's own `/proc`.
1893                // Not fatal: a `/proc` that is present and merely unhardened beats no `/proc`.
1894                libc::mount(
1895                    std::ptr::null(),
1896                    target.as_ptr(),
1897                    std::ptr::null(),
1898                    libc::MS_REMOUNT
1899                        | libc::MS_BIND
1900                        | libc::MS_NOSUID
1901                        | libc::MS_NODEV
1902                        | libc::MS_NOEXEC,
1903                    std::ptr::null(),
1904                );
1905                return Ok(());
1906            }
1907            CStepKind::DevPts { target } => {
1908                if libc::mount(
1909                    c"devpts".as_ptr(),
1910                    target.as_ptr(),
1911                    c"devpts".as_ptr(),
1912                    libc::MS_NOSUID | libc::MS_NOEXEC,
1913                    c"newinstance,ptmxmode=0666,mode=0620".as_ptr() as *const libc::c_void,
1914                ) != 0
1915                {
1916                    return Err(());
1917                }
1918            }
1919            CStepKind::RemountReadOnly(target) => {
1920                if libc::mount(
1921                    std::ptr::null(),
1922                    target.as_ptr(),
1923                    std::ptr::null(),
1924                    libc::MS_REMOUNT | libc::MS_BIND | libc::MS_RDONLY | libc::MS_NOSUID,
1925                    std::ptr::null(),
1926                ) != 0
1927                {
1928                    return Err(());
1929                }
1930            }
1931        }
1932        Ok(())
1933    }
1934
1935    /// `open`/`write`/`close` over a static path, with no allocation — the `/proc/self/*_map`
1936    /// writes are the only file I/O the composed-root construction performs.
1937    ///
1938    /// # Safety
1939    /// Must only be called from the post-`fork()`, pre-exec window, for the same reason as
1940    /// [`execute_step`]: `path` and `data` are borrowed for the duration of the call only, and the
1941    /// raw `open`/`write`/`close` sequence must stay allocation- and lock-free in that window.
1942    unsafe fn write_file(path: &std::ffi::CStr, data: &[u8]) -> Result<(), ()> {
1943        let fd = libc::open(path.as_ptr(), libc::O_WRONLY | libc::O_CLOEXEC);
1944        if fd < 0 {
1945            return Err(());
1946        }
1947        let mut written = 0usize;
1948        while written < data.len() {
1949            let n = libc::write(
1950                fd,
1951                data[written..].as_ptr() as *const libc::c_void,
1952                data.len() - written,
1953            );
1954            if n < 0 {
1955                if *libc::__errno_location() == libc::EINTR {
1956                    continue;
1957                }
1958                libc::close(fd);
1959                return Err(());
1960            }
1961            if n == 0 {
1962                break;
1963            }
1964            written += n as usize;
1965        }
1966        libc::close(fd);
1967        if written == data.len() {
1968            Ok(())
1969        } else {
1970            Err(())
1971        }
1972    }
1973}
1974
1975#[cfg(test)]
1976mod tests {
1977    use super::*;
1978    use std::collections::HashMap;
1979
1980    struct FakeHost(HashMap<PathBuf, PathKind>);
1981
1982    impl FakeHost {
1983        fn usrmerge() -> Self {
1984            let mut map = HashMap::new();
1985            map.insert(PathBuf::from("/usr"), PathKind::Dir);
1986            map.insert(
1987                PathBuf::from("/bin"),
1988                PathKind::Symlink(PathBuf::from("usr/bin")),
1989            );
1990            map.insert(
1991                PathBuf::from("/sbin"),
1992                PathKind::Symlink(PathBuf::from("usr/sbin")),
1993            );
1994            map.insert(
1995                PathBuf::from("/lib"),
1996                PathKind::Symlink(PathBuf::from("usr/lib")),
1997            );
1998            map.insert(
1999                PathBuf::from("/lib64"),
2000                PathKind::Symlink(PathBuf::from("usr/lib64")),
2001            );
2002            map.insert(PathBuf::from("/etc/ld.so.cache"), PathKind::File);
2003            map.insert(PathBuf::from("/etc/ssl"), PathKind::Dir);
2004            // Present on the fake host precisely so the synthesis tests below can show that having
2005            // them is not what decides whether they are mirrored.
2006            map.insert(PathBuf::from("/etc/passwd"), PathKind::File);
2007            map.insert(PathBuf::from("/etc/group"), PathKind::File);
2008            for device in SEALED_DEVICE_NODES {
2009                map.insert(PathBuf::from(device.path), PathKind::File);
2010            }
2011            Self(map)
2012        }
2013    }
2014
2015    impl HostLayout for FakeHost {
2016        fn kind(&self, path: &Path) -> Option<PathKind> {
2017            self.0.get(path).cloned()
2018        }
2019    }
2020
2021    fn plan_for(workdir: &str) -> ComposedRootPlan {
2022        plan_composed_root(
2023            Path::new(workdir),
2024            Path::new("/tmp"),
2025            &[],
2026            &[],
2027            &FakeHost::usrmerge(),
2028        )
2029    }
2030
2031    fn ops(plan: &ComposedRootPlan) -> Vec<RootOp> {
2032        plan.steps.iter().map(|step| step.op.clone()).collect()
2033    }
2034
2035    #[test]
2036    fn base_selection_skips_any_ancestor_of_the_workdir() {
2037        // The default case: a workdir under the user's home, so `/tmp` is free.
2038        assert_eq!(
2039            choose_root_base(Path::new("/home/u/w"), SEALED_ROOT_BASE_CANDIDATES, |_| {
2040                true
2041            }),
2042            Some(PathBuf::from("/tmp"))
2043        );
2044        // A workdir under /tmp — overmounting /tmp would hide the workdir before it can be bound.
2045        assert_eq!(
2046            choose_root_base(
2047                Path::new("/tmp/session/w"),
2048                SEALED_ROOT_BASE_CANDIDATES,
2049                |_| true
2050            ),
2051            Some(PathBuf::from("/run"))
2052        );
2053        // A host missing the first two candidates falls through to the third.
2054        assert_eq!(
2055            choose_root_base(
2056                Path::new("/home/u/w"),
2057                SEALED_ROOT_BASE_CANDIDATES,
2058                |path| { path != Path::new("/tmp") && path != Path::new("/run") }
2059            ),
2060            Some(PathBuf::from("/var/tmp"))
2061        );
2062        assert_eq!(
2063            choose_root_base(Path::new("/home/u/w"), SEALED_ROOT_BASE_CANDIDATES, |_| {
2064                false
2065            }),
2066            None
2067        );
2068    }
2069
2070    #[test]
2071    fn usrmerge_symlinks_are_reproduced_rather_than_bound_through() {
2072        let plan = plan_for("/home/u/w");
2073        assert!(ops(&plan).contains(&RootOp::Symlink {
2074            target: PathBuf::from("usr/bin"),
2075            link: PathBuf::from("/tmp/bin"),
2076        }));
2077        assert!(ops(&plan).contains(&RootOp::Bind {
2078            source: PathBuf::from("/usr"),
2079            target: PathBuf::from("/tmp/usr"),
2080            read_only: true,
2081        }));
2082        // `/lib32` and `/libx32` are absent from this fake host, so they contribute nothing.
2083        assert!(!ops(&plan)
2084            .iter()
2085            .any(|op| matches!(op, RootOp::Bind { source, .. } if source == Path::new("/lib32"))));
2086    }
2087
2088    #[test]
2089    fn every_runtime_and_etc_bind_is_read_only_and_only_the_workdir_is_not() {
2090        let plan = plan_for("/home/u/w");
2091        let writable: Vec<&PathBuf> = plan
2092            .steps
2093            .iter()
2094            .filter_map(|step| match &step.op {
2095                RootOp::Bind {
2096                    source,
2097                    read_only: false,
2098                    ..
2099                } => Some(source),
2100                _ => None,
2101            })
2102            .collect();
2103
2104        // The workdir, /tmp's workdir-backed store, and the /dev nodes (whose write side is the
2105        // device driver's, not the filesystem's) — and nothing else.
2106        for source in &writable {
2107            assert!(
2108                source.starts_with("/home/u/w") || source.starts_with("/dev/"),
2109                "unexpected writable bind of {}",
2110                source.display()
2111            );
2112        }
2113        assert!(writable.contains(&&PathBuf::from("/home/u/w")));
2114    }
2115
2116    #[test]
2117    fn etc_is_an_allowlist_so_shadow_is_absent_by_construction() {
2118        let plan = plan_for("/home/u/w");
2119        let bound: Vec<String> = plan
2120            .steps
2121            .iter()
2122            .filter_map(|step| match &step.op {
2123                RootOp::Bind { source, .. } => Some(source.display().to_string()),
2124                _ => None,
2125            })
2126            .collect();
2127
2128        assert!(bound.iter().any(|path| path == "/etc/ld.so.cache"));
2129        assert!(!bound.iter().any(|path| path == "/etc"));
2130        assert!(!bound.iter().any(|path| path.starts_with("/etc/shadow")));
2131        assert!(!bound.iter().any(|path| path.starts_with("/root")));
2132        assert!(!bound.iter().any(|path| path.starts_with("/home/u/.ssh")));
2133    }
2134
2135    /// The host has both files (see `FakeHost::usrmerge`) and the plan mirrors neither: their
2136    /// targets are bound from the workdir-backed staging files the parent writes instead.
2137    #[test]
2138    fn passwd_and_group_are_bound_from_the_staging_files_never_from_the_host() {
2139        let plan = plan_for("/home/u/w");
2140        let workdir = Path::new("/home/u/w");
2141
2142        for host_path in ["/etc/passwd", "/etc/group"] {
2143            assert!(
2144                !plan.steps.iter().any(|step| matches!(
2145                    &step.op,
2146                    RootOp::Bind { source, .. } if source == Path::new(host_path)
2147                )),
2148                "the host's {host_path} carries every account on the machine and must not be \
2149                 bound into a capsule",
2150            );
2151        }
2152
2153        for (file, target) in [
2154            (SyntheticEtcFile::Passwd, "/tmp/etc/passwd"),
2155            (SyntheticEtcFile::Group, "/tmp/etc/group"),
2156        ] {
2157            let expected = RootStep {
2158                op: RootOp::Bind {
2159                    source: synthetic_etc_source(workdir, file),
2160                    target: PathBuf::from(target),
2161                    read_only: true,
2162                },
2163                // Not the host-mirrored entries' `required: false`: the parent wrote this file, so
2164                // a failed bind is a broken runtime, not a distribution that lacks the path.
2165                required: true,
2166            };
2167            assert!(
2168                plan.steps.contains(&expected),
2169                "expected {expected:?} in {:?}",
2170                plan.steps,
2171            );
2172            // And the mountpoint the bind lands on, since a bind needs an existing target.
2173            assert!(ops(&plan).contains(&RootOp::MkFile(PathBuf::from(target))));
2174        }
2175
2176        // Every other /etc entry is still the host's own file, untouched by this.
2177        assert!(ops(&plan).contains(&RootOp::Bind {
2178            source: PathBuf::from("/etc/ld.so.cache"),
2179            target: PathBuf::from("/tmp/etc/ld.so.cache"),
2180            read_only: true,
2181        }));
2182    }
2183
2184    /// Two lines, and the `pw_dir` field is the synthetic `HOME` verbatim — the property a capsule
2185    /// observes as `pwd.getpwuid(os.getuid()).pw_dir == os.environ["HOME"]`.
2186    #[test]
2187    fn the_synthetic_databases_name_root_and_the_capsules_own_id_and_nothing_else() {
2188        let identity = SealedAccountIdentity {
2189            uid: 1000,
2190            gid: 1000,
2191            home: "/w/.capsule-home",
2192        };
2193
2194        let passwd = SyntheticEtcFile::Passwd.render(&identity).unwrap();
2195        assert_eq!(
2196            passwd,
2197            "root:x:0:0:root:/root:/bin/sh\n\
2198             capsule:x:1000:1000:Murmur capsule:/w/.capsule-home:/bin/sh\n"
2199        );
2200        let group = SyntheticEtcFile::Group.render(&identity).unwrap();
2201        assert_eq!(group, "root:x:0:\ncapsule:x:1000:\n");
2202
2203        for (file, contents) in [
2204            (SyntheticEtcFile::Passwd, &passwd),
2205            (SyntheticEtcFile::Group, &group),
2206        ] {
2207            assert_eq!(
2208                contents.lines().count(),
2209                2,
2210                "{file:?} must stay a two-line database"
2211            );
2212            assert!(
2213                contents.ends_with('\n'),
2214                "{file:?} must end its last record with a newline"
2215            );
2216        }
2217    }
2218
2219    /// A capsule already running as uid 0 gets *one* passwd line, not a second `root` — two entries
2220    /// for uid 0 would leave `getpwuid(0)` resolving to whichever the parser saw first.
2221    #[test]
2222    fn a_root_capsule_gets_a_single_passwd_line_carrying_its_own_home() {
2223        let identity = SealedAccountIdentity {
2224            uid: 0,
2225            gid: 0,
2226            home: "/w/.capsule-home",
2227        };
2228
2229        assert_eq!(
2230            SyntheticEtcFile::Passwd.render(&identity).unwrap(),
2231            "root:x:0:0:root:/w/.capsule-home:/bin/sh\n"
2232        );
2233        assert_eq!(
2234            SyntheticEtcFile::Group.render(&identity).unwrap(),
2235            "root:x:0:\n"
2236        );
2237    }
2238
2239    /// `passwd(5)` has no escaping, so a home path containing the field separator would silently
2240    /// shift every field after it — a `pw_shell` of half a path, or worse.
2241    #[test]
2242    fn a_home_path_that_cannot_be_spelled_in_a_passwd_field_is_refused() {
2243        for home in ["/w/od:d", "/w/two\nlines"] {
2244            let identity = SealedAccountIdentity {
2245                uid: 1000,
2246                gid: 1000,
2247                home,
2248            };
2249            let error = SyntheticEtcFile::Passwd.render(&identity).unwrap_err();
2250            assert!(
2251                error.contains(home),
2252                "the message must name the offending path: {error}"
2253            );
2254        }
2255    }
2256
2257    #[test]
2258    fn the_workdir_keeps_its_absolute_path_inside_the_root() {
2259        let plan = plan_for("/home/u/.murmur/sessions/abc");
2260        assert_eq!(
2261            plan.workdir_in_root,
2262            PathBuf::from("/home/u/.murmur/sessions/abc")
2263        );
2264        assert!(ops(&plan).contains(&RootOp::Bind {
2265            source: PathBuf::from("/home/u/.murmur/sessions/abc"),
2266            target: PathBuf::from("/tmp/home/u/.murmur/sessions/abc"),
2267            read_only: false,
2268        }));
2269        // Every parent component is created before the bind, exactly once.
2270        let mkdirs: Vec<&RootOp> = plan
2271            .steps
2272            .iter()
2273            .map(|step| &step.op)
2274            .filter(|op| matches!(op, RootOp::MkDir(path) if path.starts_with("/tmp/home")))
2275            .collect();
2276        assert_eq!(mkdirs.len(), 5, "got {mkdirs:?}");
2277    }
2278
2279    #[test]
2280    fn tmp_is_backed_by_a_directory_inside_the_workdir() {
2281        let plan = plan_for("/home/u/w");
2282        assert!(ops(&plan).contains(&RootOp::Bind {
2283            source: PathBuf::from("/home/u/w").join(SEALED_TMP_DIR_NAME),
2284            target: PathBuf::from("/tmp/tmp"),
2285            read_only: false,
2286        }));
2287    }
2288
2289    /// The composed root's own `/tmp` must not land on top of the path leading to the workdir.
2290    ///
2291    /// This is `mur run`'s default layout, not a curiosity: with no `--workdir`, the session
2292    /// workdir is a temporary directory, which is under `/tmp`. Planned in the other order, the
2293    /// `/tmp` bind hid the workdir bind underneath it and the construction died at `chdir` —
2294    /// *after* `pivot_root`, with the host root already detached, so the failure arrived as a bare
2295    /// `ENOENT` from inside a root that no longer had a way back.
2296    #[test]
2297    fn a_workdir_under_tmp_survives_the_composed_tmp_mounted_over_its_path() {
2298        let workdir = "/tmp/session-1234/w";
2299        let plan = plan_for(workdir);
2300        let operations = ops(&plan);
2301
2302        // A workdir under /tmp forces a base other than /tmp, so both mounts are still expressible.
2303        assert_eq!(
2304            choose_root_base(Path::new(workdir), SEALED_ROOT_BASE_CANDIDATES, |_| true),
2305            Some(PathBuf::from("/run")),
2306        );
2307
2308        let tmp_index = operations
2309            .iter()
2310            .position(
2311                |op| matches!(op, RootOp::Bind { target, .. } if target == Path::new("/tmp/tmp")),
2312            )
2313            .expect("the composed /tmp is bound");
2314        let workdir_index = operations
2315            .iter()
2316            .position(
2317                |op| matches!(op, RootOp::Bind { source, .. } if source == Path::new(workdir)),
2318            )
2319            .expect("the workdir is bound");
2320        assert!(
2321            tmp_index < workdir_index,
2322            "/tmp must be mounted before the workdir path is built inside it, or the workdir bind \
2323             is hidden by it",
2324        );
2325    }
2326
2327    #[test]
2328    fn dev_carries_the_oci_default_set_and_is_sealed_afterwards() {
2329        let plan = plan_for("/home/u/w");
2330        let operations = ops(&plan);
2331
2332        for device in SEALED_DEVICE_NODES {
2333            assert!(
2334                operations.contains(&RootOp::Bind {
2335                    source: PathBuf::from("/dev").join(device.name),
2336                    target: PathBuf::from("/tmp/dev").join(device.name),
2337                    read_only: false,
2338                }),
2339                "missing /dev/{}",
2340                device.name
2341            );
2342        }
2343        // No block device, and nothing outside the OCI default set.
2344        assert!(!operations.iter().any(
2345            |op| matches!(op, RootOp::Bind { source, .. } if source == Path::new("/dev/sda"))
2346        ));
2347
2348        let tmpfs_index = operations
2349            .iter()
2350            .position(
2351                |op| matches!(op, RootOp::Tmpfs { target, .. } if target == Path::new("/tmp/dev")),
2352            )
2353            .expect("/dev tmpfs");
2354        let sealed_index = operations
2355            .iter()
2356            .position(
2357                |op| matches!(op, RootOp::RemountReadOnly(path) if path == Path::new("/tmp/dev")),
2358            )
2359            .expect("/dev sealed read-only");
2360        assert!(
2361            tmpfs_index < sealed_index,
2362            "the device nodes must be bound before /dev is sealed"
2363        );
2364    }
2365
2366    /// The plan always asks for a masked `/proc`; whether the kernel grants one is a runtime
2367    /// question the executor answers, and on a bare host the answer is no — see
2368    /// [`PROC_HIDEPID_OPTIONS`] for the `CAP_SYS_ADMIN`-over-the-PID-namespace rule that forces
2369    /// the bind fallback. What this pins is the *preference order*: masking is tried first, and
2370    /// the unmasked private `procfs` is the last of the three before the fallback, never the
2371    /// first.
2372    #[test]
2373    fn proc_is_planned_and_masking_is_preferred_over_an_unmasked_mount() {
2374        let plan = plan_for("/home/u/w");
2375        assert!(ops(&plan).contains(&RootOp::Proc {
2376            target: PathBuf::from("/tmp/proc")
2377        }));
2378        assert_eq!(PROC_HIDEPID_OPTIONS[0], "hidepid=2");
2379        assert_eq!(
2380            PROC_HIDEPID_OPTIONS.last(),
2381            Some(&""),
2382            "the unmasked spelling must be tried last, after every masked one",
2383        );
2384    }
2385
2386    #[test]
2387    fn manifest_derived_directories_are_whole_directory_binds_and_deduped() {
2388        let mut host = FakeHost::usrmerge();
2389        host.0
2390            .insert(PathBuf::from("/opt/python3.12"), PathKind::Dir);
2391        let plan = plan_composed_root(
2392            Path::new("/home/u/w"),
2393            Path::new("/tmp"),
2394            &[PathBuf::from("/opt/python3.12"), PathBuf::from("/usr")],
2395            &[],
2396            &host,
2397        );
2398        let operations = ops(&plan);
2399
2400        assert!(operations.contains(&RootOp::Bind {
2401            source: PathBuf::from("/opt/python3.12"),
2402            target: PathBuf::from("/tmp/opt/python3.12"),
2403            read_only: true,
2404        }));
2405        // `/usr` was already bound by the fixed list; naming it again adds nothing.
2406        assert_eq!(
2407            operations
2408                .iter()
2409                .filter(
2410                    |op| matches!(op, RootOp::Bind { source, .. } if source == Path::new("/usr"))
2411                )
2412                .count(),
2413            1
2414        );
2415    }
2416
2417    /// The property the whole capability rests on: `extra_read_only` shrinks when the host is
2418    /// missing a path, `staged_runtime_read_only` fails. Both directions are asserted from the
2419    /// same plan so the contrast cannot drift apart.
2420    #[test]
2421    fn a_staged_runtime_path_absent_from_the_host_still_plans_a_required_bind() {
2422        // Neither path is in `FakeHost::usrmerge()` — the host has neither.
2423        let plan = plan_composed_root(
2424            Path::new("/home/u/w"),
2425            Path::new("/tmp"),
2426            &[PathBuf::from("/opt/optional-tree")],
2427            &[PathBuf::from("/opt/staged-tree")],
2428            &FakeHost::usrmerge(),
2429        );
2430
2431        // The optional entry contributes nothing at all — not even a step that would fail. This
2432        // is `mirror`'s silent-skip-on-absence, and it is why `extra_read_only` could not be the
2433        // destination for a staged grant.
2434        assert!(
2435            !plan.steps.iter().any(|step| matches!(
2436                &step.op,
2437                RootOp::Bind { source, .. } if source == Path::new("/opt/optional-tree")
2438            )),
2439            "an absent extra_read_only path must plan no step",
2440        );
2441
2442        // The staged entry is planned regardless, and is required — so the real `mount(2)`
2443        // returning ENOENT aborts the construction before `pivot_root`.
2444        let staged = plan
2445            .steps
2446            .iter()
2447            .find(|step| {
2448                matches!(
2449                    &step.op,
2450                    RootOp::Bind { source, .. } if source == Path::new("/opt/staged-tree")
2451                )
2452            })
2453            .expect("an absent staged_runtime path must still plan a bind");
2454        assert_eq!(
2455            staged.op,
2456            RootOp::Bind {
2457                source: PathBuf::from("/opt/staged-tree"),
2458                target: PathBuf::from("/tmp/opt/staged-tree"),
2459                read_only: true,
2460            },
2461            "staged trees are re-based read-only binds at their own absolute path",
2462        );
2463        assert!(
2464            staged.required,
2465            "a staged_runtime bind must be required, so a missing source is session-fatal \
2466             rather than a silently narrower root",
2467        );
2468    }
2469
2470    /// Staged binds land before `/etc`, `/dev`, `/proc`, `/tmp` and the workdir — and therefore
2471    /// before the `pivot_root` that `construct_composed_root` performs after every planned step.
2472    #[test]
2473    fn staged_runtime_binds_are_planned_before_every_other_step() {
2474        let mut host = FakeHost::usrmerge();
2475        host.0
2476            .insert(PathBuf::from("/opt/staged-tree"), PathKind::Dir);
2477        let plan = plan_composed_root(
2478            Path::new("/home/u/w"),
2479            Path::new("/tmp"),
2480            &[],
2481            &[PathBuf::from("/opt/staged-tree")],
2482            &host,
2483        );
2484
2485        let position = |predicate: &dyn Fn(&RootOp) -> bool| {
2486            plan.steps
2487                .iter()
2488                .position(|step| predicate(&step.op))
2489                .expect("step must be planned")
2490        };
2491
2492        let staged = position(
2493            &|op| matches!(op, RootOp::Bind { source, .. } if source == Path::new("/opt/staged-tree")),
2494        );
2495        let fixed_runtime_tree = position(
2496            &|op| matches!(op, RootOp::Bind { source, .. } if source == Path::new("/usr")),
2497        );
2498        let etc = position(
2499            &|op| matches!(op, RootOp::Bind { source, .. } if source == Path::new("/etc/ssl")),
2500        );
2501        let dev = position(
2502            &|op| matches!(op, RootOp::Tmpfs { target, .. } if target == Path::new("/tmp/dev")),
2503        );
2504        let proc = position(&|op| matches!(op, RootOp::Proc { .. }));
2505        let tmp = position(
2506            &|op| matches!(op, RootOp::Bind { target, .. } if target == Path::new("/tmp/tmp")),
2507        );
2508        let workdir = position(
2509            &|op| matches!(op, RootOp::Bind { source, .. } if source == Path::new("/home/u/w")),
2510        );
2511
2512        // Ahead of the fixed runtime tree / `extra_read_only` loop, so a coincidental target
2513        // collision is won by the required registration rather than the optional one.
2514        assert!(
2515            staged < fixed_runtime_tree,
2516            "staged binds precede the fixed runtime tree"
2517        );
2518        assert!(staged < etc, "staged binds precede /etc");
2519        assert!(staged < dev, "staged binds precede /dev");
2520        assert!(staged < proc, "staged binds precede /proc");
2521        assert!(staged < tmp, "staged binds precede /tmp");
2522        assert!(staged < workdir, "staged binds precede the workdir bind");
2523    }
2524
2525    /// The collision case the ordering above exists to protect: a path named by *both* parameters
2526    /// is registered once, by the required loop.
2527    #[test]
2528    fn a_path_named_both_staged_and_optional_stays_required() {
2529        let mut host = FakeHost::usrmerge();
2530        host.0.insert(PathBuf::from("/opt/shared"), PathKind::Dir);
2531        let plan = plan_composed_root(
2532            Path::new("/home/u/w"),
2533            Path::new("/tmp"),
2534            &[PathBuf::from("/opt/shared")],
2535            &[PathBuf::from("/opt/shared")],
2536            &host,
2537        );
2538
2539        let binds: Vec<&RootStep> = plan
2540            .steps
2541            .iter()
2542            .filter(|step| {
2543                matches!(
2544                    &step.op,
2545                    RootOp::Bind { source, .. } if source == Path::new("/opt/shared")
2546                )
2547            })
2548            .collect();
2549        assert_eq!(
2550            binds.len(),
2551            1,
2552            "the shared target is registered exactly once"
2553        );
2554        assert!(
2555            binds[0].required,
2556            "and the required registration is the one that won"
2557        );
2558    }
2559
2560    #[test]
2561    fn blocker_blames_apparmor_before_the_namespace_outcome_it_causes() {
2562        let probe = SealedProbe {
2563            apparmor_permits_userns: false,
2564            namespace: NamespaceProbe::Denied,
2565        };
2566        assert_eq!(
2567            sealed_blocker(true, true, probe),
2568            Some(SealedBlocker::AppArmorProfileMissing)
2569        );
2570        let reason = SealedBlocker::AppArmorProfileMissing.reason();
2571        assert!(reason.contains("mur-sealed"));
2572        assert!(reason.contains("apparmor_parser -r"));
2573    }
2574
2575    #[test]
2576    fn blocker_names_the_container_remediation_verbatim() {
2577        let probe = SealedProbe {
2578            apparmor_permits_userns: true,
2579            namespace: NamespaceProbe::Denied,
2580        };
2581        assert_eq!(
2582            sealed_blocker(true, true, probe),
2583            Some(SealedBlocker::NamespaceCreationDenied)
2584        );
2585        let reason = SealedBlocker::NamespaceCreationDenied.reason();
2586        assert!(reason.contains("--cap-add SYS_ADMIN"));
2587        assert!(reason.contains("outside the container"));
2588    }
2589
2590    #[test]
2591    fn a_map_failure_is_not_reported_as_the_container_case() {
2592        // `MapDenied` must stay distinct from `Denied`: a host that creates the namespace and
2593        // then refuses the id mapping is not the container case, and must not be told to add
2594        // `--cap-add SYS_ADMIN` to a container it is not running in.
2595        let probe = SealedProbe {
2596            apparmor_permits_userns: true,
2597            namespace: NamespaceProbe::MapDenied,
2598        };
2599        assert_eq!(
2600            sealed_blocker(true, true, probe),
2601            Some(SealedBlocker::IdMapDenied)
2602        );
2603
2604        let reason = SealedBlocker::IdMapDenied.reason();
2605        assert!(
2606            reason.contains("uid_map"),
2607            "must name the step that failed: {reason}"
2608        );
2609        assert!(
2610            !reason.contains("CAP_SYS_ADMIN") && !reason.contains("--cap-add"),
2611            "must not send the operator after a capability that isn't the problem: {reason}",
2612        );
2613
2614        // And the container case must keep its own, different advice.
2615        let denied = SealedProbe {
2616            apparmor_permits_userns: true,
2617            namespace: NamespaceProbe::Denied,
2618        };
2619        assert_eq!(
2620            sealed_blocker(true, true, denied),
2621            Some(SealedBlocker::NamespaceCreationDenied)
2622        );
2623        assert_ne!(
2624            SealedBlocker::IdMapDenied.reason(),
2625            SealedBlocker::NamespaceCreationDenied.reason(),
2626        );
2627    }
2628
2629    /// `SealedBlocker::ALL` is what `murmur-cli`'s containment test matches a refusal against, so a
2630    /// variant missing from it makes that test assert something false about the *host*. Adding a
2631    /// variant must break this test — loudly, here — rather than surface three crates away.
2632    #[test]
2633    fn every_blocker_is_listed_in_all_with_its_own_actionable_reason() {
2634        // The exhaustive match is the mechanism: a new variant fails to compile until it is added
2635        // here, and the `contains` below then forces it into `ALL` too.
2636        for blocker in SealedBlocker::ALL {
2637            let named = match blocker {
2638                SealedBlocker::NotLinux
2639                | SealedBlocker::AppArmorProfileMissing
2640                | SealedBlocker::NamespaceCreationDenied
2641                | SealedBlocker::IdMapDenied
2642                | SealedBlocker::MountDenied
2643                | SealedBlocker::KernelUnsupported
2644                | SealedBlocker::LandlockUnavailable => *blocker,
2645            };
2646            assert!(
2647                SealedBlocker::ALL.contains(&named),
2648                "{named:?} is missing from SealedBlocker::ALL",
2649            );
2650        }
2651
2652        let reasons: Vec<String> = SealedBlocker::ALL
2653            .iter()
2654            .map(|blocker| blocker.reason())
2655            .collect();
2656        for reason in &reasons {
2657            assert!(reason.starts_with("sealed "), "got: {reason}");
2658            assert!(!reason.contains('\n'), "got: {reason}");
2659            assert!(
2660                reason.len() > 80,
2661                "a blocker reason must name the mechanism and its remediation, not just fail: \
2662                 {reason}",
2663            );
2664        }
2665        let mut unique = reasons.clone();
2666        unique.sort();
2667        unique.dedup();
2668        assert_eq!(
2669            unique.len(),
2670            reasons.len(),
2671            "two blockers share a reason, so the refusal cannot say which one applies",
2672        );
2673    }
2674
2675    #[test]
2676    fn blocker_is_none_only_when_every_precondition_holds() {
2677        let ok = SealedProbe {
2678            apparmor_permits_userns: true,
2679            namespace: NamespaceProbe::Ok,
2680        };
2681        assert_eq!(sealed_blocker(true, true, ok), None);
2682        assert_eq!(
2683            sealed_blocker(false, true, ok),
2684            Some(SealedBlocker::NotLinux)
2685        );
2686        assert_eq!(
2687            sealed_blocker(true, false, ok),
2688            Some(SealedBlocker::LandlockUnavailable)
2689        );
2690        assert_eq!(
2691            sealed_blocker(
2692                true,
2693                true,
2694                SealedProbe {
2695                    apparmor_permits_userns: true,
2696                    namespace: NamespaceProbe::MountDenied
2697                }
2698            ),
2699            Some(SealedBlocker::MountDenied)
2700        );
2701        assert_eq!(
2702            sealed_blocker(
2703                true,
2704                true,
2705                SealedProbe {
2706                    apparmor_permits_userns: true,
2707                    namespace: NamespaceProbe::Unsupported
2708                }
2709            ),
2710            Some(SealedBlocker::KernelUnsupported)
2711        );
2712    }
2713
2714    #[test]
2715    fn the_device_set_is_exactly_the_oci_default_and_excludes_shm() {
2716        let names: Vec<&str> = SEALED_DEVICE_NODES.iter().map(|d| d.name).collect();
2717        assert_eq!(names, ["null", "zero", "full", "random", "urandom", "tty"]);
2718        assert!(!names.contains(&"shm"));
2719        assert!(!names.contains(&"console"));
2720        // Every entry records the canonical major/minor the OCI spec names.
2721        assert_eq!(
2722            SEALED_DEVICE_NODES
2723                .iter()
2724                .map(|d| (d.path, d.major, d.minor, d.mode, d.writable))
2725                .collect::<Vec<_>>(),
2726            [
2727                ("/dev/null", 1, 3, 0o666, true),
2728                ("/dev/zero", 1, 5, 0o666, true),
2729                ("/dev/full", 1, 7, 0o666, true),
2730                ("/dev/random", 1, 8, 0o666, false),
2731                ("/dev/urandom", 1, 9, 0o666, false),
2732                ("/dev/tty", 5, 0, 0o666, true),
2733            ]
2734        );
2735    }
2736}