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}