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 whether a run is refused — only [`Self::permits_userns`] does.
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize)]
114#[serde(rename_all = "snake_case")]
115pub enum UsernsGrant {
116 /// AppArmor is not enabled on this host (Fedora, Arch, a kernel built without it, …). There is
117 /// no restriction to lift and no profile to install.
118 ApparmorAbsent,
119 /// AppArmor is enabled but `kernel.apparmor_restrict_unprivileged_userns` is `0`, so no binary
120 /// on this host is transitioned into the restricted `unprivileged_userns` profile. Legitimate,
121 /// and on some hosts the only option — but it is not the configuration murmur ships, and it is
122 /// what `W-SEC-013` reports.
123 RestrictionDisabledHostWide,
124 /// The restriction is on and this process is confined by a profile whose name begins with
125 /// [`SEALED_APPARMOR_PROFILE_NAME`] — the shipped profile, or the checkout profile
126 /// `scripts/install-dev-apparmor.sh` generates. The grant is narrow: it applies to this binary
127 /// and nothing else.
128 ProfileConfining,
129 /// The restriction is on and nothing grants this binary anything. The only variant that keeps
130 /// a host below `sealed`, and the default so an unprobed host claims nothing.
131 #[default]
132 Withheld,
133}
134
135impl UsernsGrant {
136 /// Every variant, so a caller reasoning about "which grant did this host give" cannot silently
137 /// miss one. Same convention as [`SealedBlocker::ALL`].
138 pub const ALL: &'static [UsernsGrant] = &[
139 UsernsGrant::ApparmorAbsent,
140 UsernsGrant::RestrictionDisabledHostWide,
141 UsernsGrant::ProfileConfining,
142 UsernsGrant::Withheld,
143 ];
144
145 /// Whether an unprivileged `unshare(CLONE_NEWUSER)` gets through on this host.
146 ///
147 /// The whole of what the runtime decisions read: [`sealed_blocker`],
148 /// [`crate::network_namespace::egress_namespace_blocker`] and `sandbox::tier_from_probe`
149 /// consult this and nothing else about the grant, so provenance is reported without ever
150 /// changing an outcome.
151 #[must_use]
152 pub fn permits_userns(self) -> bool {
153 !matches!(self, UsernsGrant::Withheld)
154 }
155
156 /// Stable wire name, as it appears in `--explain-scope --json`, in `session_start` and in
157 /// `mur doctor`. Distinct per variant, and not a `Debug` rendering, which is free to change.
158 #[must_use]
159 pub fn wire_name(self) -> &'static str {
160 match self {
161 UsernsGrant::ApparmorAbsent => "apparmor_absent",
162 UsernsGrant::RestrictionDisabledHostWide => "restriction_disabled_host_wide",
163 UsernsGrant::ProfileConfining => "profile_confining",
164 UsernsGrant::Withheld => "withheld",
165 }
166 }
167
168 /// One line naming the mechanism in words, for the `mur doctor` block. States what the grant
169 /// covers — one binary or the whole host — because that is the difference the wire name alone
170 /// does not spell out.
171 #[must_use]
172 pub fn summary(self) -> &'static str {
173 match self {
174 UsernsGrant::ApparmorAbsent => {
175 "AppArmor is not enabled on this host, so nothing restricts unprivileged user \
176 namespaces and no profile is needed"
177 }
178 UsernsGrant::RestrictionDisabledHostWide => {
179 "kernel.apparmor_restrict_unprivileged_userns is off, so unprivileged user \
180 namespaces are unrestricted for every binary on this host, not just for mur"
181 }
182 UsernsGrant::ProfileConfining => {
183 "the restriction is on and the mur-sealed AppArmor profile is confining this \
184 binary, so the grant covers mur alone — the configuration murmur ships"
185 }
186 UsernsGrant::Withheld => {
187 "the restriction is on and no mur-sealed AppArmor profile is confining this \
188 binary, so unprivileged user namespaces are withheld from mur"
189 }
190 }
191 }
192
193 /// The whole decision, over the three host readings that produce it: whether AppArmor is
194 /// enabled, whether its `restrict_unprivileged_userns` knob is on, and what profile confines
195 /// this process. Each argument is the file's trimmed contents, or `None` when it is missing or
196 /// empty.
197 ///
198 /// Pure, and separate from the reading for the same reason [`classify_installed_profile`] is:
199 /// every combination is then testable on any OS, with no `/sys` and no `/proc`.
200 /// `linux::userns_grant` is the reader and holds no logic of its own.
201 ///
202 /// A missing or unreadable restriction knob counts as *not restricted*, not as withheld — a
203 /// kernel that does not implement the knob does not restrict anything.
204 #[must_use]
205 pub fn from_readings(
206 enabled: Option<&str>,
207 restricted: Option<&str>,
208 current_profile: Option<&str>,
209 ) -> UsernsGrant {
210 if !matches!(enabled, Some("Y") | Some("1")) {
211 return UsernsGrant::ApparmorAbsent;
212 }
213 if !matches!(restricted, Some("Y") | Some("1")) {
214 return UsernsGrant::RestrictionDisabledHostWide;
215 }
216 match current_profile {
217 Some(profile) if profile.starts_with(SEALED_APPARMOR_PROFILE_NAME) => {
218 UsernsGrant::ProfileConfining
219 }
220 _ => UsernsGrant::Withheld,
221 }
222 }
223}
224
225/// How `/etc/apparmor.d/mur-sealed` on this host compares to the profile this build ships.
226///
227/// A *file-level* finding and deliberately nothing more. AppArmor loads profiles from the kernel's
228/// own policy cache, not from this path at call time, so a file can be edited without
229/// `apparmor_parser -r` ever running and a profile can be loaded from a file that has since been
230/// deleted. [`UsernsGrant`] is the behavioural answer and stays the source of truth; this only
231/// tells an operator whether the bytes on disk are the bytes murmur ships.
232#[derive(Debug, Clone, PartialEq, Eq)]
233pub enum InstalledProfileState {
234 /// The installed file hashes to [`SEALED_APPARMOR_PROFILE_SHA256`].
235 Matches,
236 /// The file is present and hashes to something else — an older revision, or an edited copy.
237 /// Operator customisation belongs in `/etc/apparmor.d/local/mur-sealed`, which both shipped
238 /// profiles `include if exists` and which is not hashed here.
239 Drifted {
240 /// Lowercase hex SHA-256 of the bytes actually on disk.
241 installed_sha256: String,
242 },
243 /// No file at [`SEALED_APPARMOR_PROFILE_PATH`]. Expected on a host without AppArmor, and on a
244 /// checkout build using `scripts/install-dev-apparmor.sh`, which writes its own separate file.
245 Absent,
246 /// The path exists and could not be read — a permission or I/O error, kept apart from
247 /// [`Self::Absent`] so "I could not look" never reads as "it is not there".
248 Unreadable {
249 /// The `std::io::Error`, rendered.
250 error: String,
251 },
252}
253
254/// Classifies a read of [`SEALED_APPARMOR_PROFILE_PATH`] against the shipped digest.
255///
256/// Takes the already-performed read rather than the path, so all four outcomes are unit-testable
257/// with no filesystem mutation and no privilege.
258#[must_use]
259pub fn classify_installed_profile(read: Result<Vec<u8>, std::io::Error>) -> InstalledProfileState {
260 match read {
261 Ok(bytes) => {
262 let installed_sha256 = murmur_artifact::sha256_hex(&bytes);
263 if installed_sha256 == SEALED_APPARMOR_PROFILE_SHA256 {
264 InstalledProfileState::Matches
265 } else {
266 InstalledProfileState::Drifted { installed_sha256 }
267 }
268 }
269 Err(error) if error.kind() == std::io::ErrorKind::NotFound => InstalledProfileState::Absent,
270 Err(error) => InstalledProfileState::Unreadable {
271 error: error.to_string(),
272 },
273 }
274}
275
276/// [`classify_installed_profile`] over the real [`SEALED_APPARMOR_PROFILE_PATH`]. Reads one file
277/// and needs no privilege.
278#[must_use]
279pub fn inspect_installed_profile() -> InstalledProfileState {
280 classify_installed_profile(std::fs::read(SEALED_APPARMOR_PROFILE_PATH))
281}
282
283/// The paths the two shipped profiles in `packaging/apparmor/mur-sealed` attach to, as their
284/// AppArmor path globs.
285///
286/// **Remediation text only.** Nothing decides anything by matching the running binary's path
287/// against this list: matching a path is inference, while reading `/proc/self/attr/current` is
288/// what the kernel actually did, and [`classify_profile_attachment`] uses only the latter. This
289/// exists so `mur doctor` can tell an operator running a binary from an unusual location where the
290/// shipped profile *does* attach, without pretending to have measured anything.
291pub const SEALED_APPARMOR_ATTACHMENT_PATHS: &[&str] = &[
292 "/{usr/local/,usr/,opt/mur/}bin/mur",
293 "@{HOME}/{.local,.cargo}/bin/mur",
294];
295
296/// Which AppArmor profile, if any, the kernel reports as confining *this* process.
297///
298/// AppArmor attaches profiles by executable path, so a `mur` at a bind mount, a build output or a
299/// test fixture gets no profile however correctly the shipped one is installed. That is invisible
300/// in [`UsernsGrant`] alone — it reports [`UsernsGrant::Withheld`] identically for "the profile is
301/// not installed" and "the profile is installed and does not attach here" — which is why this is
302/// reported beside it rather than folded into it.
303#[derive(Debug, Clone, PartialEq, Eq)]
304pub enum ProfileAttachment {
305 /// A profile whose name begins with [`SEALED_APPARMOR_PROFILE_NAME`] confines this process —
306 /// the shipped profile, or the checkout profile `scripts/install-dev-apparmor.sh` generates.
307 /// The same condition [`UsernsGrant::ProfileConfining`] is decided on.
308 Sealed {
309 /// The profile name as the kernel reports it, without the trailing enforcement mode.
310 profile: String,
311 },
312 /// Some other profile confines this process. It grants nothing murmur asked for, and it may
313 /// be *narrower* than unconfined — a snap or a container runtime profile, for instance.
314 Other {
315 /// The profile name as the kernel reports it, without the trailing enforcement mode.
316 profile: String,
317 },
318 /// The kernel reports `unconfined`: AppArmor is present and no profile attaches to this
319 /// binary's path.
320 Unconfined,
321 /// `/proc/self/attr/current` is absent or unreadable — no AppArmor on this host, or not
322 /// Linux. Never collapsed into [`Self::Unconfined`], on the same terms
323 /// [`InstalledProfileState::Unreadable`] is kept apart from
324 /// [`InstalledProfileState::Absent`]: "nothing attaches" and "I could not look" are different
325 /// findings.
326 NotReported,
327}
328
329/// Classifies the contents of `/proc/self/attr/current` into a [`ProfileAttachment`].
330///
331/// Takes the already-performed read rather than the path, so all four outcomes are unit-testable
332/// on any OS — the same split [`classify_installed_profile`] uses.
333///
334/// The kernel writes the profile name followed by its enforcement mode in parentheses
335/// (`mur-sealed (unconfined)`, `unconfined`), so the mode is stripped before the name is compared.
336#[must_use]
337pub fn classify_profile_attachment(current_profile: Option<&str>) -> ProfileAttachment {
338 let Some(raw) = current_profile
339 .map(str::trim)
340 .filter(|value| !value.is_empty())
341 else {
342 return ProfileAttachment::NotReported;
343 };
344 let name = raw
345 .split_once(" (")
346 .map_or(raw, |(name, _mode)| name)
347 .trim();
348 if name.is_empty() || name == "unconfined" {
349 return ProfileAttachment::Unconfined;
350 }
351 if name.starts_with(SEALED_APPARMOR_PROFILE_NAME) {
352 return ProfileAttachment::Sealed {
353 profile: name.to_string(),
354 };
355 }
356 ProfileAttachment::Other {
357 profile: name.to_string(),
358 }
359}
360
361/// [`classify_profile_attachment`] over the real `/proc/self/attr/current`. Holds no logic: the
362/// file is missing on every non-Linux host and on a Linux host without AppArmor, and both read as
363/// [`ProfileAttachment::NotReported`].
364#[must_use]
365pub fn inspect_profile_attachment() -> ProfileAttachment {
366 classify_profile_attachment(
367 std::fs::read_to_string("/proc/self/attr/current")
368 .ok()
369 .as_deref(),
370 )
371}
372
373// ---------------------------------------------------------------- blockers
374
375/// The one mechanism that stands between this host and `sealed`, named specifically enough that
376/// the refusal carries a command the operator can run.
377///
378/// Deliberately a small closed enum rather than a free-form string: `containment.rs` renders it,
379/// `sandbox.rs` derives it from the probe, and the manual-verification document asserts the exact
380/// text of each rendering.
381#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)]
382#[serde(rename_all = "snake_case")]
383pub enum SealedBlocker {
384 /// Not Linux. Mount namespaces do not exist here and never will — the same permanence
385 /// `EnforcementTier::EnvironmentOnly` carries.
386 NotLinux,
387 /// AppArmor's unprivileged-user-namespace restriction is active and this binary is not
388 /// confined by the shipped [`SEALED_APPARMOR_PROFILE_NAME`] profile.
389 AppArmorProfileMissing,
390 /// `unshare(CLONE_NEWUSER | CLONE_NEWNS)` was refused outright — the container case: no
391 /// `CAP_SYS_ADMIN`, or the container's own seccomp filter blocking the syscall.
392 NamespaceCreationDenied,
393 /// The namespace was created, but writing the identity `uid_map`/`gid_map` was refused, so the
394 /// process cannot own it. Nothing to do with `CAP_SYS_ADMIN` or containers.
395 IdMapDenied,
396 /// The namespace was created, but `mount(2)` inside it was refused. The signature of a
397 /// confinement that permits `userns_create` and then denies `CAP_SYS_ADMIN`.
398 MountDenied,
399 /// The namespace was created and `mount(2)` inside it worked, but the `pivot_root(2)`
400 /// sequence the composed root's step 6 performs did not. A separate finding from
401 /// [`Self::MountDenied`] because the two have separate remediations: `mount` is what an
402 /// AppArmor profile grants, while `pivot_root` is a distinct rule in that profile and a
403 /// distinct entry in a container runtime's seccomp allowlist, so a host can grant one and
404 /// refuse the other.
405 PivotRootDenied,
406 /// The kernel has no unprivileged user namespace support at all (`CONFIG_USER_NS=n`, or
407 /// `user.max_user_namespaces=0`).
408 KernelUnsupported,
409 /// Namespaces are available but Landlock is not, so the defence-in-depth layer `sealed`
410 /// is required to keep would be missing. `sealed` is strictly stronger than `scoped`; a host
411 /// that cannot back `scoped` cannot back `sealed` either.
412 LandlockUnavailable,
413}
414
415impl SealedBlocker {
416 /// Every variant, so a caller that has to reason about "which refusal did this host give"
417 /// cannot silently miss one. Added because a hand-maintained list of the expected refusal
418 /// texts in `murmur-cli`'s CLI test drifted the moment a variant was added, and the drift
419 /// showed up as a failing assertion about the *host* rather than about the list.
420 pub const ALL: &'static [SealedBlocker] = &[
421 SealedBlocker::NotLinux,
422 SealedBlocker::AppArmorProfileMissing,
423 SealedBlocker::NamespaceCreationDenied,
424 SealedBlocker::IdMapDenied,
425 SealedBlocker::MountDenied,
426 SealedBlocker::PivotRootDenied,
427 SealedBlocker::KernelUnsupported,
428 SealedBlocker::LandlockUnavailable,
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 unshare(CLONE_NEWUSER | CLONE_NEWNS), which this host refused. \
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+mount namespace, but writing the identity uid_map/gid_map \
470 into 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 }
510 }
511}
512
513// ---------------------------------------------------------------- host probe result
514
515/// Outcome of really rehearsing the composed root's construction in a forked child.
516///
517/// The states are not interchangeable: `Denied`, `MountDenied` and `PivotRootDenied` all mean "no
518/// sealed here" but point at completely different remediations (container capability, AppArmor
519/// profile, seccomp allowlist), which is the entire reason this is not a `bool`.
520#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
521pub(crate) enum NamespaceProbe {
522 /// The child completed the whole composed-root shape in its own throwaway namespace:
523 /// `unshare`, the identity maps, private propagation, a `tmpfs` over a root base candidate,
524 /// the parking directory, `pivot_root(2)` and `umount2(MNT_DETACH)`.
525 Ok,
526 /// `unshare` itself was refused (`EPERM`).
527 Denied,
528 /// `unshare` succeeded, but writing the identity `uid_map`/`gid_map` did not — the namespace
529 /// exists and cannot be owned. Distinct from [`Denied`](Self::Denied) because the remediation
530 /// is completely different: `Denied` points at `CAP_SYS_ADMIN` and container flags, while this
531 /// points at id-mapping policy. Collapsing the two sent an operator hunting a container
532 /// problem on a host that had created the namespace perfectly well.
533 MapDenied,
534 /// `unshare` succeeded, `mount(2)` inside the namespace did not — either the
535 /// `MS_REC | MS_PRIVATE` propagation change or the new root's `tmpfs`. The two fold together
536 /// because they are the same mechanism with the same remediation.
537 MountDenied,
538 /// Every mount succeeded and the `pivot_root(2)` sequence did not. Kept apart from
539 /// [`MountDenied`](Self::MountDenied) because a host can permit `mount(2)` and refuse
540 /// `pivot_root(2)`. Folding the two together lets such a host clear the launch probe and
541 /// fail at the first subprocess with `E-RUN-014`.
542 PivotRootDenied,
543 /// The kernel does not implement it (`ENOSYS`/`EINVAL`), or the probe could not run at all.
544 #[default]
545 Unsupported,
546}
547
548/// Everything the host probe learned about `sealed`, kept separate from the decision that uses it
549/// so `sandbox::tier_from_probe` stays pure.
550#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
551pub(crate) struct SealedProbe {
552 /// Where this host's permission to create an unprivileged user namespace comes from, or
553 /// [`UsernsGrant::Withheld`] when it gives none.
554 ///
555 /// Named for the question that actually matters rather than for "is the profile loaded",
556 /// because a Fedora or Arch host with no AppArmor at all needs no profile and must not be
557 /// refused for lacking one. Every decision below reads only
558 /// [`UsernsGrant::permits_userns`]; the rest of the value is provenance, reported and never
559 /// acted on.
560 pub(crate) userns_grant: UsernsGrant,
561 /// What a real rehearsal of the composed root's construction — through `pivot_root(2)` and
562 /// `umount2(MNT_DETACH)` — did in a forked child.
563 pub(crate) namespace: NamespaceProbe,
564}
565
566/// Which single mechanism to blame, given the probe. Pure; the ordering is the order an operator
567/// has to fix things in.
568///
569/// `None` means every precondition holds and this host can back `sealed`.
570pub(crate) fn sealed_blocker(
571 is_linux: bool,
572 landlock_fully_enforced: bool,
573 probe: SealedProbe,
574) -> Option<SealedBlocker> {
575 if !is_linux {
576 return Some(SealedBlocker::NotLinux);
577 }
578 // Attribute to AppArmor before the namespace outcome: when the restriction is on and the
579 // shipped profile is not loaded, the namespace failure is a *consequence*, and naming
580 // `--cap-add SYS_ADMIN` on a bare host sends the operator somewhere useless.
581 if !probe.userns_grant.permits_userns() {
582 return Some(SealedBlocker::AppArmorProfileMissing);
583 }
584 match probe.namespace {
585 NamespaceProbe::Ok => {
586 if landlock_fully_enforced {
587 None
588 } else {
589 Some(SealedBlocker::LandlockUnavailable)
590 }
591 }
592 NamespaceProbe::Denied => Some(SealedBlocker::NamespaceCreationDenied),
593 NamespaceProbe::MapDenied => Some(SealedBlocker::IdMapDenied),
594 NamespaceProbe::MountDenied => Some(SealedBlocker::MountDenied),
595 NamespaceProbe::PivotRootDenied => Some(SealedBlocker::PivotRootDenied),
596 NamespaceProbe::Unsupported => Some(SealedBlocker::KernelUnsupported),
597 }
598}
599
600// ---------------------------------------------------------------- the composed root, declared
601
602/// Host directories bind-mounted read-only into every composed root, in order.
603///
604/// Fixed, not derived: a whole-tree bind of `/usr` plus the non-usrmerge spellings of the same
605/// tree. On a usrmerge distro `/bin`, `/sbin`, `/lib`,
606/// `/lib64`… are *symlinks* into `/usr`; [`plan_composed_root`] recreates them as symlinks rather
607/// than bind-mounting through them, so the composed root has the same shape as the host and a
608/// hashbang like `#!/bin/sh` resolves.
609///
610/// Deliberately a whole-directory bind rather than a curated per-file set: the point of `sealed`
611/// is that it *deletes* the ELF-closure derivation `scoped` needs, along with its lock-time
612/// pinning problem. `capabilities.shell.staged_runtime` is **additive** to this list, not a
613/// replacement for it: a declared runtime tree arrives through
614/// [`plan_composed_root`]'s own `staged_runtime_read_only` parameter, as a *required* bind, while
615/// this fixed list stays exactly as it is. Anything a `shell.allow` binary needs at runtime must
616/// therefore be under one of these, under a staged runtime tree, or under a directory derived
617/// from the manifest — see `extra_read_only` in [`plan_composed_root`].
618pub const SEALED_RUNTIME_PATHS: &[&str] = &[
619 "/usr", "/bin", "/sbin", "/lib", "/lib32", "/lib64", "/libx32",
620];
621
622/// One entry of the fixed `/etc` allowlist: the host path to bind, plus whether it is a directory.
623///
624/// The `directory` bit exists because the same list drives two mechanisms with different needs.
625/// [`plan_composed_root`] does not need it — it asks the host what each path is via
626/// [`HostLayout::kind`] and mirrors accordingly. `sandbox::resolve_sealed_etc_landlock_grants` does:
627/// it must decide `ReadDir` per entry, and it is required to be pure and syscall-free (it runs on
628/// any platform, for paths that need not exist). Carrying the classification *in the list* rather
629/// than in a second parallel constant is what makes the bound set and the granted set structurally
630/// incapable of drifting apart — the same reason [`SealedDevice`] carries its own `writable` bit
631/// rather than deriving it from a separate table.
632///
633/// It is a static claim about mainstream Linux, not a fact read off this host: `/etc/ssl` and
634/// friends are directories on Debian, Ubuntu, Fedora, RHEL, Arch and Alpine alike. A host that
635/// disagrees costs the entry its enumerability and nothing else — `open_landlock_fds` narrows
636/// `list_dir` back to `false` for anything that does not `fstat` as a directory, because the kernel
637/// rejects (`EINVAL`) a `ReadDir` rule on a non-directory and that would otherwise refuse the whole
638/// launch.
639#[derive(Debug, Clone, Copy, PartialEq, Eq)]
640pub struct SealedEtcPath {
641 /// Absolute path inside the composed root. Also the host path bind-mounted there, except for
642 /// an entry carrying [`Self::synthetic`], whose content the parent writes itself.
643 pub path: &'static str,
644 /// Whether this path is a directory on a mainstream Linux host, and therefore whether its
645 /// Landlock rule carries `ReadDir`. `false` for regular files and for symlinks (the symlink is
646 /// followed when the rule's fd is opened, so `/etc/localtime`'s rule lands on the zoneinfo file
647 /// it points at).
648 pub directory: bool,
649 /// `Some` when [`Self::path`] is backed by a file the *parent* synthesises rather than by the
650 /// host's own file at that path — the account databases, and nothing else.
651 ///
652 /// Carried on the entry for the same reason as `directory`: the alternative is a second
653 /// parallel list of "the ones we do not mirror", which is free to drift out of step with this
654 /// one. Typed rather than a `bool` so the content each synthetic entry gets is decided by an
655 /// exhaustive `match` — adding a third one is then a compile error until it is given content,
656 /// instead of a launch-time failure to bind a file nobody wrote.
657 pub synthetic: Option<SyntheticEtcFile>,
658}
659
660/// An `/etc` file a composed root gets in synthesised form instead of the host's.
661///
662/// Both are account databases, and both are world-readable on every distribution — so binding the
663/// host's copy hands the capsule the machine's full account list for no benefit. What a capsule
664/// actually needs from them is that `getpwuid(3)`/`getgrgid(3)` resolve *its own* id: a shell's `~`
665/// expansion, `os.path.expanduser`, `whoami`, and anything reading `$HOME` through the password
666/// database. Two entries — `root` and the id the capsule runs as — satisfy all of that.
667#[derive(Debug, Clone, Copy, PartialEq, Eq)]
668pub enum SyntheticEtcFile {
669 /// `/etc/passwd`, in the seven colon-separated fields of `passwd(5)`.
670 Passwd,
671 /// `/etc/group`, in the four colon-separated fields of `group(5)`.
672 Group,
673}
674
675/// The uid/gid a capsule's subprocesses run as, and the `HOME` they are given.
676///
677/// Read off the session workdir the parent created rather than from `getuid(2)`: this crate is
678/// `#![deny(unsafe_code)]` and the workdir's owner *is* the spawning process's identity, so
679/// `std::fs::metadata(workdir).uid()` answers the question in safe std — the same pattern
680/// `crate::resources` already uses.
681#[derive(Debug, Clone, Copy, PartialEq, Eq)]
682pub(crate) struct SealedAccountIdentity<'a> {
683 pub(crate) uid: u32,
684 pub(crate) gid: u32,
685 /// The synthetic `HOME`, exactly as `crate::shell::build_shell_env` sets it in the subprocess
686 /// environment. Byte-identical or the whole exercise is pointless: a `pw_dir` that disagrees
687 /// with `$HOME` is worse than no entry at all, because tools silently pick one of the two.
688 pub(crate) home: &'a str,
689}
690
691/// Login shell recorded for both synthetic accounts. Present in the composed root
692/// (`/bin` is on [`SEALED_RUNTIME_PATHS`]) and *not* thereby made runnable — the exec allowlist is
693/// Landlock's, and a shell field is a string in a database, not a grant.
694const SYNTHETIC_LOGIN_SHELL: &str = "/bin/sh";
695
696/// Account and group name for the capsule's own entry. Deliberately not the host's login name:
697/// that name is itself part of the account list this file exists to stop leaking.
698const SYNTHETIC_ACCOUNT_NAME: &str = "capsule";
699
700impl SyntheticEtcFile {
701 /// File name under [`SEALED_ETC_STAGING_DIR_NAME`]. The basename of the target path, so the
702 /// staging directory reads like the `/etc` it stands in for.
703 pub(crate) fn file_name(self) -> &'static str {
704 match self {
705 Self::Passwd => "passwd",
706 Self::Group => "group",
707 }
708 }
709
710 /// The exact bytes written to the staging file, for one capsule identity.
711 ///
712 /// At most two lines: `root`, plus the capsule's own id when that is not already 0. A capsule
713 /// running as root gets **one** line — the `root` line, with `$HOME` as its home directory —
714 /// rather than a duplicate entry for uid 0, which `getpwuid(3)` would resolve to whichever came
715 /// first.
716 ///
717 /// Fails rather than emits a corrupt database when the home path contains a field separator or
718 /// a newline: `passwd(5)` has no escaping, so a workdir named with a `:` would silently shift
719 /// every field after it. Parent-side and synchronous, so it surfaces as a named launch failure.
720 pub(crate) fn render(self, identity: &SealedAccountIdentity) -> Result<String, String> {
721 let SealedAccountIdentity { uid, gid, home } = *identity;
722 match self {
723 Self::Passwd => {
724 if home.contains(':') || home.contains('\n') {
725 return Err(format!(
726 "sealed: the synthetic HOME {home} contains a character /etc/passwd cannot \
727 quote (':' or a newline), so no valid passwd entry can name it"
728 ));
729 }
730 // name:password:uid:gid:gecos:home:shell — `x` in the password field is the
731 // universal "look in shadow", and there is no shadow file in a composed root.
732 if uid == 0 {
733 return Ok(format!(
734 "root:x:0:{gid}:root:{home}:{SYNTHETIC_LOGIN_SHELL}\n"
735 ));
736 }
737 Ok(format!(
738 "root:x:0:0:root:/root:{SYNTHETIC_LOGIN_SHELL}\n\
739 {SYNTHETIC_ACCOUNT_NAME}:x:{uid}:{gid}:Murmur capsule:{home}:\
740 {SYNTHETIC_LOGIN_SHELL}\n"
741 ))
742 }
743 // name:password:gid:members — the member list is empty because membership of the
744 // capsule's own group comes from its passwd entry's gid field.
745 Self::Group => {
746 if gid == 0 {
747 return Ok("root:x:0:\n".to_string());
748 }
749 Ok(format!("root:x:0:\n{SYNTHETIC_ACCOUNT_NAME}:x:{gid}:\n"))
750 }
751 }
752 }
753}
754
755/// The only `/etc` entries that exist inside a composed root, each bind-mounted read-only and each
756/// silently skipped when the host does not have it.
757///
758/// An allowlist, never `/etc` wholesale, and never a denylist of sensitive names: `/etc/shadow`,
759/// `/etc/sudoers`, `/etc/ssh`, cloud-init credentials and every future addition to `/etc` are
760/// absent by construction rather than by enumeration. The entries here are the ones without which
761/// ordinary tooling misreports rather than fails cleanly — the dynamic loader's cache, the TLS
762/// trust store, DNS configuration, the timezone, and the passwd/group databases `getpwuid(3)`
763/// needs.
764///
765/// `/etc/passwd` and `/etc/group` are the two entries the host does *not* supply. They are
766/// world-readable on every distribution, so binding the host's copies would hand the capsule the
767/// machine's full account list; instead the parent writes a two-line database naming only `root`
768/// and the capsule's own id, and the composed root binds *that* at the same path, read-only. See
769/// [`SyntheticEtcFile`]. It stays on this list because the list is also the grant list — an entry
770/// removed from here loses its Landlock rule and becomes unreadable, which is the opposite of what
771/// this narrowing is for.
772///
773/// Binding these is only half the job: Landlock still mediates *inside* the composed root, so an
774/// entry bound here but absent from the ruleset exists and is unopenable — a trust store present
775/// at `/etc/ssl/certs/ca-certificates.crt` but `EACCES` on open fails TLS verification.
776/// `sandbox::resolve_sealed_etc_landlock_grants` turns this list into the matching read grants.
777/// Keep the two sets aligned. A Landlock rule names the *inode* an fd resolved to, not the path
778/// string it was opened by, so an entry whose bind source is not the host path of the same name
779/// needs its rule taken on the file actually bound — see `sandbox::LandlockChildFds`'s
780/// `sealed_identity_fds`, which does that for the two synthetic entries.
781pub const SEALED_ETC_PATHS: &[SealedEtcPath] = &[
782 SealedEtcPath {
783 path: "/etc/ld.so.cache",
784 directory: false,
785 synthetic: None,
786 },
787 SealedEtcPath {
788 path: "/etc/ld.so.conf",
789 directory: false,
790 synthetic: None,
791 },
792 SealedEtcPath {
793 path: "/etc/ld.so.conf.d",
794 directory: true,
795 synthetic: None,
796 },
797 SealedEtcPath {
798 path: "/etc/alternatives",
799 directory: true,
800 synthetic: None,
801 },
802 SealedEtcPath {
803 path: "/etc/ssl",
804 directory: true,
805 synthetic: None,
806 },
807 SealedEtcPath {
808 path: "/etc/pki",
809 directory: true,
810 synthetic: None,
811 },
812 SealedEtcPath {
813 path: "/etc/ca-certificates",
814 directory: true,
815 synthetic: None,
816 },
817 SealedEtcPath {
818 path: "/etc/ca-certificates.conf",
819 directory: false,
820 synthetic: None,
821 },
822 SealedEtcPath {
823 path: "/etc/resolv.conf",
824 directory: false,
825 synthetic: None,
826 },
827 SealedEtcPath {
828 path: "/etc/hosts",
829 directory: false,
830 synthetic: None,
831 },
832 SealedEtcPath {
833 path: "/etc/nsswitch.conf",
834 directory: false,
835 synthetic: None,
836 },
837 SealedEtcPath {
838 path: "/etc/localtime",
839 directory: false,
840 synthetic: None,
841 },
842 SealedEtcPath {
843 path: "/etc/timezone",
844 directory: false,
845 synthetic: None,
846 },
847 SealedEtcPath {
848 path: "/etc/terminfo",
849 directory: true,
850 synthetic: None,
851 },
852 SealedEtcPath {
853 path: "/etc/passwd",
854 directory: false,
855 synthetic: Some(SyntheticEtcFile::Passwd),
856 },
857 SealedEtcPath {
858 path: "/etc/group",
859 directory: false,
860 synthetic: Some(SyntheticEtcFile::Group),
861 },
862];
863
864/// One entry of the private `/dev` tmpfs.
865///
866/// `major`/`minor`/`mode` are recorded for the record and for the manual-verification document to
867/// assert against — they are **not** used to create the node. Device nodes cannot be `mknod`ed
868/// from inside a non-initial user namespace at all (the kernel refuses regardless of
869/// `CAP_MKNOD`), so each entry is created as an empty file in the tmpfs and the host's node is
870/// bind-mounted over it. This is what bubblewrap and rootless podman do, and it means the numbers
871/// a capsule actually sees are the host's — which on every mainstream distribution are exactly the
872/// ones below.
873#[derive(Debug, Clone, Copy, PartialEq, Eq)]
874pub struct SealedDevice {
875 /// Name under `/dev`, without the directory.
876 pub name: &'static str,
877 /// The host path bind-mounted over the tmpfs entry — `/dev/` + [`Self::name`], spelled out
878 /// because it is also what the composed root's Landlock rule set is built from.
879 pub path: &'static str,
880 pub major: u32,
881 pub minor: u32,
882 pub mode: u32,
883 /// Whether the composed root's Landlock rule for this device carries `WriteFile` on top of
884 /// `ReadFile`.
885 ///
886 /// Landlock still mediates inside a composed root — that is the whole point of keeping it as
887 /// defence in depth — so a device present in `/dev` but absent from the rule set would exist
888 /// and be unopenable, which is worse than not being there. The read-only pair is the two
889 /// entropy sources, where nothing legitimate writes; the rest carry write because their write
890 /// side is defined to discard (`null`, `zero`), to report `ENOSPC` (`full`), or to be a
891 /// terminal (`tty`).
892 pub writable: bool,
893}
894
895/// The OCI runtime-spec default device set, which is what a `sealed` capsule's `/dev` contains and
896/// all it contains.
897///
898/// This is deliberately *independent* of `sandbox::CAPSULE_DEVICE_GRANTS`, the three-device
899/// Landlock grant set that services `scoped`. That set is a rule list layered over the host's real
900/// `/dev`; this one is the entire contents of a private tmpfs. They answer different questions and
901/// neither should be derived from the other.
902///
903/// `/dev/console` is omitted (the OCI spec creates it only for a terminal-attached container, and
904/// a capsule subprocess has pipes, not a controlling terminal). `/dev/ptmx` and `/dev/pts` come
905/// from a `devpts` mount rather than from this list, and `/dev/shm` is omitted deliberately: it is
906/// writable, and the session workdir is the only writable path in a composed root.
907pub const SEALED_DEVICE_NODES: &[SealedDevice] = &[
908 SealedDevice {
909 name: "null",
910 path: "/dev/null",
911 major: 1,
912 minor: 3,
913 mode: 0o666,
914 writable: true,
915 },
916 SealedDevice {
917 name: "zero",
918 path: "/dev/zero",
919 major: 1,
920 minor: 5,
921 mode: 0o666,
922 writable: true,
923 },
924 SealedDevice {
925 name: "full",
926 path: "/dev/full",
927 major: 1,
928 minor: 7,
929 mode: 0o666,
930 writable: true,
931 },
932 SealedDevice {
933 name: "random",
934 path: "/dev/random",
935 major: 1,
936 minor: 8,
937 mode: 0o666,
938 writable: false,
939 },
940 SealedDevice {
941 name: "urandom",
942 path: "/dev/urandom",
943 major: 1,
944 minor: 9,
945 mode: 0o666,
946 writable: false,
947 },
948 SealedDevice {
949 name: "tty",
950 path: "/dev/tty",
951 major: 5,
952 minor: 0,
953 mode: 0o666,
954 writable: true,
955 },
956];
957
958/// The OCI default `/dev` symlinks, `(link name under /dev, target)`.
959pub const SEALED_DEVICE_SYMLINKS: &[(&str, &str)] = &[
960 ("fd", "/proc/self/fd"),
961 ("stdin", "/proc/self/fd/0"),
962 ("stdout", "/proc/self/fd/1"),
963 ("stderr", "/proc/self/fd/2"),
964 ("ptmx", "pts/ptmx"),
965];
966
967/// Directory created inside the session workdir and bind-mounted at `/tmp` in the composed root.
968///
969/// `/tmp` has to exist — bash heredocs, `mktemp`, compilers and package managers all fail without
970/// it in ways that read as runtime bugs rather than as policy. Backing it with a directory *inside
971/// the workdir* is what keeps "the capsule's workdir is the only writable path" literally true:
972/// the bytes written to `/tmp` land in the workdir, are counted by the existing workdir-size guard
973/// (`capabilities.resources.workdir_max_bytes`), and are discarded with the session.
974///
975/// Under `--workdir` the session workdir is `<accessible workdir>/.murmur/<session id>/`, so this
976/// directory sits under the one prefix a consumer diffing the accessible workdir excludes, and
977/// inside the subtree `WorkdirGuard` measures. Declared as a runtime write by
978/// [`crate::workdir_writes`]. The capsule never names this path: it sees `/tmp`.
979pub const SEALED_TMP_DIR_NAME: &str = ".mur-tmp";
980
981/// Directory created inside the session workdir holding the synthetic `/etc` files, each of which
982/// the composed root binds read-only over its [`SEALED_ETC_PATHS`] target.
983///
984/// Inside the session workdir for the same reason as [`SEALED_TMP_DIR_NAME`], and in the same
985/// place: it is the one host directory the parent already owns, already creates per session, and
986/// already discards with it, and under `--workdir` it is under `.murmur/<session id>/` rather than
987/// at the top level of what the capsule was asked to produce. The capsule can read — and, since
988/// the workdir is its one writable path, rewrite — its own copy here; that buys it nothing,
989/// because the file is never read by anything outside the capsule and names no account the capsule
990/// is not already running as. The capsule sees `/etc/passwd` and `/etc/group`, never this path.
991pub const SEALED_ETC_STAGING_DIR_NAME: &str = ".mur-etc";
992
993/// Host path of the staging file backing one synthetic `/etc` entry.
994///
995/// The single place this path is composed. Three callers need to agree on it byte for byte — the
996/// planner that binds it, the parent that writes it, and the Landlock fd that grants it — and two
997/// of them run in different phases of the launch.
998pub(crate) fn synthetic_etc_source(session_workdir: &Path, file: SyntheticEtcFile) -> PathBuf {
999 session_workdir
1000 .join(SEALED_ETC_STAGING_DIR_NAME)
1001 .join(file.file_name())
1002}
1003
1004/// Directory the old root is parked in between `pivot_root(2)` and `umount2(MNT_DETACH)`.
1005/// Removed immediately afterwards, so it does not exist for any process the capsule can run.
1006const OLD_ROOT_NAME: &str = ".mur-oldroot";
1007
1008/// [`OLD_ROOT_NAME`] relative, and rooted at `/` after the pivot, as NUL-terminated literals.
1009///
1010/// Same reason [`SEALED_ROOT_BASE_CANDIDATES_C`] exists: the probe child rehearses the pivot after
1011/// `fork()`, where composing a `CString` is not allowed. `old_root_names_agree` keeps the three
1012/// spellings in step.
1013pub(crate) const OLD_ROOT_NAME_C: &std::ffi::CStr = c".mur-oldroot";
1014
1015/// [`OLD_ROOT_NAME`] as it is named from inside the new root, once `pivot_root(2)` has parked the
1016/// old one there.
1017pub(crate) const OLD_ROOT_PIVOTED_C: &std::ffi::CStr = c"/.mur-oldroot";
1018
1019/// Base directories the new-root `tmpfs` may be mounted over, most preferred first.
1020///
1021/// The base is overmounted inside the private namespace, so whatever the host has there becomes
1022/// unreachable for the rest of the setup — which is why the chosen base must not be an ancestor of
1023/// the session workdir (a workdir under `/tmp` is entirely normal) and must not be one of the
1024/// directories the composed root goes on to bind-mount from.
1025pub const SEALED_ROOT_BASE_CANDIDATES: &[&str] = &["/tmp", "/run", "/var/tmp", "/mnt", "/media"];
1026
1027/// [`SEALED_ROOT_BASE_CANDIDATES`] as NUL-terminated literals, in the same order.
1028///
1029/// The host probe's child rehearses the composed root inside a `fork()`, where composing a
1030/// `CString` would touch the allocator a sibling thread of the parent may have held frozen at fork
1031/// time. Static literals are the only form usable there. `probe_root_base_candidates_match_the_str_list`
1032/// keeps the two lists identical, so the probe can never rehearse a base the real construction
1033/// would not choose.
1034pub(crate) const SEALED_ROOT_BASE_CANDIDATES_C: &[&std::ffi::CStr] =
1035 &[c"/tmp", c"/run", c"/var/tmp", c"/mnt", c"/media"];
1036
1037/// `hidepid` spellings tried, in order, when mounting the composed root's `/proc` with
1038/// `mount -t proc`. The numeric form is the legacy parser's; `invisible` is the Linux 5.8+
1039/// spelling; the empty string is a plain private `procfs` with no masking.
1040///
1041/// **On a bare host none of the three succeeds, and that is a kernel rule.**
1042/// `proc_fill_super` requires `CAP_SYS_ADMIN` over the user namespace *owning the PID namespace*.
1043/// `unshare(CLONE_NEWUSER | CLONE_NEWNS)` leaves the process in the host's initial PID namespace,
1044/// whose owner is the initial user namespace — where an unprivileged process has no capabilities
1045/// at all — so every `mount -t proc` returns `EPERM`. Adding `CLONE_NEWPID` does not help either:
1046/// `unshare` moves only *future children* into the new PID namespace, so the mounting process is
1047/// still judged against the old one. Mounting a private `procfs` unprivileged genuinely requires
1048/// forking so the child becomes PID 1, which changes reaping and signal semantics for the whole
1049/// capsule subprocess tree and is out of scope here (see the PID-namespace note in
1050/// `docs/content/reference/sealed-containment-manual-verification.md`).
1051///
1052/// They are still tried first, and in this order, because they *do* succeed where the process has
1053/// that privilege — as root, and inside a container run with `--cap-add SYS_ADMIN` — and because
1054/// a later PID-namespace slice makes them succeed everywhere without touching this list.
1055///
1056/// When they all fail the executor binds the host's `/proc` instead. The capsule can then
1057/// enumerate host PIDs and read the `/proc/<pid>/root` and `/proc/<pid>/cwd` symlinks, so `/proc`
1058/// is the one part of the composed root where "outside does not exist" degrades to `scoped`'s
1059/// "outside is denied" — Landlock's ruleset covers no path under `/proc`, so opens through it are
1060/// 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
1061/// breaks `/dev/fd`, process substitution and every runtime that reads `/proc/self/*`.
1062const PROC_HIDEPID_OPTIONS: &[&str] = &["hidepid=2", "hidepid=invisible", ""];
1063
1064// ---------------------------------------------------------------- host layout (test seam)
1065
1066/// What a path is on the host, as far as composing a root cares.
1067#[derive(Debug, Clone, PartialEq, Eq)]
1068pub(crate) enum PathKind {
1069 Dir,
1070 /// Any non-directory: a regular file, a device node, a socket. All are bind-mount targets that
1071 /// need an empty *file* created for them rather than a directory.
1072 File,
1073 /// A symlink, with its literal (un-resolved) target.
1074 Symlink(PathBuf),
1075}
1076
1077/// The host filesystem facts [`plan_composed_root`] reads. A trait purely so the planner can be
1078/// unit-tested against a synthetic usrmerge / non-usrmerge layout on any machine — production has
1079/// exactly one implementation, [`RealHostLayout`].
1080pub(crate) trait HostLayout {
1081 fn kind(&self, path: &Path) -> Option<PathKind>;
1082}
1083
1084/// The real filesystem, read with `symlink_metadata` so a usrmerge `/bin -> usr/bin` reports as a
1085/// symlink rather than as the directory it points at.
1086pub(crate) struct RealHostLayout;
1087
1088impl HostLayout for RealHostLayout {
1089 fn kind(&self, path: &Path) -> Option<PathKind> {
1090 let meta = std::fs::symlink_metadata(path).ok()?;
1091 if meta.file_type().is_symlink() {
1092 return Some(PathKind::Symlink(std::fs::read_link(path).ok()?));
1093 }
1094 if meta.is_dir() {
1095 return Some(PathKind::Dir);
1096 }
1097 Some(PathKind::File)
1098 }
1099}
1100
1101// ---------------------------------------------------------------- the plan
1102
1103/// One filesystem operation in the composed-root plan.
1104#[derive(Debug, Clone, PartialEq, Eq)]
1105pub(crate) enum RootOp {
1106 /// `mkdir(path, 0755)`. `EEXIST` is success.
1107 MkDir(PathBuf),
1108 /// Create an empty regular file, to be bind-mounted over.
1109 MkFile(PathBuf),
1110 /// `symlink(target, link)` — used to reproduce the host's usrmerge shape and the OCI `/dev`
1111 /// symlinks.
1112 Symlink { target: PathBuf, link: PathBuf },
1113 /// `mount(source, target, MS_BIND | MS_REC)`, followed by a second
1114 /// `MS_REMOUNT | MS_BIND | MS_RDONLY` call when `read_only`. The second call is required: a
1115 /// single `MS_BIND | MS_RDONLY` mount does *not* produce a read-only bind.
1116 Bind {
1117 source: PathBuf,
1118 target: PathBuf,
1119 read_only: bool,
1120 },
1121 /// A fresh `tmpfs`.
1122 Tmpfs {
1123 target: PathBuf,
1124 options: &'static str,
1125 },
1126 /// A `/proc`: a fresh `procfs` masked with `hidepid` where the kernel permits one, a bind of
1127 /// the host's where it does not. Which of the two a host gets is decided at execution time, not
1128 /// here — see [`PROC_HIDEPID_OPTIONS`].
1129 Proc { target: PathBuf },
1130 /// A fresh `devpts` instance, so the capsule can allocate its own ptys without seeing the
1131 /// host's.
1132 DevPts { target: PathBuf },
1133 /// `mount(NULL, target, NULL, MS_REMOUNT | MS_BIND | MS_RDONLY)` — used to seal `/dev` after
1134 /// its device nodes have been bound into it.
1135 RemountReadOnly(PathBuf),
1136}
1137
1138/// One planned operation plus whether failing it aborts the launch.
1139///
1140/// `required: false` is the "shrink, do not fail" convention this crate already uses for optional
1141/// device grants in `sandbox::open_landlock_fds`: losing an optional bind makes the composed root
1142/// *narrower*, never wider, so refusing the launch over it would trade a working capsule for no
1143/// security gain.
1144#[derive(Debug, Clone, PartialEq, Eq)]
1145pub(crate) struct RootStep {
1146 pub(crate) op: RootOp,
1147 pub(crate) required: bool,
1148}
1149
1150/// The full, host-resolved recipe for one composed root.
1151#[derive(Debug, Clone, PartialEq, Eq)]
1152pub(crate) struct ComposedRootPlan {
1153 /// Directory the new-root `tmpfs` is mounted over, and the directory `pivot_root` is handed.
1154 pub(crate) base: PathBuf,
1155 pub(crate) steps: Vec<RootStep>,
1156 /// The workdir's path *inside* the composed root. Identical to its host path by design: env
1157 /// vars, `PWD`, log paths and anything a previous turn recorded keep working, and the capsule
1158 /// never has to be told it moved.
1159 pub(crate) workdir_in_root: PathBuf,
1160}
1161
1162/// Picks the directory the new-root `tmpfs` is mounted over.
1163///
1164/// Rejects any candidate that is the workdir or an ancestor of it — overmounting an ancestor of
1165/// the workdir would hide the very directory the plan then has to bind-mount — and any candidate
1166/// the host does not have. Pure apart from the injected `exists` predicate.
1167pub(crate) fn choose_root_base(
1168 workdir: &Path,
1169 candidates: &[&str],
1170 exists: impl Fn(&Path) -> bool,
1171) -> Option<PathBuf> {
1172 candidates
1173 .iter()
1174 .map(Path::new)
1175 .find(|candidate| !workdir.starts_with(candidate) && exists(candidate))
1176 .map(Path::to_path_buf)
1177}
1178
1179/// Builds the ordered operation list for one composed root. Pure with respect to `host`.
1180///
1181/// `extra_read_only` carries the host directories this *particular* capsule needs beyond
1182/// [`SEALED_RUNTIME_PATHS`] — the containing directory of each resolved `shell.allow` binary and
1183/// each `capabilities.shell.interpreter_runtime` directory. They are whole-directory binds, not a
1184/// per-file grant set: a bind carries a directory's whole contents, so this must not grow into a
1185/// second ELF-closure derivation. Entries already covered by a fixed path are dropped.
1186///
1187/// `staged_runtime_read_only` carries the `source_path` of each declared
1188/// `capabilities.shell.staged_runtime` grant. It is a *separate* parameter from `extra_read_only`
1189/// rather than more entries in it, because the two have opposite failure semantics and the
1190/// difference is the whole point of the capability. See [`PlanBuilder::require_bind`].
1191pub(crate) fn plan_composed_root(
1192 accessible_workdir: &Path,
1193 session_workdir: &Path,
1194 base: &Path,
1195 extra_read_only: &[PathBuf],
1196 staged_runtime_read_only: &[PathBuf],
1197 host: &dyn HostLayout,
1198) -> ComposedRootPlan {
1199 let mut builder = PlanBuilder::new(base);
1200
1201 // 0. Declared `staged_runtime` trees, read-only and REQUIRED — before everything else.
1202 //
1203 // Ordering is load-bearing, not tidiness. `mirror` below deduplicates by target path via
1204 // the `made` set, so whichever loop registers a target first wins it and any later loop
1205 // silently no-ops on the same target. Running the required binds first means a staged path
1206 // that happens to collide with an optional one can only ever be *upgraded* to required,
1207 // never silently downgraded to optional.
1208 //
1209 // These are also planned ahead of /dev, /proc, /tmp and the workdir bind — and therefore
1210 // ahead of `pivot_root`, which `construct_composed_root` performs only after every planned
1211 // step has run. A capsule never reaches a shell tool call inside a root that is missing a
1212 // runtime tree it declared.
1213 for path in staged_runtime_read_only {
1214 builder.require_bind(path);
1215 }
1216
1217 // 1. The host runtime tree, read-only. A usrmerge symlink is reproduced as a symlink so the
1218 // composed root has the host's shape; a real directory is bind-mounted.
1219 for path in SEALED_RUNTIME_PATHS
1220 .iter()
1221 .map(Path::new)
1222 .chain(extra_read_only.iter().map(PathBuf::as_path))
1223 {
1224 builder.mirror(path, host, /* required */ false);
1225 }
1226
1227 // 2. The narrow /etc allowlist, read-only. Everything else under /etc simply does not exist.
1228 //
1229 // An entry marked synthetic is not mirrored from the host at all: its target is bound from
1230 // the staging file the parent wrote inside the workdir, exactly as /tmp is in step 5 below.
1231 // `required: true` there, unlike the host-mirrored entries' `required: false` — those are
1232 // optional because a distribution may genuinely not have them, whereas the parent wrote
1233 // these two itself, so a bind that fails is a broken runtime rather than a narrower one, and
1234 // the empty `MkFile` left in its place would be a passwd database with no accounts in it.
1235 for entry in SEALED_ETC_PATHS {
1236 let path = Path::new(entry.path);
1237 match entry.synthetic {
1238 None => builder.mirror(path, host, /* required */ false),
1239 Some(file) => builder.bind_file(
1240 synthetic_etc_source(session_workdir, file),
1241 rebase(base, path),
1242 /* required */ true,
1243 ),
1244 }
1245 }
1246
1247 // 3. A private /dev tmpfs carrying the OCI default device set, sealed read-only once it is
1248 // populated. Device nodes are bind-mounted from the host because `mknod` of a device is
1249 // refused inside a user namespace — see `SealedDevice`.
1250 let dev = base.join("dev");
1251 builder.mkdir_p(&dev);
1252 builder.push(
1253 RootOp::Tmpfs {
1254 target: dev.clone(),
1255 options: "mode=0755,size=1m",
1256 },
1257 true,
1258 );
1259 for device in SEALED_DEVICE_NODES {
1260 let source = PathBuf::from(device.path);
1261 if host.kind(&source).is_none() {
1262 continue;
1263 }
1264 let target = dev.join(device.name);
1265 builder.push(RootOp::MkFile(target.clone()), false);
1266 builder.push(
1267 RootOp::Bind {
1268 source,
1269 target,
1270 read_only: false,
1271 },
1272 false,
1273 );
1274 }
1275 let pts = dev.join("pts");
1276 builder.push(RootOp::MkDir(pts.clone()), false);
1277 builder.push(RootOp::DevPts { target: pts }, false);
1278 for (link, target) in SEALED_DEVICE_SYMLINKS {
1279 builder.push(
1280 RootOp::Symlink {
1281 target: PathBuf::from(*target),
1282 link: dev.join(link),
1283 },
1284 false,
1285 );
1286 }
1287 builder.push(RootOp::RemountReadOnly(dev), false);
1288
1289 // 4. /proc — masked with hidepid where the kernel allows it, bound from the host where it does
1290 // not. See `PROC_HIDEPID_OPTIONS`.
1291 let proc = base.join("proc");
1292 builder.push(RootOp::MkDir(proc.clone()), true);
1293 builder.push(RootOp::Proc { target: proc }, true);
1294
1295 // 5. /tmp, backed by a directory inside the session workdir so it stays inside the one
1296 // writable path and inside the workdir size budget.
1297 //
1298 // Before the workdir, not after, and the ordering is load-bearing rather than tidy. A
1299 // session workdir under `/tmp` is not an edge case — it is `mur run`'s default, since
1300 // `--workdir` falls back to a temporary directory. With the workdir reproduced first, this
1301 // mount landed *on top of* the path leading to it, the workdir bind vanished under it, and
1302 // the construction died at `chdir into the workdir inside the root: ENOENT` — after the
1303 // pivot, with the host root already detached. Mounting `/tmp` first means the workdir's path
1304 // components are created inside it, so the deeper mount is the one that survives.
1305 let tmp = base.join("tmp");
1306 builder.push(RootOp::MkDir(tmp.clone()), true);
1307 builder.push(
1308 RootOp::Bind {
1309 source: session_workdir.join(SEALED_TMP_DIR_NAME),
1310 target: tmp,
1311 read_only: false,
1312 },
1313 true,
1314 );
1315
1316 // 6. The capsule's workdir — the accessible one, which is also the subprocess cwd — at its
1317 // own absolute path, read-write. The only writable path in the composed root, and the
1318 // subtree holding the session directory that backs /tmp above.
1319 let workdir_target = rebase(base, accessible_workdir);
1320 builder.mkdir_p(&workdir_target);
1321 builder.push(
1322 RootOp::Bind {
1323 source: accessible_workdir.to_path_buf(),
1324 target: workdir_target,
1325 read_only: false,
1326 },
1327 true,
1328 );
1329
1330 ComposedRootPlan {
1331 base: base.to_path_buf(),
1332 steps: builder.steps,
1333 workdir_in_root: accessible_workdir.to_path_buf(),
1334 }
1335}
1336
1337/// `base` + `path`, where `path` is absolute: `/tmp` + `/usr/lib` → `/tmp/usr/lib`.
1338///
1339/// `pub(crate)` so [`crate::staged_runtime::target_under_root`] can reuse this instead of
1340/// reimplementing the same re-basing rule.
1341pub(crate) fn rebase(base: &Path, path: &Path) -> PathBuf {
1342 let relative = path.strip_prefix("/").unwrap_or(path);
1343 base.join(relative)
1344}
1345
1346struct PlanBuilder {
1347 base: PathBuf,
1348 steps: Vec<RootStep>,
1349 /// Directories already scheduled for creation, so a shared prefix (`/usr`, `/etc`) is not
1350 /// `mkdir`ed once per entry under it.
1351 made: std::collections::HashSet<PathBuf>,
1352}
1353
1354impl PlanBuilder {
1355 fn new(base: &Path) -> Self {
1356 let mut made = std::collections::HashSet::new();
1357 made.insert(base.to_path_buf());
1358 Self {
1359 base: base.to_path_buf(),
1360 steps: Vec::new(),
1361 made,
1362 }
1363 }
1364
1365 fn push(&mut self, op: RootOp, required: bool) {
1366 self.steps.push(RootStep { op, required });
1367 }
1368
1369 /// Schedules `mkdir` for every not-yet-scheduled component of `target` under the base.
1370 fn mkdir_p(&mut self, target: &Path) {
1371 let mut current = self.base.clone();
1372 let Ok(relative) = target.strip_prefix(&self.base) else {
1373 return;
1374 };
1375 for component in relative.components() {
1376 current = current.join(component);
1377 if self.made.insert(current.clone()) {
1378 self.steps.push(RootStep {
1379 op: RootOp::MkDir(current.clone()),
1380 required: true,
1381 });
1382 }
1383 }
1384 }
1385
1386 /// Schedules an unconditional, **required** read-only directory bind of `source`.
1387 ///
1388 /// The deliberate difference from [`Self::mirror`] is that this never consults [`HostLayout`].
1389 /// `mirror` asks the host whether a path exists and, when it does not, returns having
1390 /// scheduled *nothing at all* — not even a step that would fail. That is right for
1391 /// `extra_read_only`, where a missing path should make the composed root narrower rather than
1392 /// refuse the launch. It is exactly wrong for `staged_runtime`, where a missing source must
1393 /// fail the launch: routed through `mirror`, a capsule declaring a runtime tree the host does
1394 /// not have would launch successfully into a root silently lacking it.
1395 ///
1396 /// So no existence pre-check happens here, or anywhere else in this module. The plan always
1397 /// carries the step, `required: true`, and the real `mount(2)` in `execute_step`'s
1398 /// [`CStepKind::Bind`] arm is the single source of truth for "does this exist" — a missing
1399 /// source fails with `ENOENT` at construction time, in the child, which
1400 /// `construct_composed_root`'s `step.required` check turns into a `SealedRootFailure`. That
1401 /// path already reaches the operator as `RuntimeError::SealedRootConstructionFailed`
1402 /// (`E-RUN-014`), already names the offending path via [`SealedRootSpec::describe`], and is
1403 /// already session-fatal, so no error variant of its own is needed.
1404 ///
1405 /// Always a directory bind: a staged runtime tree is a tree. `mkdir_p` registers the target in
1406 /// `made`, so a later `mirror` of the same path is the one that no-ops, never this.
1407 fn require_bind(&mut self, source: &Path) {
1408 let target = rebase(&self.base, source);
1409 if self.made.contains(&target) {
1410 return;
1411 }
1412 self.mkdir_p(&target);
1413 self.push(
1414 RootOp::Bind {
1415 source: source.to_path_buf(),
1416 target,
1417 read_only: true,
1418 },
1419 /* required */ true,
1420 );
1421 }
1422
1423 /// Reproduces one host path inside the composed root: a symlink as a symlink, a directory or
1424 /// file as a read-only bind mount. A path the host does not have contributes nothing.
1425 fn mirror(&mut self, source: &Path, host: &dyn HostLayout, required: bool) {
1426 let Some(kind) = host.kind(source) else {
1427 return;
1428 };
1429 let target = rebase(&self.base, source);
1430 if self.made.contains(&target) {
1431 return;
1432 }
1433 match kind {
1434 PathKind::Symlink(link_target) => {
1435 if let Some(parent) = target.parent() {
1436 self.mkdir_p(parent);
1437 }
1438 self.made.insert(target.clone());
1439 self.push(
1440 RootOp::Symlink {
1441 target: link_target,
1442 link: target,
1443 },
1444 required,
1445 );
1446 }
1447 PathKind::Dir => {
1448 self.mkdir_p(&target);
1449 self.push(
1450 RootOp::Bind {
1451 source: source.to_path_buf(),
1452 target,
1453 read_only: true,
1454 },
1455 required,
1456 );
1457 }
1458 PathKind::File => self.bind_file(source.to_path_buf(), target, required),
1459 }
1460 }
1461
1462 /// Schedules the mountpoint-then-bind pair one file bind needs: `mkdir -p` of its parent, an
1463 /// empty file to mount over (a bind needs an existing target of the right kind), then the
1464 /// read-only bind itself.
1465 ///
1466 /// Takes `source` and `target` separately rather than deriving one from the other, which is the
1467 /// whole reason it is a method: [`Self::mirror`] binds a host path at its own path, while a
1468 /// synthetic `/etc` entry binds a file from inside the workdir at a path that names something
1469 /// else entirely.
1470 fn bind_file(&mut self, source: PathBuf, target: PathBuf, required: bool) {
1471 if self.made.contains(&target) {
1472 return;
1473 }
1474 if let Some(parent) = target.parent() {
1475 self.mkdir_p(parent);
1476 }
1477 self.made.insert(target.clone());
1478 self.push(RootOp::MkFile(target.clone()), required);
1479 self.push(
1480 RootOp::Bind {
1481 source,
1482 target,
1483 read_only: true,
1484 },
1485 required,
1486 );
1487 }
1488}
1489
1490// ---------------------------------------------------------------- Linux: probe + execution
1491
1492/// Marker every composed-root failure message carries, so `shell.rs` can tell a `pre_exec`
1493/// sealed-root failure apart from a Landlock or seccomp one after reading it back off the
1494/// diagnostic pipe — the pipe carries one flat string, so the classification has to live in the
1495/// text. Declared here rather than inside the Linux submodule because the reader is
1496/// cross-platform code.
1497pub(crate) const SEALED_ROOT_FAILURE_PREFIX: &str = "sealed-root:";
1498
1499#[cfg(target_os = "linux")]
1500pub(crate) use linux::{
1501 build_sealed_root_spec, construct_composed_root, probe_sealed_support, SealedRootSpec,
1502};
1503
1504/// The post-`fork()` namespace primitives [`crate::network_namespace`] reuses rather than
1505/// reimplementing.
1506///
1507/// The `dumpable` pair is shared because a non-dumpable task's `/proc/self/uid_map` is root-owned
1508/// and therefore unopenable by the task itself, and every namespace `mur` creates hits that trap
1509/// identically — see [`linux::make_dumpable_for_map_writes`].
1510///
1511/// `userns_grant` is shared because it answers where the permission for `unshare(CLONE_NEWUSER)`
1512/// comes from, which `sealed` and the capsule network namespace both need. Two implementations of
1513/// one host question could return two answers, and the operator would be told to fix two different
1514/// things.
1515#[cfg(target_os = "linux")]
1516pub(crate) use linux::{
1517 make_dumpable_for_map_writes, restore_dumpable, userns_grant, write_decimal_map,
1518};
1519
1520#[cfg(target_os = "linux")]
1521#[allow(unsafe_code)]
1522mod linux {
1523 use std::ffi::CString;
1524 use std::os::unix::ffi::OsStrExt;
1525 use std::path::Path;
1526
1527 use super::{
1528 ComposedRootPlan, NamespaceProbe, RootOp, RootStep, SealedProbe, UsernsGrant,
1529 OLD_ROOT_NAME, OLD_ROOT_NAME_C, OLD_ROOT_PIVOTED_C, PROC_HIDEPID_OPTIONS,
1530 SEALED_ROOT_BASE_CANDIDATES_C, SEALED_ROOT_FAILURE_PREFIX,
1531 };
1532
1533 // ------------------------------------------------------------ probe
1534
1535 /// Reads AppArmor's unprivileged-userns restriction and, if it is not in the way, really
1536 /// creates a user+mount namespace in a forked child.
1537 ///
1538 /// Forking rather than testing in-process is required, not defensive: `unshare(CLONE_NEWUSER)`
1539 /// is irreversible for the calling process, and putting the whole runtime into a user
1540 /// namespace as a side effect of a capability check would be exactly the "probe that changes
1541 /// the thing it measures" mistake `probe_landlock_full_access` documents avoiding.
1542 pub(crate) fn probe_sealed_support() -> SealedProbe {
1543 SealedProbe {
1544 userns_grant: userns_grant(),
1545 namespace: probe_namespace(),
1546 }
1547 }
1548
1549 /// Reads the three host files [`UsernsGrant::from_readings`] decides on, which holds the
1550 /// whole decision.
1551 ///
1552 /// The knob is read from `/sys/module/apparmor/parameters` first and from `/proc/sys/kernel`
1553 /// only if that is missing: the two are the same value, and the `/sys` path is the one that
1554 /// exists on kernels where the `/proc` alias does not. Reading `/proc/self/attr/current`
1555 /// rather than the loaded-profile list is deliberate: a profile that is loaded but does not
1556 /// *attach* to the path `mur` was installed at helps nobody, and this asks the question that
1557 /// decides the outcome.
1558 pub(crate) fn userns_grant() -> UsernsGrant {
1559 let enabled = read_trimmed("/sys/module/apparmor/parameters/enabled");
1560 let restricted =
1561 read_trimmed("/sys/module/apparmor/parameters/restrict_unprivileged_userns")
1562 .or_else(|| read_trimmed("/proc/sys/kernel/apparmor_restrict_unprivileged_userns"));
1563 let current = read_trimmed("/proc/self/attr/current");
1564 UsernsGrant::from_readings(
1565 enabled.as_deref(),
1566 restricted.as_deref(),
1567 current.as_deref(),
1568 )
1569 }
1570
1571 fn read_trimmed(path: &str) -> Option<String> {
1572 std::fs::read_to_string(path)
1573 .ok()
1574 .map(|value| value.trim().to_string())
1575 .filter(|value| !value.is_empty())
1576 }
1577
1578 /// Flips `PR_SET_DUMPABLE` for the duration of the identity-map writes, and back again.
1579 ///
1580 /// Not optional. `mur` marks itself
1581 /// non-dumpable at startup (`security::harden_process_dumpable`, `prctl(PR_SET_DUMPABLE, 0)`)
1582 /// so that no same-uid process can read its `/proc/<pid>/environ`. That flag is inherited
1583 /// across `fork()`, and the kernel's `task_dump_owner()` reassigns *every* `/proc/<pid>/*`
1584 /// entry of a non-dumpable task to root — including `setgroups`, `uid_map` and `gid_map`. An
1585 /// unprivileged process therefore cannot open its own `uid_map` for writing, and the namespace
1586 /// it just created is one it can never own.
1587 ///
1588 /// The symptom is misleading: `unshare` succeeds, the map write fails with `EACCES`, and the
1589 /// refusal blames the host's id-mapping policy — on a host whose id-mapping policy is fine,
1590 /// and where the identical syscall sequence run from any other program succeeds. Without this
1591 /// flip, `sealed` is unreachable on every host for that reason alone.
1592 ///
1593 /// The window is reopened for exactly the three `open`/`write`/`close` pairs that need it and
1594 /// closed again before the first mount, so the child spends no longer readable than the map
1595 /// writes take.
1596 ///
1597 /// [`restore_dumpable`] puts back whatever the flag *was*, read here and returned, rather than
1598 /// assuming `0`. Hardcoding `0` looked equivalent — `mur` always runs non-dumpable — and was
1599 /// not: it also cleared the flag for every process that had *not* hardened itself, which
1600 /// includes this crate's own integration tests, and a non-dumpable child is one whose
1601 /// `/proc/<pid>/mem` the seccomp-notify supervisor could not read — every allowlisted `execve`
1602 /// then failed closed to `EACCES`.
1603 ///
1604 /// # Safety
1605 /// Post-`fork()`, pre-exec child context only — `prctl(2)` is async-signal-safe, but the
1606 /// dumpable window this reopens is only sound while this process is the single-threaded child
1607 /// that is about to `execve` a sandboxed binary.
1608 pub(crate) unsafe fn make_dumpable_for_map_writes() -> libc::c_int {
1609 // SAFETY: both `prctl` calls take four integer arguments; no pointers cross the boundary.
1610 // `PR_GET_DUMPABLE` returning `-1` means the query failed, in which case the restore below
1611 // is skipped rather than guessing.
1612 unsafe {
1613 let previous = libc::prctl(libc::PR_GET_DUMPABLE, 0, 0, 0, 0);
1614 libc::prctl(libc::PR_SET_DUMPABLE, 1 as libc::c_int, 0, 0, 0);
1615 previous
1616 }
1617 }
1618
1619 /// Undoes [`make_dumpable_for_map_writes`], restoring the exact value it found.
1620 ///
1621 /// # Safety
1622 /// Same window and same constraints as [`make_dumpable_for_map_writes`]; `previous` must be
1623 /// the value that call returned.
1624 pub(crate) unsafe fn restore_dumpable(previous: libc::c_int) {
1625 if previous < 0 {
1626 return;
1627 }
1628 // SAFETY: integer-only `prctl`, in the post-fork child.
1629 unsafe {
1630 libc::prctl(libc::PR_SET_DUMPABLE, previous, 0, 0, 0);
1631 }
1632 }
1633
1634 // Exit codes the probe child reports. Kept as small distinct integers so the parent learns the
1635 // *step* that failed without any IPC beyond `waitpid`.
1636 const PROBE_OK: i32 = 0;
1637 const PROBE_UNSHARE_DENIED: i32 = 1;
1638 const PROBE_UNSHARE_UNSUPPORTED: i32 = 2;
1639 const PROBE_MOUNT_DENIED: i32 = 3;
1640 const PROBE_MAP_DENIED: i32 = 4;
1641 /// No candidate in [`SEALED_ROOT_BASE_CANDIDATES_C`] accepted a `tmpfs` mount. Same mechanism
1642 /// and same remediation as [`PROBE_MOUNT_DENIED`], so it folds into `MountDenied`.
1643 const PROBE_TMPFS_DENIED: i32 = 5;
1644 /// The parking directory could not be made, or the child could not `chdir` into the new root —
1645 /// composed-root step 6 before the syscall itself.
1646 const PROBE_PIVOT_PREPARE_DENIED: i32 = 6;
1647 /// `syscall(SYS_pivot_root, ".", ".mur-oldroot")` was refused.
1648 const PROBE_PIVOT_ROOT_DENIED: i32 = 7;
1649 /// `pivot_root` succeeded and detaching the parked old root did not — composed-root step 7.
1650 const PROBE_OLD_ROOT_DETACH_DENIED: i32 = 8;
1651
1652 fn probe_namespace() -> NamespaceProbe {
1653 // Read before the fork, and therefore before `unshare`: inside a fresh user namespace
1654 // with no mapping written yet, `getuid()`/`getgid()` report the *overflow* ids (65534),
1655 // not the real ones. Writing `65534 65534 1` into `uid_map` is refused with `EPERM` on
1656 // every host, so reading them after the unshare made the probe fail everywhere and
1657 // report the host as the culprit. This is the one ordering in this function that is
1658 // load-bearing.
1659 // SAFETY: getuid/getgid take no arguments, dereference nothing and cannot fail.
1660 let uid = unsafe { libc::getuid() };
1661 let gid = unsafe { libc::getgid() };
1662
1663 // SAFETY: `fork()` from a possibly-multithreaded process is sound as long as the child
1664 // touches nothing but async-signal-safe primitives. The child below calls `unshare`,
1665 // `prctl`, `mount`, the `open`/`write`/`close` triples that write the identity maps,
1666 // `chdir`, `mkdir`, `pivot_root` via `syscall`, `umount2`, and `_exit` — every one of them
1667 // async-signal-safe, and no allocation, no locks, no stdio. Anything added to the child
1668 // must hold to the same list.
1669 let pid = unsafe { libc::fork() };
1670 if pid < 0 {
1671 return NamespaceProbe::Unsupported;
1672 }
1673 if pid == 0 {
1674 // SAFETY: forked-child context, syscalls only; every branch ends in `_exit`.
1675 unsafe {
1676 if libc::unshare(libc::CLONE_NEWUSER | libc::CLONE_NEWNS) != 0 {
1677 let errno = *libc::__errno_location();
1678 libc::_exit(match errno {
1679 libc::EPERM => PROBE_UNSHARE_DENIED,
1680 _ => PROBE_UNSHARE_UNSUPPORTED,
1681 });
1682 }
1683 // Rehearse the identity uid/gid mapping too, rather than stopping at
1684 // `unshare`. A probe that skips it is not measuring what the real construction
1685 // does: a host can allow the namespace and still refuse to let the process own
1686 // it, and finding that out at spawn time instead of at launch time is exactly
1687 // the surprise this probe exists to prevent.
1688 //
1689 // `uid`/`gid` come from before the fork — see `probe_namespace`.
1690 //
1691 // Dumpable for the map writes only, and restored immediately: see
1692 // `make_dumpable_for_map_writes` for why an inherited non-dumpable flag makes
1693 // these three files root-owned.
1694 let previous_dumpable = make_dumpable_for_map_writes();
1695 let _ = write_decimal_map(c"/proc/self/setgroups", None, 0);
1696 let mapped = write_decimal_map(c"/proc/self/uid_map", Some(uid), uid).is_ok()
1697 && write_decimal_map(c"/proc/self/gid_map", Some(gid), gid).is_ok();
1698 restore_dumpable(previous_dumpable);
1699 if !mapped {
1700 libc::_exit(PROBE_MAP_DENIED);
1701 }
1702
1703 // The first mount any composed root performs, and the one AppArmor's
1704 // `unprivileged_userns` profile denies: if this works, `CAP_SYS_ADMIN` is real
1705 // inside the new namespace.
1706 if libc::mount(
1707 std::ptr::null(),
1708 c"/".as_ptr(),
1709 std::ptr::null(),
1710 libc::MS_REC | libc::MS_PRIVATE,
1711 std::ptr::null(),
1712 ) != 0
1713 {
1714 libc::_exit(PROBE_MOUNT_DENIED);
1715 }
1716
1717 // Steps 4, 6 and 7 of `construct_composed_root`, in the same order and with the
1718 // same syscalls. A probe that stops at the mount above clears a host that refuses
1719 // `pivot_root(2)`, which then fails at the first subprocess after an inference
1720 // call has already been paid for.
1721 //
1722 // Everything below happens inside this child's own mount namespace, whose
1723 // propagation the mount above just made private, so the host's mount table is
1724 // unchanged whatever the outcome — and the child `_exit`s a few syscalls later
1725 // without ever unwinding any of it.
1726 let mut based = false;
1727 for base in SEALED_ROOT_BASE_CANDIDATES_C {
1728 if libc::mount(
1729 c"tmpfs".as_ptr(),
1730 base.as_ptr(),
1731 c"tmpfs".as_ptr(),
1732 libc::MS_NOSUID | libc::MS_NODEV,
1733 c"mode=0755".as_ptr() as *const libc::c_void,
1734 ) == 0
1735 {
1736 // `chdir` first, so the parking directory and `pivot_root`'s two
1737 // arguments are all relative and no path has to be composed here.
1738 if libc::chdir(base.as_ptr()) != 0 {
1739 libc::_exit(PROBE_PIVOT_PREPARE_DENIED);
1740 }
1741 based = true;
1742 break;
1743 }
1744 }
1745 if !based {
1746 libc::_exit(PROBE_TMPFS_DENIED);
1747 }
1748
1749 if libc::mkdir(OLD_ROOT_NAME_C.as_ptr(), 0o700) != 0 {
1750 libc::_exit(PROBE_PIVOT_PREPARE_DENIED);
1751 }
1752 if libc::syscall(
1753 libc::SYS_pivot_root,
1754 c".".as_ptr(),
1755 OLD_ROOT_NAME_C.as_ptr(),
1756 ) != 0
1757 {
1758 libc::_exit(PROBE_PIVOT_ROOT_DENIED);
1759 }
1760 if libc::chdir(c"/".as_ptr()) != 0 {
1761 libc::_exit(PROBE_PIVOT_PREPARE_DENIED);
1762 }
1763 if libc::umount2(OLD_ROOT_PIVOTED_C.as_ptr(), libc::MNT_DETACH) != 0 {
1764 libc::_exit(PROBE_OLD_ROOT_DETACH_DENIED);
1765 }
1766 libc::_exit(PROBE_OK);
1767 }
1768 }
1769
1770 let mut status: libc::c_int = 0;
1771 // SAFETY: `pid` is the child just forked; `status` is a live local.
1772 let waited = unsafe { libc::waitpid(pid, &mut status, 0) };
1773 if waited != pid {
1774 return NamespaceProbe::Unsupported;
1775 }
1776 if !libc::WIFEXITED(status) {
1777 return NamespaceProbe::Unsupported;
1778 }
1779 match libc::WEXITSTATUS(status) {
1780 PROBE_OK => NamespaceProbe::Ok,
1781 PROBE_UNSHARE_DENIED => NamespaceProbe::Denied,
1782 // Kept apart from `Denied`: the child already told us which step failed, and
1783 // throwing that away made a map failure indistinguishable from the kernel refusing
1784 // the namespace outright — so the reported cause, and the remediation offered with
1785 // it, named the wrong thing entirely.
1786 PROBE_MAP_DENIED => NamespaceProbe::MapDenied,
1787 // A tmpfs the kernel would not accept over any candidate base is the same mechanism
1788 // and the same remediation as the propagation mount above, so the two report as one.
1789 PROBE_MOUNT_DENIED | PROBE_TMPFS_DENIED => NamespaceProbe::MountDenied,
1790 // The whole of composed-root steps 6 and 7. The parking `mkdir`, the two `chdir`s and
1791 // the `umount2` are the sequence `pivot_root(2)` is performed with, and a host that
1792 // refuses any of them refuses the pivot; the exit codes stay distinct so the *stage*
1793 // is still recoverable from a core-less child, while the blocker names the mechanism
1794 // an operator has to grant.
1795 PROBE_PIVOT_PREPARE_DENIED | PROBE_PIVOT_ROOT_DENIED | PROBE_OLD_ROOT_DETACH_DENIED => {
1796 NamespaceProbe::PivotRootDenied
1797 }
1798 _ => NamespaceProbe::Unsupported,
1799 }
1800 }
1801
1802 /// Writes either the literal `deny` (when `id` is `None`) or an identity map line
1803 /// `"<id> <id> 1\n"`, formatting the number into a stack buffer.
1804 ///
1805 /// Hand-rolled rather than `format!` because this runs in a forked child: the parent may have
1806 /// been mid-allocation on another thread when `fork()` returned, so touching the allocator
1807 /// here can deadlock. `id` never exceeds `u32::MAX`, so ten digits is the whole range.
1808 ///
1809 /// # Safety
1810 /// Must only be called from the post-`fork()`, pre-exec window in the child, before any other
1811 /// thread could exist in this process — the same async-signal-safety constraint documented on
1812 /// [`construct_composed_root`]'s `unsafe` block, which this function's callers all sit inside.
1813 pub(crate) unsafe fn write_decimal_map(
1814 path: &std::ffi::CStr,
1815 id: Option<libc::uid_t>,
1816 value: libc::uid_t,
1817 ) -> Result<(), ()> {
1818 let Some(_) = id else {
1819 return write_file(path, b"deny");
1820 };
1821
1822 let mut buf = [0u8; 32];
1823 let mut len = 0usize;
1824 let write_number = |buf: &mut [u8; 32], len: &mut usize, mut n: u32| {
1825 let mut digits = [0u8; 10];
1826 let mut count = 0;
1827 loop {
1828 digits[count] = b'0' + (n % 10) as u8;
1829 count += 1;
1830 n /= 10;
1831 if n == 0 {
1832 break;
1833 }
1834 }
1835 while count > 0 {
1836 count -= 1;
1837 buf[*len] = digits[count];
1838 *len += 1;
1839 }
1840 };
1841
1842 write_number(&mut buf, &mut len, value);
1843 buf[len] = b' ';
1844 len += 1;
1845 write_number(&mut buf, &mut len, value);
1846 buf[len] = b' ';
1847 len += 1;
1848 buf[len] = b'1';
1849 len += 1;
1850 buf[len] = b'\n';
1851 len += 1;
1852
1853 write_file(path, &buf[..len])
1854 }
1855
1856 // ------------------------------------------------------------ spec (parent side)
1857
1858 /// One planned operation, lowered to NUL-terminated C strings plus a pre-rendered label.
1859 ///
1860 /// The label is built here, in the parent, precisely so the child never has to: a failure
1861 /// message that names its step costs one `String` per step at spawn time and zero allocation
1862 /// inside `pre_exec`.
1863 struct CStep {
1864 kind: CStepKind,
1865 required: bool,
1866 label: String,
1867 }
1868
1869 enum CStepKind {
1870 MkDir(CString),
1871 MkFile(CString),
1872 Symlink {
1873 target: CString,
1874 link: CString,
1875 },
1876 Bind {
1877 source: CString,
1878 target: CString,
1879 read_only: bool,
1880 },
1881 Tmpfs {
1882 target: CString,
1883 options: CString,
1884 },
1885 Proc {
1886 target: CString,
1887 },
1888 DevPts {
1889 target: CString,
1890 },
1891 RemountReadOnly(CString),
1892 }
1893
1894 /// Everything the child needs to build a composed root, fully resolved before `fork()`.
1895 pub(crate) struct SealedRootSpec {
1896 uid_map: Vec<u8>,
1897 gid_map: Vec<u8>,
1898 base: CString,
1899 /// `<base>/.mur-oldroot` — created before the pivot.
1900 old_root_full: CString,
1901 /// `.mur-oldroot`, relative, as `pivot_root(2)` wants it with `cwd == base`.
1902 old_root_relative: CString,
1903 /// `/.mur-oldroot`, as it is named after the pivot, for the detach and the rmdir.
1904 old_root_pivoted: CString,
1905 steps: Vec<CStep>,
1906 workdir_in_root: CString,
1907 }
1908
1909 /// Where a composed root's construction failed. `Copy` and allocation-free by construction —
1910 /// it is produced inside `pre_exec`; the message is rendered by the caller, afterwards.
1911 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1912 pub(crate) struct SealedRootFailure {
1913 /// Index into the spec's step list, or `usize::MAX` for one of the fixed
1914 /// unshare/pivot/chdir stages.
1915 pub(crate) step: usize,
1916 /// A fixed stage name when `step` is `usize::MAX`; otherwise unused.
1917 pub(crate) stage: &'static str,
1918 pub(crate) errno: i32,
1919 }
1920
1921 impl SealedRootFailure {
1922 fn stage(stage: &'static str) -> Self {
1923 Self {
1924 step: usize::MAX,
1925 stage,
1926 errno: std::io::Error::last_os_error().raw_os_error().unwrap_or(0),
1927 }
1928 }
1929
1930 fn step(index: usize) -> Self {
1931 Self {
1932 step: index,
1933 stage: "",
1934 errno: std::io::Error::last_os_error().raw_os_error().unwrap_or(0),
1935 }
1936 }
1937 }
1938
1939 impl SealedRootSpec {
1940 /// Renders a failure into the message that reaches the operator, naming both the step and
1941 /// the errno. Runs in the *parent's* error path (via the diagnostic pipe) or in the
1942 /// child's, where an allocation is already unavoidable because the launch is over.
1943 pub(crate) fn describe(&self, failure: SealedRootFailure) -> String {
1944 let what = if failure.step == usize::MAX {
1945 failure.stage.to_string()
1946 } else {
1947 self.steps
1948 .get(failure.step)
1949 .map(|step| step.label.clone())
1950 .unwrap_or_else(|| format!("step {}", failure.step))
1951 };
1952 format!(
1953 "{SEALED_ROOT_FAILURE_PREFIX} {what} failed: {}",
1954 std::io::Error::from_raw_os_error(failure.errno)
1955 )
1956 }
1957 }
1958
1959 /// Lowers a [`ComposedRootPlan`] into the C-string form the child executes. Fails only on a
1960 /// path containing an interior NUL, which no real path does.
1961 pub(crate) fn build_sealed_root_spec(
1962 plan: &ComposedRootPlan,
1963 ) -> Result<SealedRootSpec, String> {
1964 // SAFETY: `getuid`/`getgid` take no arguments, dereference nothing and cannot fail.
1965 let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
1966
1967 let mut steps = Vec::with_capacity(plan.steps.len());
1968 for RootStep { op, required } in &plan.steps {
1969 let (kind, label) = match op {
1970 RootOp::MkDir(path) => (
1971 CStepKind::MkDir(cstr(path)?),
1972 format!("mkdir {}", path.display()),
1973 ),
1974 RootOp::MkFile(path) => (
1975 CStepKind::MkFile(cstr(path)?),
1976 format!("create {}", path.display()),
1977 ),
1978 RootOp::Symlink { target, link } => (
1979 CStepKind::Symlink {
1980 target: cstr(target)?,
1981 link: cstr(link)?,
1982 },
1983 format!("symlink {} -> {}", link.display(), target.display()),
1984 ),
1985 RootOp::Bind {
1986 source,
1987 target,
1988 read_only,
1989 } => (
1990 CStepKind::Bind {
1991 source: cstr(source)?,
1992 target: cstr(target)?,
1993 read_only: *read_only,
1994 },
1995 format!(
1996 "bind{} {} -> {}",
1997 if *read_only { " (ro)" } else { "" },
1998 source.display(),
1999 target.display()
2000 ),
2001 ),
2002 RootOp::Tmpfs { target, options } => (
2003 CStepKind::Tmpfs {
2004 target: cstr(target)?,
2005 options: CString::new(*options).map_err(|error| error.to_string())?,
2006 },
2007 format!("tmpfs on {}", target.display()),
2008 ),
2009 RootOp::Proc { target } => (
2010 CStepKind::Proc {
2011 target: cstr(target)?,
2012 },
2013 format!("proc on {}", target.display()),
2014 ),
2015 RootOp::DevPts { target } => (
2016 CStepKind::DevPts {
2017 target: cstr(target)?,
2018 },
2019 format!("devpts on {}", target.display()),
2020 ),
2021 RootOp::RemountReadOnly(path) => (
2022 CStepKind::RemountReadOnly(cstr(path)?),
2023 format!("remount read-only {}", path.display()),
2024 ),
2025 };
2026 steps.push(CStep {
2027 kind,
2028 required: *required,
2029 label,
2030 });
2031 }
2032
2033 Ok(SealedRootSpec {
2034 // Identity maps: the capsule keeps the uid it already had, so files it writes into the
2035 // bind-mounted workdir are owned by the real user rather than by `nobody`.
2036 uid_map: format!("{uid} {uid} 1\n").into_bytes(),
2037 gid_map: format!("{gid} {gid} 1\n").into_bytes(),
2038 base: cstr(&plan.base)?,
2039 old_root_full: cstr(&plan.base.join(OLD_ROOT_NAME))?,
2040 old_root_relative: CString::new(OLD_ROOT_NAME).map_err(|e| e.to_string())?,
2041 old_root_pivoted: CString::new(format!("/{OLD_ROOT_NAME}"))
2042 .map_err(|e| e.to_string())?,
2043 steps,
2044 workdir_in_root: cstr(&plan.workdir_in_root)?,
2045 })
2046 }
2047
2048 fn cstr(path: &Path) -> Result<CString, String> {
2049 CString::new(path.as_os_str().as_bytes())
2050 .map_err(|_| format!("path contains an interior NUL: {}", path.display()))
2051 }
2052
2053 // ------------------------------------------------------------ execution (child side)
2054
2055 /// Builds the composed root and pivots onto it. Runs inside the forked child's `pre_exec`
2056 /// window, before fd hygiene, the capability drop, seccomp and Landlock — which is the only
2057 /// window in which `unshare`/`mount`/`pivot_root` are available at all, since
2058 /// `sandbox::SECCOMP_MUST_STAY_DENIED` denies all three to every process the filter covers.
2059 ///
2060 /// Allocation-free on the success path: every path was turned into a `CString` by
2061 /// [`build_sealed_root_spec`] before `fork()`.
2062 pub(crate) fn construct_composed_root(spec: &SealedRootSpec) -> Result<(), SealedRootFailure> {
2063 // SAFETY: every call below is a bare syscall over pointers into `spec`, which outlives
2064 // this function. No allocation, no locks, no reentrancy — the constraints of the
2065 // post-fork/pre-exec window.
2066 unsafe {
2067 // 1. The namespaces. `CLONE_NEWUSER` is what makes `CLONE_NEWNS` available without
2068 // host root; asking for both in one call means the mount namespace is created with
2069 // the new user namespace's credentials already in force.
2070 if libc::unshare(libc::CLONE_NEWUSER | libc::CLONE_NEWNS) != 0 {
2071 return Err(SealedRootFailure::stage(
2072 "unshare(CLONE_NEWUSER|CLONE_NEWNS)",
2073 ));
2074 }
2075
2076 // 2. Identity uid/gid maps. `setgroups=deny` first, which the kernel requires before
2077 // an unprivileged process may write `gid_map`. A host without `setgroups` (pre-3.19)
2078 // is tolerated; a failing `uid_map` is not, because running as the overflow uid
2079 // would leave the capsule unable to write its own workdir.
2080 //
2081 // Dumpable is flipped on for exactly these three writes and put back before the
2082 // first mount: `mur` runs non-dumpable, and a non-dumpable task's `/proc/self/*`
2083 // entries are owned by root, which makes its own `uid_map` unopenable. See
2084 // [`make_dumpable_for_map_writes`].
2085 let previous_dumpable = make_dumpable_for_map_writes();
2086 let _ = write_file(c"/proc/self/setgroups", b"deny");
2087 let uid_mapped = write_file(c"/proc/self/uid_map", &spec.uid_map).is_ok();
2088 let gid_mapped = uid_mapped && write_file(c"/proc/self/gid_map", &spec.gid_map).is_ok();
2089 restore_dumpable(previous_dumpable);
2090 if !uid_mapped {
2091 return Err(SealedRootFailure::stage("write /proc/self/uid_map"));
2092 }
2093 if !gid_mapped {
2094 return Err(SealedRootFailure::stage("write /proc/self/gid_map"));
2095 }
2096
2097 // 3. Private propagation, so none of what follows escapes back to the host's mounts.
2098 if libc::mount(
2099 std::ptr::null(),
2100 c"/".as_ptr(),
2101 std::ptr::null(),
2102 libc::MS_REC | libc::MS_PRIVATE,
2103 std::ptr::null(),
2104 ) != 0
2105 {
2106 return Err(SealedRootFailure::stage("mount --make-rprivate /"));
2107 }
2108
2109 // 4. The new root: an empty tmpfs over the chosen base. It is a mount point, which is
2110 // what `pivot_root(2)` requires of its new root.
2111 if libc::mount(
2112 c"tmpfs".as_ptr(),
2113 spec.base.as_ptr(),
2114 c"tmpfs".as_ptr(),
2115 libc::MS_NOSUID | libc::MS_NODEV,
2116 c"mode=0755".as_ptr() as *const libc::c_void,
2117 ) != 0
2118 {
2119 return Err(SealedRootFailure::stage(
2120 "mount tmpfs for the composed root",
2121 ));
2122 }
2123
2124 // 5. The plan.
2125 for (index, step) in spec.steps.iter().enumerate() {
2126 if execute_step(step).is_err() && step.required {
2127 return Err(SealedRootFailure::step(index));
2128 }
2129 }
2130
2131 // 6. Pivot. `chdir` into the new root first so `pivot_root` can be handed the pair of
2132 // relative paths the syscall is happiest with.
2133 if libc::mkdir(spec.old_root_full.as_ptr(), 0o700) != 0 {
2134 return Err(SealedRootFailure::stage(
2135 "mkdir the old-root parking directory",
2136 ));
2137 }
2138 if libc::chdir(spec.base.as_ptr()) != 0 {
2139 return Err(SealedRootFailure::stage("chdir into the composed root"));
2140 }
2141 if libc::syscall(
2142 libc::SYS_pivot_root,
2143 c".".as_ptr(),
2144 spec.old_root_relative.as_ptr(),
2145 ) != 0
2146 {
2147 return Err(SealedRootFailure::stage("pivot_root"));
2148 }
2149 if libc::chdir(c"/".as_ptr()) != 0 {
2150 return Err(SealedRootFailure::stage("chdir / after pivot_root"));
2151 }
2152
2153 // 7. Detach and remove the old root. After this there is no pathname anywhere in this
2154 // namespace that reaches the host filesystem.
2155 if libc::umount2(spec.old_root_pivoted.as_ptr(), libc::MNT_DETACH) != 0 {
2156 return Err(SealedRootFailure::stage("umount2 the old root"));
2157 }
2158 // Best-effort, deliberately: `MNT_DETACH` already removed the old root from this
2159 // namespace's tree, so a leftover empty directory here is cosmetic. Failing a launch
2160 // over it would trade a working capsule for nothing.
2161 libc::rmdir(spec.old_root_pivoted.as_ptr());
2162
2163 // 8. Seal the root tmpfs itself. `MS_REMOUNT | MS_BIND` changes per-mount flags only,
2164 // which is the well-defined way to make an existing mount read-only; the workdir
2165 // bind underneath it keeps its own (writable) flags.
2166 if libc::mount(
2167 std::ptr::null(),
2168 c"/".as_ptr(),
2169 std::ptr::null(),
2170 libc::MS_REMOUNT
2171 | libc::MS_BIND
2172 | libc::MS_RDONLY
2173 | libc::MS_NOSUID
2174 | libc::MS_NODEV,
2175 std::ptr::null(),
2176 ) != 0
2177 {
2178 return Err(SealedRootFailure::stage(
2179 "remount the composed root read-only",
2180 ));
2181 }
2182
2183 // 9. Back into the workdir, at the same absolute path it had on the host. `Command`
2184 // already `chdir`ed here before this closure ran, but that was in the old root.
2185 if libc::chdir(spec.workdir_in_root.as_ptr()) != 0 {
2186 return Err(SealedRootFailure::stage(
2187 "chdir into the workdir inside the root",
2188 ));
2189 }
2190 }
2191
2192 Ok(())
2193 }
2194
2195 /// Executes one planned step. `Err(())` carries no detail — the caller pairs the index with
2196 /// the spec's pre-rendered label and the live errno.
2197 ///
2198 /// # Safety
2199 /// Must only be called from the post-`fork()`, pre-exec window: every branch is a bare
2200 /// syscall over `CStr` pointers borrowed from `step`, which the caller guarantees outlives
2201 /// the call, and none may allocate or take a lock that a sibling thread could hold frozen at
2202 /// `fork()` time.
2203 unsafe fn execute_step(step: &CStep) -> Result<(), ()> {
2204 match &step.kind {
2205 CStepKind::MkDir(path) => {
2206 if libc::mkdir(path.as_ptr(), 0o755) != 0
2207 && *libc::__errno_location() != libc::EEXIST
2208 {
2209 return Err(());
2210 }
2211 }
2212 CStepKind::MkFile(path) => {
2213 let fd = libc::open(
2214 path.as_ptr(),
2215 libc::O_CREAT | libc::O_WRONLY | libc::O_CLOEXEC,
2216 libc::mode_t::from(0o644u16),
2217 );
2218 if fd < 0 {
2219 return Err(());
2220 }
2221 libc::close(fd);
2222 }
2223 CStepKind::Symlink { target, link } => {
2224 if libc::symlink(target.as_ptr(), link.as_ptr()) != 0 {
2225 return Err(());
2226 }
2227 }
2228 CStepKind::Bind {
2229 source,
2230 target,
2231 read_only,
2232 } => {
2233 if libc::mount(
2234 source.as_ptr(),
2235 target.as_ptr(),
2236 std::ptr::null(),
2237 libc::MS_BIND | libc::MS_REC,
2238 std::ptr::null(),
2239 ) != 0
2240 {
2241 return Err(());
2242 }
2243 if *read_only
2244 && libc::mount(
2245 std::ptr::null(),
2246 target.as_ptr(),
2247 std::ptr::null(),
2248 libc::MS_REMOUNT | libc::MS_BIND | libc::MS_RDONLY | libc::MS_NOSUID,
2249 std::ptr::null(),
2250 ) != 0
2251 {
2252 return Err(());
2253 }
2254 }
2255 CStepKind::Tmpfs { target, options } => {
2256 if libc::mount(
2257 c"tmpfs".as_ptr(),
2258 target.as_ptr(),
2259 c"tmpfs".as_ptr(),
2260 libc::MS_NOSUID,
2261 options.as_ptr() as *const libc::c_void,
2262 ) != 0
2263 {
2264 return Err(());
2265 }
2266 }
2267 CStepKind::Proc { target } => {
2268 // Try each `hidepid` spelling in turn, then a recursive bind of the host's
2269 // `/proc` — see `PROC_HIDEPID_OPTIONS` for the bind's rationale and exposure.
2270 for option in PROC_HIDEPID_OPTIONS {
2271 let mut buffer = [0u8; 32];
2272 let bytes = option.as_bytes();
2273 if bytes.len() >= buffer.len() {
2274 continue;
2275 }
2276 buffer[..bytes.len()].copy_from_slice(bytes);
2277 let data = if bytes.is_empty() {
2278 std::ptr::null()
2279 } else {
2280 buffer.as_ptr() as *const libc::c_void
2281 };
2282 if libc::mount(
2283 c"proc".as_ptr(),
2284 target.as_ptr(),
2285 c"proc".as_ptr(),
2286 libc::MS_NOSUID | libc::MS_NODEV | libc::MS_NOEXEC,
2287 data,
2288 ) == 0
2289 {
2290 return Ok(());
2291 }
2292 }
2293
2294 // Last resort: bind the host's `/proc` in. `mount -t proc` needs `CAP_SYS_ADMIN`
2295 // over the *PID* namespace's user namespace, which an unprivileged user namespace
2296 // never has, so on a bare host every spelling above fails with `EPERM` and this is
2297 // the branch that actually runs. A bind of an existing mount needs no such
2298 // privilege. Recursive so the `/proc` submounts a modern host carries come with it
2299 // rather than leaving empty stubs.
2300 if libc::mount(
2301 c"/proc".as_ptr(),
2302 target.as_ptr(),
2303 std::ptr::null(),
2304 libc::MS_BIND | libc::MS_REC,
2305 std::ptr::null(),
2306 ) != 0
2307 {
2308 return Err(());
2309 }
2310 // Best-effort hardening of the bind. `MS_REMOUNT | MS_BIND` changes per-mount
2311 // flags only — unlike `hidepid`, which is a superblock option and stays refused —
2312 // so this narrows what the bind can do without touching the host's own `/proc`.
2313 // Not fatal: a `/proc` that is present and merely unhardened beats no `/proc`.
2314 libc::mount(
2315 std::ptr::null(),
2316 target.as_ptr(),
2317 std::ptr::null(),
2318 libc::MS_REMOUNT
2319 | libc::MS_BIND
2320 | libc::MS_NOSUID
2321 | libc::MS_NODEV
2322 | libc::MS_NOEXEC,
2323 std::ptr::null(),
2324 );
2325 return Ok(());
2326 }
2327 CStepKind::DevPts { target } => {
2328 if libc::mount(
2329 c"devpts".as_ptr(),
2330 target.as_ptr(),
2331 c"devpts".as_ptr(),
2332 libc::MS_NOSUID | libc::MS_NOEXEC,
2333 c"newinstance,ptmxmode=0666,mode=0620".as_ptr() as *const libc::c_void,
2334 ) != 0
2335 {
2336 return Err(());
2337 }
2338 }
2339 CStepKind::RemountReadOnly(target) => {
2340 if libc::mount(
2341 std::ptr::null(),
2342 target.as_ptr(),
2343 std::ptr::null(),
2344 libc::MS_REMOUNT | libc::MS_BIND | libc::MS_RDONLY | libc::MS_NOSUID,
2345 std::ptr::null(),
2346 ) != 0
2347 {
2348 return Err(());
2349 }
2350 }
2351 }
2352 Ok(())
2353 }
2354
2355 /// `open`/`write`/`close` over a static path, with no allocation — the `/proc/self/*_map`
2356 /// writes are the only file I/O the composed-root construction performs.
2357 ///
2358 /// # Safety
2359 /// Must only be called from the post-`fork()`, pre-exec window, for the same reason as
2360 /// [`execute_step`]: `path` and `data` are borrowed for the duration of the call only, and the
2361 /// raw `open`/`write`/`close` sequence must stay allocation- and lock-free in that window.
2362 unsafe fn write_file(path: &std::ffi::CStr, data: &[u8]) -> Result<(), ()> {
2363 let fd = libc::open(path.as_ptr(), libc::O_WRONLY | libc::O_CLOEXEC);
2364 if fd < 0 {
2365 return Err(());
2366 }
2367 let mut written = 0usize;
2368 while written < data.len() {
2369 let n = libc::write(
2370 fd,
2371 data[written..].as_ptr() as *const libc::c_void,
2372 data.len() - written,
2373 );
2374 if n < 0 {
2375 if *libc::__errno_location() == libc::EINTR {
2376 continue;
2377 }
2378 libc::close(fd);
2379 return Err(());
2380 }
2381 if n == 0 {
2382 break;
2383 }
2384 written += n as usize;
2385 }
2386 libc::close(fd);
2387 if written == data.len() {
2388 Ok(())
2389 } else {
2390 Err(())
2391 }
2392 }
2393}
2394
2395#[cfg(test)]
2396mod tests {
2397 use super::*;
2398 use std::collections::HashMap;
2399
2400 struct FakeHost(HashMap<PathBuf, PathKind>);
2401
2402 impl FakeHost {
2403 fn usrmerge() -> Self {
2404 let mut map = HashMap::new();
2405 map.insert(PathBuf::from("/usr"), PathKind::Dir);
2406 map.insert(
2407 PathBuf::from("/bin"),
2408 PathKind::Symlink(PathBuf::from("usr/bin")),
2409 );
2410 map.insert(
2411 PathBuf::from("/sbin"),
2412 PathKind::Symlink(PathBuf::from("usr/sbin")),
2413 );
2414 map.insert(
2415 PathBuf::from("/lib"),
2416 PathKind::Symlink(PathBuf::from("usr/lib")),
2417 );
2418 map.insert(
2419 PathBuf::from("/lib64"),
2420 PathKind::Symlink(PathBuf::from("usr/lib64")),
2421 );
2422 map.insert(PathBuf::from("/etc/ld.so.cache"), PathKind::File);
2423 map.insert(PathBuf::from("/etc/ssl"), PathKind::Dir);
2424 // Present on the fake host precisely so the synthesis tests below can show that having
2425 // them is not what decides whether they are mirrored.
2426 map.insert(PathBuf::from("/etc/passwd"), PathKind::File);
2427 map.insert(PathBuf::from("/etc/group"), PathKind::File);
2428 for device in SEALED_DEVICE_NODES {
2429 map.insert(PathBuf::from(device.path), PathKind::File);
2430 }
2431 Self(map)
2432 }
2433 }
2434
2435 impl HostLayout for FakeHost {
2436 fn kind(&self, path: &Path) -> Option<PathKind> {
2437 self.0.get(path).cloned()
2438 }
2439 }
2440
2441 /// The session workdir a `--workdir` launch stages under `accessible`.
2442 ///
2443 /// Spelled out rather than reusing the accessible path, because that is the shape in which
2444 /// the two are different directories — and the `/tmp` and `/etc` staging sources have to
2445 /// resolve against this one, not against the directory the capsule was asked to produce.
2446 fn session_of(accessible: impl AsRef<Path>) -> PathBuf {
2447 accessible.as_ref().join(".murmur").join(TEST_SESSION_ID)
2448 }
2449
2450 const TEST_SESSION_ID: &str = "ses_plan_tests";
2451
2452 fn plan_for(workdir: &str) -> ComposedRootPlan {
2453 plan_composed_root(
2454 Path::new(workdir),
2455 &session_of(workdir),
2456 Path::new("/tmp"),
2457 &[],
2458 &[],
2459 &FakeHost::usrmerge(),
2460 )
2461 }
2462
2463 fn ops(plan: &ComposedRootPlan) -> Vec<RootOp> {
2464 plan.steps.iter().map(|step| step.op.clone()).collect()
2465 }
2466
2467 #[test]
2468 fn base_selection_skips_any_ancestor_of_the_workdir() {
2469 // The default case: a workdir under the user's home, so `/tmp` is free.
2470 assert_eq!(
2471 choose_root_base(Path::new("/home/u/w"), SEALED_ROOT_BASE_CANDIDATES, |_| {
2472 true
2473 }),
2474 Some(PathBuf::from("/tmp"))
2475 );
2476 // A workdir under /tmp — overmounting /tmp would hide the workdir before it can be bound.
2477 assert_eq!(
2478 choose_root_base(
2479 Path::new("/tmp/session/w"),
2480 SEALED_ROOT_BASE_CANDIDATES,
2481 |_| true
2482 ),
2483 Some(PathBuf::from("/run"))
2484 );
2485 // A host missing the first two candidates falls through to the third.
2486 assert_eq!(
2487 choose_root_base(
2488 Path::new("/home/u/w"),
2489 SEALED_ROOT_BASE_CANDIDATES,
2490 |path| { path != Path::new("/tmp") && path != Path::new("/run") }
2491 ),
2492 Some(PathBuf::from("/var/tmp"))
2493 );
2494 assert_eq!(
2495 choose_root_base(Path::new("/home/u/w"), SEALED_ROOT_BASE_CANDIDATES, |_| {
2496 false
2497 }),
2498 None
2499 );
2500 }
2501
2502 #[test]
2503 fn usrmerge_symlinks_are_reproduced_rather_than_bound_through() {
2504 let plan = plan_for("/home/u/w");
2505 assert!(ops(&plan).contains(&RootOp::Symlink {
2506 target: PathBuf::from("usr/bin"),
2507 link: PathBuf::from("/tmp/bin"),
2508 }));
2509 assert!(ops(&plan).contains(&RootOp::Bind {
2510 source: PathBuf::from("/usr"),
2511 target: PathBuf::from("/tmp/usr"),
2512 read_only: true,
2513 }));
2514 // `/lib32` and `/libx32` are absent from this fake host, so they contribute nothing.
2515 assert!(!ops(&plan)
2516 .iter()
2517 .any(|op| matches!(op, RootOp::Bind { source, .. } if source == Path::new("/lib32"))));
2518 }
2519
2520 #[test]
2521 fn every_runtime_and_etc_bind_is_read_only_and_only_the_workdir_is_not() {
2522 let plan = plan_for("/home/u/w");
2523 let writable: Vec<&PathBuf> = plan
2524 .steps
2525 .iter()
2526 .filter_map(|step| match &step.op {
2527 RootOp::Bind {
2528 source,
2529 read_only: false,
2530 ..
2531 } => Some(source),
2532 _ => None,
2533 })
2534 .collect();
2535
2536 // The workdir, /tmp's workdir-backed store, and the /dev nodes (whose write side is the
2537 // device driver's, not the filesystem's) — and nothing else.
2538 for source in &writable {
2539 assert!(
2540 source.starts_with("/home/u/w") || source.starts_with("/dev/"),
2541 "unexpected writable bind of {}",
2542 source.display()
2543 );
2544 }
2545 assert!(writable.contains(&&PathBuf::from("/home/u/w")));
2546 }
2547
2548 #[test]
2549 fn etc_is_an_allowlist_so_shadow_is_absent_by_construction() {
2550 let plan = plan_for("/home/u/w");
2551 let bound: Vec<String> = plan
2552 .steps
2553 .iter()
2554 .filter_map(|step| match &step.op {
2555 RootOp::Bind { source, .. } => Some(source.display().to_string()),
2556 _ => None,
2557 })
2558 .collect();
2559
2560 assert!(bound.iter().any(|path| path == "/etc/ld.so.cache"));
2561 assert!(!bound.iter().any(|path| path == "/etc"));
2562 assert!(!bound.iter().any(|path| path.starts_with("/etc/shadow")));
2563 assert!(!bound.iter().any(|path| path.starts_with("/root")));
2564 assert!(!bound.iter().any(|path| path.starts_with("/home/u/.ssh")));
2565 }
2566
2567 /// The host has both files (see `FakeHost::usrmerge`) and the plan mirrors neither: their
2568 /// targets are bound from the workdir-backed staging files the parent writes instead.
2569 #[test]
2570 fn passwd_and_group_are_bound_from_the_staging_files_never_from_the_host() {
2571 let plan = plan_for("/home/u/w");
2572 let workdir = Path::new("/home/u/w");
2573
2574 for host_path in ["/etc/passwd", "/etc/group"] {
2575 assert!(
2576 !plan.steps.iter().any(|step| matches!(
2577 &step.op,
2578 RootOp::Bind { source, .. } if source == Path::new(host_path)
2579 )),
2580 "the host's {host_path} carries every account on the machine and must not be \
2581 bound into a capsule",
2582 );
2583 }
2584
2585 for (file, target) in [
2586 (SyntheticEtcFile::Passwd, "/tmp/etc/passwd"),
2587 (SyntheticEtcFile::Group, "/tmp/etc/group"),
2588 ] {
2589 let expected = RootStep {
2590 op: RootOp::Bind {
2591 source: synthetic_etc_source(&session_of(workdir), file),
2592 target: PathBuf::from(target),
2593 read_only: true,
2594 },
2595 // Not the host-mirrored entries' `required: false`: the parent wrote this file, so
2596 // a failed bind is a broken runtime, not a distribution that lacks the path.
2597 required: true,
2598 };
2599 assert!(
2600 plan.steps.contains(&expected),
2601 "expected {expected:?} in {:?}",
2602 plan.steps,
2603 );
2604 // And the mountpoint the bind lands on, since a bind needs an existing target.
2605 assert!(ops(&plan).contains(&RootOp::MkFile(PathBuf::from(target))));
2606 }
2607
2608 // Every other /etc entry is still the host's own file, untouched by this.
2609 assert!(ops(&plan).contains(&RootOp::Bind {
2610 source: PathBuf::from("/etc/ld.so.cache"),
2611 target: PathBuf::from("/tmp/etc/ld.so.cache"),
2612 read_only: true,
2613 }));
2614 }
2615
2616 /// Two lines, and the `pw_dir` field is the synthetic `HOME` verbatim — the property a capsule
2617 /// observes as `pwd.getpwuid(os.getuid()).pw_dir == os.environ["HOME"]`.
2618 #[test]
2619 fn the_synthetic_databases_name_root_and_the_capsules_own_id_and_nothing_else() {
2620 let identity = SealedAccountIdentity {
2621 uid: 1000,
2622 gid: 1000,
2623 home: "/w/.capsule-home",
2624 };
2625
2626 let passwd = SyntheticEtcFile::Passwd.render(&identity).unwrap();
2627 assert_eq!(
2628 passwd,
2629 "root:x:0:0:root:/root:/bin/sh\n\
2630 capsule:x:1000:1000:Murmur capsule:/w/.capsule-home:/bin/sh\n"
2631 );
2632 let group = SyntheticEtcFile::Group.render(&identity).unwrap();
2633 assert_eq!(group, "root:x:0:\ncapsule:x:1000:\n");
2634
2635 for (file, contents) in [
2636 (SyntheticEtcFile::Passwd, &passwd),
2637 (SyntheticEtcFile::Group, &group),
2638 ] {
2639 assert_eq!(
2640 contents.lines().count(),
2641 2,
2642 "{file:?} must stay a two-line database"
2643 );
2644 assert!(
2645 contents.ends_with('\n'),
2646 "{file:?} must end its last record with a newline"
2647 );
2648 }
2649 }
2650
2651 /// A capsule already running as uid 0 gets *one* passwd line, not a second `root` — two entries
2652 /// for uid 0 would leave `getpwuid(0)` resolving to whichever the parser saw first.
2653 #[test]
2654 fn a_root_capsule_gets_a_single_passwd_line_carrying_its_own_home() {
2655 let identity = SealedAccountIdentity {
2656 uid: 0,
2657 gid: 0,
2658 home: "/w/.capsule-home",
2659 };
2660
2661 assert_eq!(
2662 SyntheticEtcFile::Passwd.render(&identity).unwrap(),
2663 "root:x:0:0:root:/w/.capsule-home:/bin/sh\n"
2664 );
2665 assert_eq!(
2666 SyntheticEtcFile::Group.render(&identity).unwrap(),
2667 "root:x:0:\n"
2668 );
2669 }
2670
2671 /// `passwd(5)` has no escaping, so a home path containing the field separator would silently
2672 /// shift every field after it — a `pw_shell` of half a path, or worse.
2673 #[test]
2674 fn a_home_path_that_cannot_be_spelled_in_a_passwd_field_is_refused() {
2675 for home in ["/w/od:d", "/w/two\nlines"] {
2676 let identity = SealedAccountIdentity {
2677 uid: 1000,
2678 gid: 1000,
2679 home,
2680 };
2681 let error = SyntheticEtcFile::Passwd.render(&identity).unwrap_err();
2682 assert!(
2683 error.contains(home),
2684 "the message must name the offending path: {error}"
2685 );
2686 }
2687 }
2688
2689 #[test]
2690 fn the_workdir_keeps_its_absolute_path_inside_the_root() {
2691 let plan = plan_for("/home/u/.murmur/sessions/abc");
2692 assert_eq!(
2693 plan.workdir_in_root,
2694 PathBuf::from("/home/u/.murmur/sessions/abc")
2695 );
2696 assert!(ops(&plan).contains(&RootOp::Bind {
2697 source: PathBuf::from("/home/u/.murmur/sessions/abc"),
2698 target: PathBuf::from("/tmp/home/u/.murmur/sessions/abc"),
2699 read_only: false,
2700 }));
2701 // Every parent component is created before the bind, exactly once.
2702 let mkdirs: Vec<&RootOp> = plan
2703 .steps
2704 .iter()
2705 .map(|step| &step.op)
2706 .filter(|op| matches!(op, RootOp::MkDir(path) if path.starts_with("/tmp/home")))
2707 .collect();
2708 assert_eq!(mkdirs.len(), 5, "got {mkdirs:?}");
2709 }
2710
2711 #[test]
2712 fn tmp_is_backed_by_a_directory_inside_the_session_workdir() {
2713 let plan = plan_for("/home/u/w");
2714 assert!(ops(&plan).contains(&RootOp::Bind {
2715 source: session_of("/home/u/w").join(SEALED_TMP_DIR_NAME),
2716 target: PathBuf::from("/tmp/tmp"),
2717 read_only: false,
2718 }));
2719 // The accessible workdir's own top level gains nothing: `/tmp`'s backing store is under
2720 // `.murmur/<session id>/`, the one prefix a consumer diffing that directory excludes.
2721 assert!(!ops(&plan).contains(&RootOp::Bind {
2722 source: PathBuf::from("/home/u/w").join(SEALED_TMP_DIR_NAME),
2723 target: PathBuf::from("/tmp/tmp"),
2724 read_only: false,
2725 }));
2726 }
2727
2728 /// The composed root's own `/tmp` must not land on top of the path leading to the workdir.
2729 ///
2730 /// This is `mur run`'s default layout, not a curiosity: with no `--workdir`, the session
2731 /// workdir is a temporary directory, which is under `/tmp`. Planned in the other order, the
2732 /// `/tmp` bind hid the workdir bind underneath it and the construction died at `chdir` —
2733 /// *after* `pivot_root`, with the host root already detached, so the failure arrived as a bare
2734 /// `ENOENT` from inside a root that no longer had a way back.
2735 #[test]
2736 fn a_workdir_under_tmp_survives_the_composed_tmp_mounted_over_its_path() {
2737 let workdir = "/tmp/session-1234/w";
2738 let plan = plan_for(workdir);
2739 let operations = ops(&plan);
2740
2741 // A workdir under /tmp forces a base other than /tmp, so both mounts are still expressible.
2742 assert_eq!(
2743 choose_root_base(Path::new(workdir), SEALED_ROOT_BASE_CANDIDATES, |_| true),
2744 Some(PathBuf::from("/run")),
2745 );
2746
2747 let tmp_index = operations
2748 .iter()
2749 .position(
2750 |op| matches!(op, RootOp::Bind { target, .. } if target == Path::new("/tmp/tmp")),
2751 )
2752 .expect("the composed /tmp is bound");
2753 let workdir_index = operations
2754 .iter()
2755 .position(
2756 |op| matches!(op, RootOp::Bind { source, .. } if source == Path::new(workdir)),
2757 )
2758 .expect("the workdir is bound");
2759 assert!(
2760 tmp_index < workdir_index,
2761 "/tmp must be mounted before the workdir path is built inside it, or the workdir bind \
2762 is hidden by it",
2763 );
2764 }
2765
2766 #[test]
2767 fn dev_carries_the_oci_default_set_and_is_sealed_afterwards() {
2768 let plan = plan_for("/home/u/w");
2769 let operations = ops(&plan);
2770
2771 for device in SEALED_DEVICE_NODES {
2772 assert!(
2773 operations.contains(&RootOp::Bind {
2774 source: PathBuf::from("/dev").join(device.name),
2775 target: PathBuf::from("/tmp/dev").join(device.name),
2776 read_only: false,
2777 }),
2778 "missing /dev/{}",
2779 device.name
2780 );
2781 }
2782 // No block device, and nothing outside the OCI default set.
2783 assert!(!operations.iter().any(
2784 |op| matches!(op, RootOp::Bind { source, .. } if source == Path::new("/dev/sda"))
2785 ));
2786
2787 let tmpfs_index = operations
2788 .iter()
2789 .position(
2790 |op| matches!(op, RootOp::Tmpfs { target, .. } if target == Path::new("/tmp/dev")),
2791 )
2792 .expect("/dev tmpfs");
2793 let sealed_index = operations
2794 .iter()
2795 .position(
2796 |op| matches!(op, RootOp::RemountReadOnly(path) if path == Path::new("/tmp/dev")),
2797 )
2798 .expect("/dev sealed read-only");
2799 assert!(
2800 tmpfs_index < sealed_index,
2801 "the device nodes must be bound before /dev is sealed"
2802 );
2803 }
2804
2805 /// The plan always asks for a masked `/proc`; whether the kernel grants one is a runtime
2806 /// question the executor answers, and on a bare host the answer is no — see
2807 /// [`PROC_HIDEPID_OPTIONS`] for the `CAP_SYS_ADMIN`-over-the-PID-namespace rule that forces
2808 /// the bind fallback. What this pins is the *preference order*: masking is tried first, and
2809 /// the unmasked private `procfs` is the last of the three before the fallback, never the
2810 /// first.
2811 #[test]
2812 fn proc_is_planned_and_masking_is_preferred_over_an_unmasked_mount() {
2813 let plan = plan_for("/home/u/w");
2814 assert!(ops(&plan).contains(&RootOp::Proc {
2815 target: PathBuf::from("/tmp/proc")
2816 }));
2817 assert_eq!(PROC_HIDEPID_OPTIONS[0], "hidepid=2");
2818 assert_eq!(
2819 PROC_HIDEPID_OPTIONS.last(),
2820 Some(&""),
2821 "the unmasked spelling must be tried last, after every masked one",
2822 );
2823 }
2824
2825 #[test]
2826 fn manifest_derived_directories_are_whole_directory_binds_and_deduped() {
2827 let mut host = FakeHost::usrmerge();
2828 host.0
2829 .insert(PathBuf::from("/opt/python3.12"), PathKind::Dir);
2830 let plan = plan_composed_root(
2831 Path::new("/home/u/w"),
2832 &session_of("/home/u/w"),
2833 Path::new("/tmp"),
2834 &[PathBuf::from("/opt/python3.12"), PathBuf::from("/usr")],
2835 &[],
2836 &host,
2837 );
2838 let operations = ops(&plan);
2839
2840 assert!(operations.contains(&RootOp::Bind {
2841 source: PathBuf::from("/opt/python3.12"),
2842 target: PathBuf::from("/tmp/opt/python3.12"),
2843 read_only: true,
2844 }));
2845 // `/usr` was already bound by the fixed list; naming it again adds nothing.
2846 assert_eq!(
2847 operations
2848 .iter()
2849 .filter(
2850 |op| matches!(op, RootOp::Bind { source, .. } if source == Path::new("/usr"))
2851 )
2852 .count(),
2853 1
2854 );
2855 }
2856
2857 /// The property the whole capability rests on: `extra_read_only` shrinks when the host is
2858 /// missing a path, `staged_runtime_read_only` fails. Both directions are asserted from the
2859 /// same plan so the contrast cannot drift apart.
2860 #[test]
2861 fn a_staged_runtime_path_absent_from_the_host_still_plans_a_required_bind() {
2862 // Neither path is in `FakeHost::usrmerge()` — the host has neither.
2863 let plan = plan_composed_root(
2864 Path::new("/home/u/w"),
2865 &session_of("/home/u/w"),
2866 Path::new("/tmp"),
2867 &[PathBuf::from("/opt/optional-tree")],
2868 &[PathBuf::from("/opt/staged-tree")],
2869 &FakeHost::usrmerge(),
2870 );
2871
2872 // The optional entry contributes nothing at all — not even a step that would fail. This
2873 // is `mirror`'s silent-skip-on-absence, and it is why `extra_read_only` could not be the
2874 // destination for a staged grant.
2875 assert!(
2876 !plan.steps.iter().any(|step| matches!(
2877 &step.op,
2878 RootOp::Bind { source, .. } if source == Path::new("/opt/optional-tree")
2879 )),
2880 "an absent extra_read_only path must plan no step",
2881 );
2882
2883 // The staged entry is planned regardless, and is required — so the real `mount(2)`
2884 // returning ENOENT aborts the construction before `pivot_root`.
2885 let staged = plan
2886 .steps
2887 .iter()
2888 .find(|step| {
2889 matches!(
2890 &step.op,
2891 RootOp::Bind { source, .. } if source == Path::new("/opt/staged-tree")
2892 )
2893 })
2894 .expect("an absent staged_runtime path must still plan a bind");
2895 assert_eq!(
2896 staged.op,
2897 RootOp::Bind {
2898 source: PathBuf::from("/opt/staged-tree"),
2899 target: PathBuf::from("/tmp/opt/staged-tree"),
2900 read_only: true,
2901 },
2902 "staged trees are re-based read-only binds at their own absolute path",
2903 );
2904 assert!(
2905 staged.required,
2906 "a staged_runtime bind must be required, so a missing source is session-fatal \
2907 rather than a silently narrower root",
2908 );
2909 }
2910
2911 /// Staged binds land before `/etc`, `/dev`, `/proc`, `/tmp` and the workdir — and therefore
2912 /// before the `pivot_root` that `construct_composed_root` performs after every planned step.
2913 #[test]
2914 fn staged_runtime_binds_are_planned_before_every_other_step() {
2915 let mut host = FakeHost::usrmerge();
2916 host.0
2917 .insert(PathBuf::from("/opt/staged-tree"), PathKind::Dir);
2918 let plan = plan_composed_root(
2919 Path::new("/home/u/w"),
2920 &session_of("/home/u/w"),
2921 Path::new("/tmp"),
2922 &[],
2923 &[PathBuf::from("/opt/staged-tree")],
2924 &host,
2925 );
2926
2927 let position = |predicate: &dyn Fn(&RootOp) -> bool| {
2928 plan.steps
2929 .iter()
2930 .position(|step| predicate(&step.op))
2931 .expect("step must be planned")
2932 };
2933
2934 let staged = position(
2935 &|op| matches!(op, RootOp::Bind { source, .. } if source == Path::new("/opt/staged-tree")),
2936 );
2937 let fixed_runtime_tree = position(
2938 &|op| matches!(op, RootOp::Bind { source, .. } if source == Path::new("/usr")),
2939 );
2940 let etc = position(
2941 &|op| matches!(op, RootOp::Bind { source, .. } if source == Path::new("/etc/ssl")),
2942 );
2943 let dev = position(
2944 &|op| matches!(op, RootOp::Tmpfs { target, .. } if target == Path::new("/tmp/dev")),
2945 );
2946 let proc = position(&|op| matches!(op, RootOp::Proc { .. }));
2947 let tmp = position(
2948 &|op| matches!(op, RootOp::Bind { target, .. } if target == Path::new("/tmp/tmp")),
2949 );
2950 let workdir = position(
2951 &|op| matches!(op, RootOp::Bind { source, .. } if source == Path::new("/home/u/w")),
2952 );
2953
2954 // Ahead of the fixed runtime tree / `extra_read_only` loop, so a coincidental target
2955 // collision is won by the required registration rather than the optional one.
2956 assert!(
2957 staged < fixed_runtime_tree,
2958 "staged binds precede the fixed runtime tree"
2959 );
2960 assert!(staged < etc, "staged binds precede /etc");
2961 assert!(staged < dev, "staged binds precede /dev");
2962 assert!(staged < proc, "staged binds precede /proc");
2963 assert!(staged < tmp, "staged binds precede /tmp");
2964 assert!(staged < workdir, "staged binds precede the workdir bind");
2965 }
2966
2967 /// The collision case the ordering above exists to protect: a path named by *both* parameters
2968 /// is registered once, by the required loop.
2969 #[test]
2970 fn a_path_named_both_staged_and_optional_stays_required() {
2971 let mut host = FakeHost::usrmerge();
2972 host.0.insert(PathBuf::from("/opt/shared"), PathKind::Dir);
2973 let plan = plan_composed_root(
2974 Path::new("/home/u/w"),
2975 &session_of("/home/u/w"),
2976 Path::new("/tmp"),
2977 &[PathBuf::from("/opt/shared")],
2978 &[PathBuf::from("/opt/shared")],
2979 &host,
2980 );
2981
2982 let binds: Vec<&RootStep> = plan
2983 .steps
2984 .iter()
2985 .filter(|step| {
2986 matches!(
2987 &step.op,
2988 RootOp::Bind { source, .. } if source == Path::new("/opt/shared")
2989 )
2990 })
2991 .collect();
2992 assert_eq!(
2993 binds.len(),
2994 1,
2995 "the shared target is registered exactly once"
2996 );
2997 assert!(
2998 binds[0].required,
2999 "and the required registration is the one that won"
3000 );
3001 }
3002
3003 // ---- `UsernsGrant` ---------------------------------------------------------------------
3004
3005 /// Every variant, produced from the combination of readings that must produce it. The three
3006 /// permitting cases are the point: they reach the same achieved class, so only the variant
3007 /// separates a `sealed` result on a host whose hardening is switched off from one obtained
3008 /// through the shipped profile.
3009 #[test]
3010 fn every_userns_grant_comes_from_its_own_combination_of_readings() {
3011 let cases = [
3012 // AppArmor off, or the knob file missing entirely: nothing restricts anything.
3013 ((None, None, None), UsernsGrant::ApparmorAbsent),
3014 (
3015 (Some("N"), Some("1"), Some("unconfined")),
3016 UsernsGrant::ApparmorAbsent,
3017 ),
3018 // Enabled, restriction explicitly off host-wide — the `/etc/sysctl.d` drop-in case.
3019 (
3020 (Some("Y"), Some("0"), Some("unconfined")),
3021 UsernsGrant::RestrictionDisabledHostWide,
3022 ),
3023 // Enabled and the knob unreadable: not restricted, so not withheld.
3024 (
3025 (Some("1"), None, Some("unconfined")),
3026 UsernsGrant::RestrictionDisabledHostWide,
3027 ),
3028 // Restriction on, and a profile whose name begins with `mur-sealed` is attached —
3029 // both the shipped profile and the checkout profile the dev script generates.
3030 (
3031 (Some("Y"), Some("Y"), Some("mur-sealed (unconfined)")),
3032 UsernsGrant::ProfileConfining,
3033 ),
3034 (
3035 (Some("Y"), Some("1"), Some("mur-sealed-home (unconfined)")),
3036 UsernsGrant::ProfileConfining,
3037 ),
3038 (
3039 (Some("Y"), Some("1"), Some("mur-sealed-dev (unconfined)")),
3040 UsernsGrant::ProfileConfining,
3041 ),
3042 // Restriction on and nothing attached, or something else attached.
3043 (
3044 (Some("Y"), Some("Y"), Some("unconfined")),
3045 UsernsGrant::Withheld,
3046 ),
3047 (
3048 (Some("Y"), Some("1"), Some("firefox (enforce)")),
3049 UsernsGrant::Withheld,
3050 ),
3051 ((Some("Y"), Some("1"), None), UsernsGrant::Withheld),
3052 ];
3053
3054 for ((enabled, restricted, current), expected) in cases {
3055 assert_eq!(
3056 UsernsGrant::from_readings(enabled, restricted, current),
3057 expected,
3058 "enabled={enabled:?} restricted={restricted:?} current={current:?}"
3059 );
3060 }
3061 }
3062
3063 /// The wire names are what `--explain-scope --json`, `session_start` and `mur doctor` all
3064 /// print, so two variants sharing one would re-collapse exactly the distinction this enum
3065 /// exists to make. `permits_userns` is the whole of what the runtime decisions read, and it
3066 /// must be false for `Withheld` alone — any other variant denying would refuse a host the
3067 /// runtime is required to keep serving.
3068 #[test]
3069 fn every_grant_has_a_distinct_wire_name_and_only_withheld_denies() {
3070 let names: Vec<&str> = UsernsGrant::ALL
3071 .iter()
3072 .map(|grant| grant.wire_name())
3073 .collect();
3074 let mut unique = names.clone();
3075 unique.sort_unstable();
3076 unique.dedup();
3077 assert_eq!(unique.len(), names.len(), "two grants share a wire name");
3078 assert_eq!(
3079 names,
3080 [
3081 "apparmor_absent",
3082 "restriction_disabled_host_wide",
3083 "profile_confining",
3084 "withheld",
3085 ]
3086 );
3087
3088 for grant in UsernsGrant::ALL {
3089 assert_eq!(
3090 grant.permits_userns(),
3091 *grant != UsernsGrant::Withheld,
3092 "{grant:?} must permit the namespace unless it is Withheld"
3093 );
3094 assert_eq!(
3095 serde_json::to_value(grant).unwrap(),
3096 serde_json::Value::String(grant.wire_name().to_string()),
3097 "the serialized form and the wire name must be one string"
3098 );
3099 }
3100
3101 // The `mur doctor` line under each wire name has to say something different for each, or
3102 // the block repeats the wire name in longer words and tells an operator nothing.
3103 let mut summaries: Vec<&str> = UsernsGrant::ALL.iter().map(|g| g.summary()).collect();
3104 summaries.sort_unstable();
3105 summaries.dedup();
3106 assert_eq!(
3107 summaries.len(),
3108 UsernsGrant::ALL.len(),
3109 "two grants share a summary"
3110 );
3111
3112 // An unprobed host claims nothing, exactly as the `bool` field's `false` default did.
3113 assert_eq!(UsernsGrant::default(), UsernsGrant::Withheld);
3114 }
3115
3116 // ---- the installed profile ------------------------------------------------------------
3117
3118 /// All four outcomes, driven by a `Result` rather than by the filesystem, so a host with no
3119 /// AppArmor and a host with an unreadable `/etc/apparmor.d` are both covered without root and
3120 /// without touching a byte of `/etc`.
3121 #[test]
3122 fn the_installed_profile_classifier_separates_all_four_outcomes() {
3123 let shipped = std::fs::read(concat!(
3124 env!("CARGO_MANIFEST_DIR"),
3125 "/../../packaging/apparmor/mur-sealed"
3126 ))
3127 .expect("the shipped profile is in the workspace");
3128 assert_eq!(
3129 classify_installed_profile(Ok(shipped)),
3130 InstalledProfileState::Matches
3131 );
3132
3133 let drifted = classify_installed_profile(Ok(b"# an older revision\n".to_vec()));
3134 match drifted {
3135 InstalledProfileState::Drifted { installed_sha256 } => {
3136 assert_ne!(installed_sha256, SEALED_APPARMOR_PROFILE_SHA256);
3137 assert_eq!(installed_sha256.len(), 64);
3138 }
3139 other => panic!("edited bytes must read as drift, got {other:?}"),
3140 }
3141
3142 assert_eq!(
3143 classify_installed_profile(Err(std::io::Error::from(std::io::ErrorKind::NotFound))),
3144 InstalledProfileState::Absent
3145 );
3146
3147 // "I could not look" must never read as "it is not there".
3148 match classify_installed_profile(Err(std::io::Error::from(
3149 std::io::ErrorKind::PermissionDenied,
3150 ))) {
3151 InstalledProfileState::Unreadable { error } => assert!(!error.is_empty()),
3152 other => panic!("a read error must stay distinct from absence, got {other:?}"),
3153 }
3154 }
3155
3156 /// A refused `pivot_root(2)` is its own finding, with its own remediation. Without this
3157 /// variant a host that permits `mount(2)` and refuses the pivot probes as `Ok` and fails at
3158 /// the first subprocess with `E-RUN-014`, after an inference call has already been paid for.
3159 #[test]
3160 fn a_refused_pivot_root_is_its_own_blocker() {
3161 let probe = SealedProbe {
3162 userns_grant: UsernsGrant::ProfileConfining,
3163 namespace: NamespaceProbe::PivotRootDenied,
3164 };
3165 assert_eq!(
3166 sealed_blocker(true, true, probe),
3167 Some(SealedBlocker::PivotRootDenied)
3168 );
3169
3170 let reason = SealedBlocker::PivotRootDenied.reason();
3171 assert!(reason.contains("pivot_root(2)"), "reason was: {reason}");
3172 // Distinguishable from `MountDenied`'s: an operator handed the mount remediation on a
3173 // host whose mounts already work has been sent somewhere useless.
3174 assert_ne!(reason, SealedBlocker::MountDenied.reason());
3175 assert!(reason.contains("seccomp"), "reason was: {reason}");
3176 }
3177
3178 /// Every [`NamespaceProbe`] variant reaches a blocker decision, so a variant added later
3179 /// cannot fall through to "this host can back sealed".
3180 #[test]
3181 fn every_namespace_probe_outcome_is_attributed() {
3182 const ALL: &[NamespaceProbe] = &[
3183 NamespaceProbe::Ok,
3184 NamespaceProbe::Denied,
3185 NamespaceProbe::MapDenied,
3186 NamespaceProbe::MountDenied,
3187 NamespaceProbe::PivotRootDenied,
3188 NamespaceProbe::Unsupported,
3189 ];
3190 for namespace in ALL {
3191 let probe = SealedProbe {
3192 userns_grant: UsernsGrant::ProfileConfining,
3193 namespace: *namespace,
3194 };
3195 let blocker = sealed_blocker(true, true, probe);
3196 if *namespace == NamespaceProbe::Ok {
3197 assert_eq!(blocker, None, "a complete rehearsal must back sealed");
3198 } else {
3199 let blocker = blocker.unwrap_or_else(|| panic!("{namespace:?} named no blocker"));
3200 assert!(
3201 SealedBlocker::ALL.contains(&blocker),
3202 "{blocker:?} is missing from SealedBlocker::ALL"
3203 );
3204 }
3205 }
3206 }
3207
3208 /// The probe child cannot compose a `CString`, so it carries its own NUL-terminated copies of
3209 /// the base candidates. A copy that drifted would have the probe rehearse a base the real
3210 /// construction would never choose.
3211 #[test]
3212 fn probe_root_base_candidates_match_the_str_list() {
3213 let literals: Vec<&str> = SEALED_ROOT_BASE_CANDIDATES_C
3214 .iter()
3215 .map(|value| value.to_str().expect("ASCII path literal"))
3216 .collect();
3217 assert_eq!(literals, SEALED_ROOT_BASE_CANDIDATES);
3218 }
3219
3220 /// Same reason, for the old-root parking directory: three spellings of one name.
3221 #[test]
3222 fn old_root_names_agree() {
3223 assert_eq!(OLD_ROOT_NAME_C.to_str().unwrap(), OLD_ROOT_NAME);
3224 assert_eq!(
3225 OLD_ROOT_PIVOTED_C.to_str().unwrap(),
3226 format!("/{OLD_ROOT_NAME}")
3227 );
3228 }
3229
3230 /// Four outcomes, none of them inferred from a path. `unconfined` and "I could not look" stay
3231 /// apart for the same reason [`InstalledProfileState`]'s two absences do.
3232 #[test]
3233 fn profile_attachment_reports_only_what_the_kernel_said() {
3234 assert_eq!(
3235 classify_profile_attachment(Some("mur-sealed (unconfined)")),
3236 ProfileAttachment::Sealed {
3237 profile: "mur-sealed".to_string()
3238 }
3239 );
3240 assert_eq!(
3241 classify_profile_attachment(Some("mur-sealed-home")),
3242 ProfileAttachment::Sealed {
3243 profile: "mur-sealed-home".to_string()
3244 }
3245 );
3246 assert_eq!(
3247 classify_profile_attachment(Some("snap.chromium.chromium (enforce)")),
3248 ProfileAttachment::Other {
3249 profile: "snap.chromium.chromium".to_string()
3250 }
3251 );
3252 assert_eq!(
3253 classify_profile_attachment(Some("unconfined")),
3254 ProfileAttachment::Unconfined
3255 );
3256 assert_eq!(
3257 classify_profile_attachment(Some("unconfined\n")),
3258 ProfileAttachment::Unconfined
3259 );
3260 assert_eq!(
3261 classify_profile_attachment(None),
3262 ProfileAttachment::NotReported
3263 );
3264 assert_eq!(
3265 classify_profile_attachment(Some(" ")),
3266 ProfileAttachment::NotReported
3267 );
3268 }
3269
3270 /// The attachment list is documentation of the shipped profile, never a verdict — nothing
3271 /// decides anything by matching a path against it. This keeps it honest about what the file
3272 /// actually declares.
3273 #[test]
3274 fn attachment_paths_are_the_ones_the_shipped_profile_declares() {
3275 let path = concat!(
3276 env!("CARGO_MANIFEST_DIR"),
3277 "/../../packaging/apparmor/mur-sealed"
3278 );
3279 let profile =
3280 std::fs::read_to_string(path).expect("the shipped profile is in the workspace");
3281 for attachment in SEALED_APPARMOR_ATTACHMENT_PATHS {
3282 assert!(
3283 profile.contains(attachment),
3284 "{attachment} is not an attachment in packaging/apparmor/mur-sealed"
3285 );
3286 }
3287 }
3288
3289 /// The digest constant is a literal because `capsule-runtime` is published to crates.io and
3290 /// `cargo package` would not carry a file from outside the crate directory. This test is what
3291 /// keeps the literal honest, and it does not run during `cargo package`.
3292 #[test]
3293 fn shipped_profile_digest_constant_matches_the_file() {
3294 let path = concat!(
3295 env!("CARGO_MANIFEST_DIR"),
3296 "/../../packaging/apparmor/mur-sealed"
3297 );
3298 let bytes = std::fs::read(path).expect("the shipped profile is in the workspace");
3299 let actual = murmur_artifact::sha256_hex(&bytes);
3300 assert_eq!(
3301 actual, SEALED_APPARMOR_PROFILE_SHA256,
3302 "packaging/apparmor/mur-sealed changed. Update SEALED_APPARMOR_PROFILE_SHA256 in \
3303 crates/capsule-runtime/src/sealed.rs to:\n {actual}"
3304 );
3305 }
3306
3307 /// The profile is the fix and the sysctl is a fallback that costs the whole host its
3308 /// unprivileged-userns hardening. Both must be in the text — on a host where no profile can be
3309 /// loaded the sysctl is the right answer — but their order and the stated cost are what stop a
3310 /// reader taking them for peers.
3311 #[test]
3312 fn the_apparmor_reason_offers_the_profile_before_the_sysctl() {
3313 let reason = SealedBlocker::AppArmorProfileMissing.reason();
3314 let profile = reason
3315 .find("apparmor_parser -r")
3316 .expect("the profile install command must be named");
3317 let sysctl = reason
3318 .find("kernel.apparmor_restrict_unprivileged_userns=0")
3319 .expect("the fallback must still be named for hosts that cannot load a profile");
3320 assert!(
3321 profile < sysctl,
3322 "the profile install must come first: {reason}"
3323 );
3324 assert!(
3325 reason.contains("LAST RESORT"),
3326 "the sysctl must be labelled a fallback, not offered as a peer: {reason}"
3327 );
3328 assert!(
3329 reason.contains("every program on the machine"),
3330 "the sysctl's host-wide cost must be stated: {reason}"
3331 );
3332 assert!(
3333 reason.contains("scripts/install-dev-apparmor.sh"),
3334 "a checkout build must be told how to get the narrow grant: {reason}"
3335 );
3336 }
3337
3338 #[test]
3339 fn blocker_blames_apparmor_before_the_namespace_outcome_it_causes() {
3340 let probe = SealedProbe {
3341 userns_grant: UsernsGrant::Withheld,
3342 namespace: NamespaceProbe::Denied,
3343 };
3344 assert_eq!(
3345 sealed_blocker(true, true, probe),
3346 Some(SealedBlocker::AppArmorProfileMissing)
3347 );
3348 let reason = SealedBlocker::AppArmorProfileMissing.reason();
3349 assert!(reason.contains("mur-sealed"));
3350 assert!(reason.contains("apparmor_parser -r"));
3351 }
3352
3353 #[test]
3354 fn blocker_names_the_container_remediation_verbatim() {
3355 let probe = SealedProbe {
3356 userns_grant: UsernsGrant::ProfileConfining,
3357 namespace: NamespaceProbe::Denied,
3358 };
3359 assert_eq!(
3360 sealed_blocker(true, true, probe),
3361 Some(SealedBlocker::NamespaceCreationDenied)
3362 );
3363 let reason = SealedBlocker::NamespaceCreationDenied.reason();
3364 assert!(reason.contains("--cap-add SYS_ADMIN"));
3365 assert!(reason.contains("outside the container"));
3366 }
3367
3368 #[test]
3369 fn a_map_failure_is_not_reported_as_the_container_case() {
3370 // `MapDenied` must stay distinct from `Denied`: a host that creates the namespace and
3371 // then refuses the id mapping is not the container case, and must not be told to add
3372 // `--cap-add SYS_ADMIN` to a container it is not running in.
3373 let probe = SealedProbe {
3374 userns_grant: UsernsGrant::ProfileConfining,
3375 namespace: NamespaceProbe::MapDenied,
3376 };
3377 assert_eq!(
3378 sealed_blocker(true, true, probe),
3379 Some(SealedBlocker::IdMapDenied)
3380 );
3381
3382 let reason = SealedBlocker::IdMapDenied.reason();
3383 assert!(
3384 reason.contains("uid_map"),
3385 "must name the step that failed: {reason}"
3386 );
3387 assert!(
3388 !reason.contains("CAP_SYS_ADMIN") && !reason.contains("--cap-add"),
3389 "must not send the operator after a capability that isn't the problem: {reason}",
3390 );
3391
3392 // And the container case must keep its own, different advice.
3393 let denied = SealedProbe {
3394 userns_grant: UsernsGrant::ProfileConfining,
3395 namespace: NamespaceProbe::Denied,
3396 };
3397 assert_eq!(
3398 sealed_blocker(true, true, denied),
3399 Some(SealedBlocker::NamespaceCreationDenied)
3400 );
3401 assert_ne!(
3402 SealedBlocker::IdMapDenied.reason(),
3403 SealedBlocker::NamespaceCreationDenied.reason(),
3404 );
3405 }
3406
3407 /// `SealedBlocker::ALL` is what `murmur-cli`'s containment test matches a refusal against, so a
3408 /// variant missing from it makes that test assert something false about the *host*. Adding a
3409 /// variant must break this test — loudly, here — rather than surface three crates away.
3410 #[test]
3411 fn every_blocker_is_listed_in_all_with_its_own_actionable_reason() {
3412 // The exhaustive match is the mechanism: a new variant fails to compile until it is added
3413 // here, and the `contains` below then forces it into `ALL` too.
3414 for blocker in SealedBlocker::ALL {
3415 let named = match blocker {
3416 SealedBlocker::NotLinux
3417 | SealedBlocker::AppArmorProfileMissing
3418 | SealedBlocker::NamespaceCreationDenied
3419 | SealedBlocker::IdMapDenied
3420 | SealedBlocker::MountDenied
3421 | SealedBlocker::PivotRootDenied
3422 | SealedBlocker::KernelUnsupported
3423 | SealedBlocker::LandlockUnavailable => *blocker,
3424 };
3425 assert!(
3426 SealedBlocker::ALL.contains(&named),
3427 "{named:?} is missing from SealedBlocker::ALL",
3428 );
3429 }
3430
3431 let reasons: Vec<String> = SealedBlocker::ALL
3432 .iter()
3433 .map(|blocker| blocker.reason())
3434 .collect();
3435 for reason in &reasons {
3436 assert!(reason.starts_with("sealed "), "got: {reason}");
3437 assert!(!reason.contains('\n'), "got: {reason}");
3438 assert!(
3439 reason.len() > 80,
3440 "a blocker reason must name the mechanism and its remediation, not just fail: \
3441 {reason}",
3442 );
3443 }
3444 let mut unique = reasons.clone();
3445 unique.sort();
3446 unique.dedup();
3447 assert_eq!(
3448 unique.len(),
3449 reasons.len(),
3450 "two blockers share a reason, so the refusal cannot say which one applies",
3451 );
3452 }
3453
3454 #[test]
3455 fn blocker_is_none_only_when_every_precondition_holds() {
3456 let ok = SealedProbe {
3457 userns_grant: UsernsGrant::ProfileConfining,
3458 namespace: NamespaceProbe::Ok,
3459 };
3460 assert_eq!(sealed_blocker(true, true, ok), None);
3461 assert_eq!(
3462 sealed_blocker(false, true, ok),
3463 Some(SealedBlocker::NotLinux)
3464 );
3465 assert_eq!(
3466 sealed_blocker(true, false, ok),
3467 Some(SealedBlocker::LandlockUnavailable)
3468 );
3469 assert_eq!(
3470 sealed_blocker(
3471 true,
3472 true,
3473 SealedProbe {
3474 userns_grant: UsernsGrant::ProfileConfining,
3475 namespace: NamespaceProbe::MountDenied
3476 }
3477 ),
3478 Some(SealedBlocker::MountDenied)
3479 );
3480 assert_eq!(
3481 sealed_blocker(
3482 true,
3483 true,
3484 SealedProbe {
3485 userns_grant: UsernsGrant::ProfileConfining,
3486 namespace: NamespaceProbe::Unsupported
3487 }
3488 ),
3489 Some(SealedBlocker::KernelUnsupported)
3490 );
3491 }
3492
3493 #[test]
3494 fn the_device_set_is_exactly_the_oci_default_and_excludes_shm() {
3495 let names: Vec<&str> = SEALED_DEVICE_NODES.iter().map(|d| d.name).collect();
3496 assert_eq!(names, ["null", "zero", "full", "random", "urandom", "tty"]);
3497 assert!(!names.contains(&"shm"));
3498 assert!(!names.contains(&"console"));
3499 // Every entry records the canonical major/minor the OCI spec names.
3500 assert_eq!(
3501 SEALED_DEVICE_NODES
3502 .iter()
3503 .map(|d| (d.path, d.major, d.minor, d.mode, d.writable))
3504 .collect::<Vec<_>>(),
3505 [
3506 ("/dev/null", 1, 3, 0o666, true),
3507 ("/dev/zero", 1, 5, 0o666, true),
3508 ("/dev/full", 1, 7, 0o666, true),
3509 ("/dev/random", 1, 8, 0o666, false),
3510 ("/dev/urandom", 1, 9, 0o666, false),
3511 ("/dev/tty", 5, 0, 0o666, true),
3512 ]
3513 );
3514 }
3515}