capsule_runtime/staged_runtime.rs
1//! `capabilities.shell.staged_runtime`: staging a pinned host runtime tree into a capsule root.
2//!
3//! ## The mechanism, and why it is the inverse of `interpreter_runtime`
4//!
5//! A path-based interpreter — CPython above all — cannot run from an allowlisted binary alone. Its
6//! stdlib lives outside the workdir, at a path the `DT_NEEDED` closure cannot discover, so
7//! *something* has to make that tree reachable. There are exactly two ways to do it:
8//!
9//! * [`crate::sandbox`]'s `interpreter_runtime` grants widen the capsule's Landlock scope
10//! *outwards*, so the host's `/usr/lib/python3.11` stays readable from inside. That works at
11//! `scoped`, and it costs: the capsule is now coupled to one host's directory layout, host
12//! paths stay reachable, and moving to a second host with a different Python build silently
13//! changes what the capsule runs. This is why declaring one fires `W-SEC-009`.
14//! * `staged_runtime` moves the tree *inwards*. A `sealed` capsule already builds a composed
15//! root out of read-only binds ([`crate::sealed`]); staging adds one more bind, of an
16//! operator-named, operator-pinned runtime tree, at the same absolute path it has on the host.
17//! Nothing outside the root becomes reachable, and the `pin` makes "the same interpreter on
18//! both hosts" a claim a human can check rather than assume.
19//!
20//! The second supersedes the first for any binary that uses it, which is why the manifest parser
21//! refuses both for one binary rather than merging them (see `parse_staged_runtime` in
22//! `murmur-artifact`). Staging is not an additional grant layered onto a widened scope; it is the
23//! thing that makes widening unnecessary.
24//!
25//! ## What this module contains, and how the mount reaches the launch path
26//!
27//! Two independent pieces, with very different reach:
28//!
29//! * [`check_staged_runtime_floor`] — pure, every OS, and **live in the launch path**. It is the
30//! gate that refuses to stage a capsule declaring `staged_runtime` below an effective `sealed`
31//! floor, called from `runtime::stage_session` next to `check_containment_floor`.
32//! * [`bind_mount_staged_runtimes`] — Linux-only, and the executable statement of the mount
33//! *contract*: the re-basing rule, the two-call read-only bind, and the failure taxonomy. It
34//! is built and proven here in isolation, against a throwaway directory tree inside a mount
35//! namespace its own test creates.
36//!
37//! The staging mounts **are** performed on every `sealed` launch that declares a grant — but not
38//! by calling that function. `sandbox::resolve_staged_runtime_dirs` collects each grant's
39//! `source_path` into `ShellEnforcement::staged_runtime_dirs`, `build_sealed_root` passes them to
40//! [`crate::sealed::plan_composed_root`]'s `staged_runtime_read_only` parameter, and they are
41//! planned as *required* `RootOp::Bind` steps ahead of every other step — and so ahead of
42//! `pivot_root`. A grant whose source is missing fails at the real `mount(2)` with `ENOENT`,
43//! aborting the construction; that reaches the operator as
44//! `RuntimeError::SealedRootConstructionFailed` (`E-RUN-014`) and is session-fatal.
45//!
46//! The function itself stays uncalled by production code because it allocates, and the composed
47//! root executes inside the forked child's `pre_exec` window, which must not — see
48//! [`bind_mount_staged_runtimes`]'s own doc comment. It remains valuable as an independently
49//! provable statement of what the planned step must do on a real kernel, without needing a sealed
50//! capsule, a `pivot_root` or the enforcement pipeline to demonstrate it.
51
52#[cfg(target_os = "linux")]
53use std::path::{Path, PathBuf};
54
55use murmur_artifact::{ContainmentClass, StagedRuntimeGrant};
56
57use crate::errors::RuntimeError;
58
59/// Refuses a capsule that declares `capabilities.shell.staged_runtime` without an effective
60/// `sealed` containment floor.
61///
62/// Compares against the **declared** floor — the already-combined strongest of manifest,
63/// workspace config and `--containment` — and never probes the host. That is the whole point of
64/// keeping it separate from [`crate::containment::check_containment_floor`], which asks the
65/// opposite question (can this host back what was declared?) and has the opposite remedy. A
66/// machine that could deliver `sealed` still fails this check when the capsule did not ask for
67/// `sealed`, because the composed root is only built for a capsule that declared it: launching
68/// anyway would produce a capsule whose interpreter is simply not there, diagnosed as a missing
69/// binary somewhere deep in a task rather than as the policy error it is.
70///
71/// Pure, so the decision is testable on any OS and at any enforcement tier.
72pub fn check_staged_runtime_floor(
73 grants: &[StagedRuntimeGrant],
74 declared: ContainmentClass,
75) -> Result<(), RuntimeError> {
76 if grants.is_empty() || declared >= ContainmentClass::Sealed {
77 return Ok(());
78 }
79
80 Err(RuntimeError::StagedRuntimeRequiresSealed {
81 // Every offending binary, not just the first: an operator fixing this should not have to
82 // re-run to discover the second grant.
83 binaries: grants.iter().map(|grant| grant.binary.clone()).collect(),
84 declared,
85 })
86}
87
88/// Where [`bind_mount_staged_runtimes`] failed, so the error names a step rather than a bare
89/// errno.
90#[cfg(target_os = "linux")]
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum StagedRuntimeMountStage {
93 /// Creating the mount point inside the target root.
94 CreateMountPoint,
95 /// The `MS_BIND | MS_REC` mount itself.
96 Bind,
97 /// The second, `MS_REMOUNT | MS_BIND | MS_RDONLY` call that actually makes the bind read-only.
98 RemountReadOnly,
99}
100
101#[cfg(target_os = "linux")]
102impl std::fmt::Display for StagedRuntimeMountStage {
103 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104 let name = match self {
105 Self::CreateMountPoint => "create mount point",
106 Self::Bind => "bind mount",
107 Self::RemountReadOnly => "remount read-only",
108 };
109 f.write_str(name)
110 }
111}
112
113/// One staged-runtime bind mount that could not be established.
114#[cfg(target_os = "linux")]
115#[derive(Debug, Clone)]
116pub struct StagedRuntimeMountError {
117 /// The `staged_runtime` grant's binary, so the failure names the capsule's own declaration
118 /// rather than only a path.
119 pub binary: String,
120 /// The host tree that was being staged.
121 pub source_path: PathBuf,
122 /// Where it was being staged to, inside the target root.
123 pub target_path: PathBuf,
124 pub stage: StagedRuntimeMountStage,
125 /// `errno` from the failing syscall.
126 pub errno: i32,
127}
128
129#[cfg(target_os = "linux")]
130impl std::fmt::Display for StagedRuntimeMountError {
131 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132 write!(
133 f,
134 "staged runtime for '{}': {} of {} at {} failed (errno {})",
135 self.binary,
136 self.stage,
137 self.source_path.display(),
138 self.target_path.display(),
139 self.errno
140 )
141 }
142}
143
144#[cfg(target_os = "linux")]
145impl std::error::Error for StagedRuntimeMountError {}
146
147/// Re-bases an absolute host path under `root`, preserving it verbatim: `/opt/py` under `/newroot`
148/// is `/newroot/opt/py`.
149///
150/// Identity of the path inside and outside the root is the property the whole mechanism rests on —
151/// `sys.prefix`, shebang lines, `PYTHONHOME` and anything a previous turn wrote to disk all keep
152/// resolving, and the capsule never has to be told its interpreter moved. It is the same rule
153/// [`crate::sealed`] applies to the session workdir, via the same [`crate::sealed::rebase`]
154/// function — reused here rather than reimplemented, so there is exactly one re-basing rule.
155#[cfg(target_os = "linux")]
156fn target_under_root(root: &Path, source_path: &Path) -> PathBuf {
157 crate::sealed::rebase(root, source_path)
158}
159
160/// Bind-mounts every declared `staged_runtime` tree read-only into `root`, each at its own
161/// absolute host path re-based under `root`. Returns the target paths it established, in order.
162///
163/// # Where the launch path performs this, and why it is not here
164///
165/// The staging mounts are live: every `sealed` launch that declares a grant establishes them
166/// before `pivot_root`. They are just not established by calling this function, and the reason is
167/// worth stating rather than leaving for someone to rediscover.
168///
169/// [`crate::sealed`] executes its mounts inside the forked child's `pre_exec` window, where
170/// allocation can deadlock on a malloc lock another thread of the parent held at `fork()`. It
171/// avoids that by splitting the decision (`plan_composed_root`, pure, parent-side) from the
172/// execution (`construct_composed_root`, raw syscalls over `CString`s built before the fork). This
173/// function allocates — it joins paths and creates directories — so calling it from that window
174/// as-is would break the invariant that split exists to protect.
175///
176/// The wiring is therefore a *plan entry*, not a call: each grant's `source_path` is fed to
177/// [`crate::sealed::plan_composed_root`]'s `staged_runtime_read_only` parameter, which lowers to
178/// an allocation-free, **required** `RootOp::Bind { read_only: true }`, planned ahead of every
179/// other step. Note that this is a dedicated parameter and not the pre-existing `extra_read_only`:
180/// entries there are planned via `mirror`, which schedules *nothing at all* for a path the host
181/// does not have, so a missing source would have launched a capsule into a root silently lacking
182/// its runtime tree — the opposite of the guarantee below.
183///
184/// What remains here is the executable, independently provable statement of what that plan entry
185/// must *do*: same re-basing rule, same two-call read-only bind, same failure taxonomy. Its test
186/// is what pins that behaviour down on a real kernel without requiring a sealed capsule, a
187/// `pivot_root`, or the enforcement pipeline.
188///
189/// # Preconditions
190///
191/// The caller must already be inside a mount namespace it is willing to modify — this function
192/// does not create one, on purpose, so it composes with a namespace built for other reasons. Each
193/// `source_path` must exist on the host; a grant naming a missing tree fails at
194/// [`StagedRuntimeMountStage::Bind`] with `ENOENT` rather than being skipped, because a capsule
195/// that declared a pinned runtime and did not get it must not proceed as though it had.
196///
197/// # Read-only takes two calls
198///
199/// A single `mount(MS_BIND | MS_RDONLY)` does **not** produce a read-only bind — the kernel
200/// applies the flag to the new mount only on a follow-up `MS_REMOUNT | MS_BIND`. Both are issued
201/// here, and `MS_NOSUID` rides along on the remount, matching [`crate::sealed`]'s binds.
202#[cfg(target_os = "linux")]
203#[allow(unsafe_code)]
204pub fn bind_mount_staged_runtimes(
205 root: &Path,
206 grants: &[StagedRuntimeGrant],
207) -> Result<Vec<PathBuf>, StagedRuntimeMountError> {
208 use std::ffi::CString;
209 use std::os::unix::ffi::OsStrExt;
210
211 let mut staged = Vec::with_capacity(grants.len());
212
213 for grant in grants {
214 let source = PathBuf::from(&grant.source_path);
215 let target = target_under_root(root, &source);
216 let fail = |stage: StagedRuntimeMountStage, errno: i32| StagedRuntimeMountError {
217 binary: grant.binary.clone(),
218 source_path: source.clone(),
219 target_path: target.clone(),
220 stage,
221 errno,
222 };
223
224 // The mount point has to exist before anything can be bound over it. A runtime tree is a
225 // directory, so this is `mkdir -p`; `EEXIST` is success, which is what `create_dir_all`
226 // already reports.
227 std::fs::create_dir_all(&target).map_err(|error| {
228 fail(
229 StagedRuntimeMountStage::CreateMountPoint,
230 error.raw_os_error().unwrap_or(0),
231 )
232 })?;
233
234 // A NUL byte cannot reach here from a parsed manifest (the YAML parser rejects it in a
235 // scalar), but the conversion is fallible, so report it as the mount-point failure it
236 // functionally is rather than unwrapping in a function that may one day run near a fork.
237 let c_source = CString::new(source.as_os_str().as_bytes())
238 .map_err(|_| fail(StagedRuntimeMountStage::CreateMountPoint, libc::EINVAL))?;
239 let c_target = CString::new(target.as_os_str().as_bytes())
240 .map_err(|_| fail(StagedRuntimeMountStage::CreateMountPoint, libc::EINVAL))?;
241
242 // SAFETY: both pointers are NUL-terminated and outlive each call; the filesystem-type and
243 // data arguments are unused for a bind mount and are passed as NULL, as `mount(2)`
244 // specifies.
245 unsafe {
246 if libc::mount(
247 c_source.as_ptr(),
248 c_target.as_ptr(),
249 std::ptr::null(),
250 libc::MS_BIND | libc::MS_REC,
251 std::ptr::null(),
252 ) != 0
253 {
254 return Err(fail(StagedRuntimeMountStage::Bind, errno()));
255 }
256
257 // The call that actually makes it read-only. Without this the tree above is writable.
258 if libc::mount(
259 std::ptr::null(),
260 c_target.as_ptr(),
261 std::ptr::null(),
262 libc::MS_REMOUNT | libc::MS_BIND | libc::MS_RDONLY | libc::MS_NOSUID,
263 std::ptr::null(),
264 ) != 0
265 {
266 return Err(fail(StagedRuntimeMountStage::RemountReadOnly, errno()));
267 }
268 }
269
270 staged.push(target);
271 }
272
273 Ok(staged)
274}
275
276#[cfg(target_os = "linux")]
277fn errno() -> i32 {
278 std::io::Error::last_os_error().raw_os_error().unwrap_or(0)
279}
280
281#[cfg(test)]
282#[allow(clippy::print_stdout, clippy::print_stderr)]
283mod tests {
284 use super::*;
285
286 fn grant(binary: &str, source_path: &str) -> StagedRuntimeGrant {
287 StagedRuntimeGrant {
288 binary: binary.to_string(),
289 source_path: source_path.to_string(),
290 pin: "test-pin-1".to_string(),
291 }
292 }
293
294 #[test]
295 fn no_grants_never_requires_sealed() {
296 for declared in [
297 ContainmentClass::Advisory,
298 ContainmentClass::Scoped,
299 ContainmentClass::Sealed,
300 ] {
301 assert!(check_staged_runtime_floor(&[], declared).is_ok());
302 }
303 }
304
305 #[test]
306 fn staged_runtime_below_sealed_is_refused() {
307 for declared in [ContainmentClass::Advisory, ContainmentClass::Scoped] {
308 let error = check_staged_runtime_floor(&[grant("python3", "/opt/py")], declared)
309 .expect_err("a staged runtime below sealed must be refused");
310 match error {
311 RuntimeError::StagedRuntimeRequiresSealed {
312 binaries,
313 declared: reported,
314 } => {
315 assert_eq!(binaries, vec!["python3".to_string()]);
316 assert_eq!(reported, declared);
317 }
318 other => panic!("expected StagedRuntimeRequiresSealed, got {other:?}"),
319 }
320 }
321 }
322
323 #[test]
324 fn staged_runtime_at_sealed_is_accepted() {
325 assert!(check_staged_runtime_floor(
326 &[grant("python3", "/opt/py")],
327 ContainmentClass::Sealed
328 )
329 .is_ok());
330 }
331
332 /// Every tier, so a new variant cannot quietly escape a test that iterates "all of them".
333 /// Mirrors the same list in `containment`'s tests.
334 const ALL_TIERS: &[crate::sandbox::EnforcementTier] = &[
335 crate::sandbox::EnforcementTier::KernelSealed,
336 crate::sandbox::EnforcementTier::KernelFull,
337 crate::sandbox::EnforcementTier::KernelSeccompOnly,
338 crate::sandbox::EnforcementTier::EnvironmentOnly,
339 ];
340
341 /// The check is a function of the **declared** floor alone. This is the property that keeps it
342 /// distinct from `check_containment_floor`, so it is asserted rather than left implicit in the
343 /// signature: for every enforcement tier the host might probe as — including
344 /// `KernelSealed`, where the host genuinely could deliver a composed root — a capsule that did
345 /// not declare `sealed` is still refused, and one that did is still accepted.
346 #[test]
347 fn the_check_is_independent_of_the_achieved_tier() {
348 let grants = [grant("python3", "/opt/testbed/conda")];
349
350 for tier in ALL_TIERS {
351 // The tier is deliberately unused by the call below; binding it here is what makes
352 // the claim legible, and `achieved_class_for_tier` proves the tier is a real one that
353 // maps to a class rather than a placeholder.
354 let achieved = crate::containment::achieved_class_for_tier(*tier);
355
356 for declared in [ContainmentClass::Advisory, ContainmentClass::Scoped] {
357 assert!(
358 check_staged_runtime_floor(&grants, declared).is_err(),
359 "declared {declared} must be refused regardless of tier {tier:?} \
360 (which achieves {achieved})"
361 );
362 }
363 assert!(
364 check_staged_runtime_floor(&grants, ContainmentClass::Sealed).is_ok(),
365 "a declared sealed floor must pass regardless of tier {tier:?}"
366 );
367 }
368 }
369
370 #[test]
371 fn refusal_names_every_offending_binary() {
372 let error = check_staged_runtime_floor(
373 &[grant("python3", "/opt/py"), grant("node", "/opt/node")],
374 ContainmentClass::Scoped,
375 )
376 .expect_err("must be refused");
377 let rendered = error.to_string();
378 assert!(rendered.contains("python3"), "{rendered}");
379 assert!(rendered.contains("node"), "{rendered}");
380 assert!(rendered.contains("sealed"), "{rendered}");
381 }
382
383 #[cfg(target_os = "linux")]
384 #[test]
385 fn target_is_the_source_path_rebased_under_root() {
386 assert_eq!(
387 target_under_root(Path::new("/newroot"), Path::new("/opt/testbed/conda")),
388 PathBuf::from("/newroot/opt/testbed/conda")
389 );
390 // A root with a trailing component of its own still nests the full source path.
391 assert_eq!(
392 target_under_root(Path::new("/tmp/mur-root"), Path::new("/usr/lib/python3.9")),
393 PathBuf::from("/tmp/mur-root/usr/lib/python3.9")
394 );
395 }
396
397 // ------------------------------------------------------------------ the real mount test
398 //
399 // `bind_mount_staged_runtimes` is only meaningful against a real kernel, so the test below
400 // mounts for real inside a private mount namespace it creates itself. Two constraints shape
401 // how it is run:
402 //
403 // * `unshare(CLONE_NEWUSER)` returns `EINVAL` in a multi-threaded process, and libtest runs
404 // each test on its own thread by default. So the work happens in a re-executed copy of
405 // this same test binary, run with `--test-threads=1`, where libtest executes the test
406 // body on the main thread of an otherwise single-threaded process.
407 // * The inner run is `#[ignore]`d so a normal `cargo test` never picks it up directly — it
408 // is reached only through the driver below, which is what supplies the env var and the
409 // single-threaded harness.
410 //
411 // Nothing here uses `pivot_root`, the enforcement pipeline, or `crate::sealed`.
412
413 /// Env var the driver sets to tell the re-executed copy it is the inner run.
414 #[cfg(target_os = "linux")]
415 const INNER_RUN_ENV: &str = "MURMUR_STAGED_RUNTIME_MOUNT_INNER";
416
417 #[cfg(target_os = "linux")]
418 #[test]
419 fn bind_mount_staged_runtimes_mounts_read_only_in_a_private_namespace() {
420 use std::process::Command;
421
422 // The inner run needs a user namespace to get `CAP_SYS_ADMIN` for `mount(2)`. Where the
423 // host forbids that, say so loudly and name what went unproven rather than passing quietly.
424 let namespaces = crate::sandbox::HostProbe::probe().namespaces().clone();
425 if !namespaces.user_mount_namespace_usable() {
426 let failed = namespaces
427 .first_failure()
428 .unwrap_or_else(|| "no attempt reported a failure".to_string());
429 eprintln!(
430 "\n\
431 ==================== SKIPPED: staged-runtime bind mount ====================\n\
432 This host does not permit an unprivileged user+mount namespace, so this test\n\
433 could NOT prove any of the following on this machine:\n\
434 - that bind_mount_staged_runtimes() establishes a real bind mount\n\
435 - that the staged tree is READ-ONLY through the target root\n\
436 - that the source tree's contents are readable through the target root\n\
437 The pure re-basing and floor-check tests above still ran. The namespace probe\n\
438 reported: {failed}\n\
439 Re-run this suite on a Linux host where `mur doctor` reports the sealed root\n\
440 created, or refused no earlier than its tmpfs, before treating the mount\n\
441 behaviour as verified.\n\
442 ============================================================================\n"
443 );
444 return;
445 }
446
447 let exe = std::env::current_exe().expect("test binary path");
448 let output = Command::new(exe)
449 .args([
450 "--exact",
451 "staged_runtime::tests::inner_bind_mount_in_private_namespace",
452 "--ignored",
453 "--test-threads=1",
454 "--nocapture",
455 ])
456 .env(INNER_RUN_ENV, "1")
457 .output()
458 .expect("re-exec the test binary for the single-threaded inner run");
459
460 let stdout = String::from_utf8_lossy(&output.stdout);
461 let stderr = String::from_utf8_lossy(&output.stderr);
462 assert!(
463 output.status.success(),
464 "inner mount run failed\n--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}"
465 );
466 // Guard against the inner test silently not being selected: a filter typo would otherwise
467 // leave a passing run that asserted nothing at all.
468 assert!(
469 stdout.contains("1 passed"),
470 "inner run did not execute the mount test\n--- stdout ---\n{stdout}"
471 );
472 }
473
474 /// What the forked child reports back through its exit status. `Ok` is 0; every other value
475 /// names one step, so a failure in the child is diagnosable from the parent without a pipe.
476 #[cfg(target_os = "linux")]
477 fn child_exit_meaning(code: i32) -> &'static str {
478 match code {
479 0 => "success",
480 10 => "unshare(CLONE_NEWUSER|CLONE_NEWNS) failed",
481 11 => "writing /proc/self/setgroups, uid_map or gid_map failed",
482 12 => "making / MS_REC|MS_PRIVATE failed",
483 13 => "bind_mount_staged_runtimes returned Err",
484 14 => "the staged target path was not the source path re-based under the root",
485 15 => "reading the marker file through the target root failed or read wrong contents",
486 16 => "writing an existing file through the target root did NOT fail with EROFS",
487 17 => "creating a new file through the target root did NOT fail with EROFS",
488 18 => "the source tree stopped being writable on the host side",
489 _ => "unrecognised child status",
490 }
491 }
492
493 /// The inner run, driven by the test above. Creates a private mount namespace, stages a
494 /// throwaway tree into a throwaway root, and checks both directions — reads succeed through
495 /// the target, writes are refused with `EROFS`.
496 ///
497 /// The work happens in a `fork()`ed child for one specific reason: `unshare(CLONE_NEWUSER)`
498 /// returns `EINVAL` in a multi-threaded process, and libtest runs the test body on a spawned
499 /// thread even at `--test-threads=1` (the process reports `Threads: 2`). `fork` clones only
500 /// the calling thread, so the child is single-threaded and the call is permitted.
501 ///
502 /// Allocating after `fork` in a threaded parent can deadlock on a malloc lock another thread
503 /// held at fork time. That is why this runs under the driver's `--test-threads=1` re-exec: the
504 /// only other thread is libtest's main thread, parked in a join and allocating nothing, so no
505 /// lock can be held across the fork. Both halves of that arrangement are load-bearing — do not
506 /// drop the re-exec and fork from a normal parallel test run.
507 #[cfg(target_os = "linux")]
508 #[test]
509 #[ignore = "driven by bind_mount_staged_runtimes_mounts_read_only_in_a_private_namespace"]
510 #[allow(unsafe_code)]
511 fn inner_bind_mount_in_private_namespace() {
512 if std::env::var(INNER_RUN_ENV).is_err() {
513 panic!("inner run invoked without {INNER_RUN_ENV}; run the driver test instead");
514 }
515
516 // Everything the child needs, built *before* the fork. The temp dirs are owned by the
517 // parent so they are cleaned up on the host: the child's mounts live only in its own
518 // namespace and vanish when it exits, while the directories themselves are ordinary and
519 // visible to both.
520 let source_tree = tempfile::tempdir().expect("source tree");
521 let root = tempfile::tempdir().expect("target root");
522 let marker = source_tree.path().join("lib").join("marker.txt");
523 std::fs::create_dir_all(marker.parent().unwrap()).expect("create source subdir");
524 std::fs::write(&marker, "staged-runtime-contents").expect("write marker");
525
526 let grants = vec![grant(
527 "python3",
528 source_tree.path().to_str().expect("utf-8 temp path"),
529 )];
530 let expected_target = target_under_root(root.path(), source_tree.path());
531 let staged_marker = expected_target.join("lib").join("marker.txt");
532 let uid = unsafe { libc::geteuid() };
533 let gid = unsafe { libc::getegid() };
534
535 // SAFETY: see the doc comment above — the child touches only its own namespace and exits
536 // via `_exit`, never returning into libtest.
537 let pid = unsafe { libc::fork() };
538 assert!(pid >= 0, "fork failed: {}", std::io::Error::last_os_error());
539
540 if pid == 0 {
541 // ---- child: single-threaded, owns a private namespace, never returns ----
542 let mut code = 0;
543
544 // 1. A private mount namespace, via a user namespace so an unprivileged uid holds
545 // CAP_SYS_ADMIN inside it and nowhere else.
546 if unsafe { libc::unshare(libc::CLONE_NEWUSER | libc::CLONE_NEWNS) } != 0 {
547 unsafe { libc::_exit(10) };
548 }
549
550 // 2. Map this uid/gid one-to-one, so the staged tree's ownership still reads normally.
551 // `setgroups` must be denied before `gid_map` becomes writable at all.
552 if std::fs::write("/proc/self/setgroups", "deny").is_err()
553 || std::fs::write("/proc/self/uid_map", format!("{uid} {uid} 1")).is_err()
554 || std::fs::write("/proc/self/gid_map", format!("{gid} {gid} 1")).is_err()
555 {
556 unsafe { libc::_exit(11) };
557 }
558
559 // 3. Private propagation, so nothing below leaks back into the host mount table.
560 if unsafe {
561 libc::mount(
562 std::ptr::null(),
563 c"/".as_ptr(),
564 std::ptr::null(),
565 libc::MS_REC | libc::MS_PRIVATE,
566 std::ptr::null(),
567 )
568 } != 0
569 {
570 unsafe { libc::_exit(12) };
571 }
572
573 // 4. The call under test. Nothing above it is `pivot_root`, the enforcement pipeline,
574 // or `crate::sealed` — the helper takes a plain directory as its root.
575 match bind_mount_staged_runtimes(root.path(), &grants) {
576 Ok(staged) => {
577 // The target is the source path re-based under the root, not a flattened name.
578 if staged != vec![expected_target.clone()] {
579 code = 14;
580 }
581 }
582 Err(_) => code = 13,
583 }
584
585 // 5a. Reading *through the target root* sees the source tree's contents. This is what
586 // proves a bind happened rather than an empty directory having been created.
587 if code == 0
588 && std::fs::read_to_string(&staged_marker).ok().as_deref()
589 != Some("staged-runtime-contents")
590 {
591 code = 15;
592 }
593
594 // 5b. Writing through the target root is refused, specifically with `EROFS`. A
595 // permission error would mean only that this uid cannot write the tree, which is a
596 // much weaker property than the mount being read-only.
597 if code == 0
598 && std::fs::write(&staged_marker, "tampered")
599 .err()
600 .and_then(|e| e.raw_os_error())
601 != Some(libc::EROFS)
602 {
603 code = 16;
604 }
605 if code == 0
606 && std::fs::write(expected_target.join("new-file"), "nope")
607 .err()
608 .and_then(|e| e.raw_os_error())
609 != Some(libc::EROFS)
610 {
611 code = 17;
612 }
613
614 // 5c. The source itself stays writable: the read-only flag belongs to the new mount,
615 // not to the underlying tree.
616 if code == 0 && std::fs::write(&marker, "still-writable-at-source").is_err() {
617 code = 18;
618 }
619
620 unsafe { libc::_exit(code) };
621 }
622
623 // ---- parent ----
624 let mut status = 0;
625 assert_eq!(
626 unsafe { libc::waitpid(pid, &mut status, 0) },
627 pid,
628 "waitpid failed: {}",
629 std::io::Error::last_os_error()
630 );
631 assert!(
632 libc::WIFEXITED(status),
633 "the mount child did not exit normally (status {status})"
634 );
635 let code = libc::WEXITSTATUS(status);
636 assert_eq!(code, 0, "mount child failed: {}", child_exit_meaning(code));
637
638 // The child's mounts were confined to its namespace, so the host still sees an ordinary
639 // empty directory here. Asserted from the parent because it is the one claim the child
640 // structurally cannot make about itself.
641 assert!(
642 !staged_marker.exists(),
643 "the staged bind mount leaked out of the child's mount namespace"
644 );
645 }
646}