Skip to main content

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