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