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