Skip to main content

capsule_runtime/
sandbox.rs

1//! Kernel-level enforcement for the shell-subprocess tree spawned by `shell::execute_shell`.
2//!
3//! Today `execute_shell` is gated only by app-level checks (`policy.shell_allow` string
4//! equality, and `shell::build_shell_env`'s synthetic-HOME/credential-stripping). Nothing
5//! stops the spawned process — or anything it execs/forks — from making arbitrary syscalls:
6//! reading files outside its workdir, or connecting to hosts outside `policy.network_allow`.
7//! This module closes that gap for the shell subprocess tree specifically, using two Linux
8//! kernel primitives:
9//!
10//!   - **Landlock `Execute` rights** to allowlist exec by *path*, decided by the kernel on the
11//!     path it resolved itself. Each `shell.allow` binary — plus its ELF interpreter and its
12//!     `DT_NEEDED` closure — gets a narrow read+execute grant (`resolve_landlock_grants`), and the
13//!     capsule's own workdir gets `Execute` **only** when the manifest declares
14//!     `capabilities.filesystem.workdir_exec: true` (`linux_enforce::workdir_access_rights`). With
15//!     the default, nothing the capsule writes into its workdir can run under any name, so the
16//!     rename-to-an-allowlisted-basename bypass has no path left. `execve`/`execveat` are
17//!     ordinary allowed syscalls, decided entirely by the Landlock domain they run in.
18//!   - **classic seccomp-bpf argument matching** (`SECCOMP_RET_ERRNO`) on `socket(2)`'s `domain`,
19//!     to refuse whole address families outright — see `denied_socket_domains`. Unlike a
20//!     destination `sockaddr`, `domain` is an integer in a register, so the kernel's own BPF can
21//!     compare it with no userspace round-trip and no notification at all. This is what stops a
22//!     capsule reaching `/var/run/docker.sock` (host root) over `AF_UNIX`, which no other
23//!     mechanism here covers: a network namespace does not mediate `AF_UNIX` at all, and no
24//!     Landlock ABI the ruleset is built at mediates an abstract-namespace socket.
25//!     `AF_NETLINK`/`AF_PACKET` are denied unconditionally; `AF_UNIX` is denied unless
26//!     `capabilities.network.unix_sockets` says otherwise.
27//!   - **a default-deny syscall allowlist** modelled on the OCI/Docker default seccomp profile —
28//!     see `SECCOMP_SYSCALL_ALLOWLIST`. The filter's default action is `SECCOMP_RET_ERRNO(EPERM)`,
29//!     so a syscall named by neither that array nor one of the rules above is refused outright,
30//!     with no argument inspection at all. This is what puts `io_uring_*` (historically an
31//!     LSM-path-hook bypass, and so a candidate route around Landlock itself), `bpf`,
32//!     `userfaultfd`, `perf_event_open`, `ptrace` and the rest of `SECCOMP_MUST_STAY_DENIED` out
33//!     of a capsule's reach. The filter also sets `SCMP_FLTATR_CTL_LOG`, so each denial reaches
34//!     the kernel audit trail with a syscall number, pid and comm.
35//!   - **Landlock LSM** to scope filesystem access to the capsule's `workdir`. The ruleset is
36//!     built at the kernel's Landlock ABI, capped at `LANDLOCK_TARGET_ABI` (v3), so on a 5.19+
37//!     kernel files move and hard-link between folders inside the workdir (`Refer`), and on 6.2+
38//!     truncation outside it is refused (`Truncate`). The workdir's own
39//!     grant deliberately withholds `MakeChar`/`MakeBlock`/`MakeSock`, and withholds `Execute`
40//!     unless `capabilities.filesystem.workdir_exec` is declared — see
41//!     `linux_enforce::WORKDIR_ACCESS_RIGHTS_NO_EXEC` and `linux_enforce::workdir_access_rights`.
42//!     Outside the workdir, alongside the derived
43//!     read+execute grants, a fixed three-device set is granted unconditionally — see
44//!     `CAPSULE_DEVICE_GRANTS`: `/dev/null` read+write (with the session's temporary and home
45//!     folders and any `read-write` host path, the only writable paths outside the workdir),
46//!     `/dev/zero` and `/dev/urandom` read-only, and no other device at all.
47//!
48//! Both Linux tiers additionally strip the forked child's **Linux capabilities** (bounding set,
49//! then permitted/effective/inheritable, then `no_new_privs`) before `execve()` — see
50//! `linux_enforce::drop_all_capabilities`. Landlock and the capability model are independent,
51//! both-must-allow gates: a root-uid capsule keeps `CAP_MKNOD` no matter what Landlock permits,
52//! so the Landlock narrowing above does not subsume this.
53//!
54//! Both are Linux-only. macOS (and any other non-Linux target) has no equivalent kernel
55//! primitive and permanently falls back to the existing, unmodified synthetic-HOME/env-
56//! stripping mechanism in `shell.rs` — see `EnforcementTier::EnvironmentOnly`.
57//!
58//! ## Four-tier model
59//!
60//! - `KernelSealed` (Linux, everything `KernelFull` has, plus a composed root the namespace probe
61//!   really built inside the capsule's network namespace — see [`crate::namespace_probe`]): the
62//!   subprocess tree additionally runs inside a private mount namespace pivoted onto a composed
63//!   root, so paths outside it are *absent* rather than denied. Landlock and seccomp still install,
64//!   inside the new root, as defence in depth — see [`crate::sealed`]. Only capsules that declare
65//!   `capabilities.containment: sealed` get this; see `applied_tier`.
66//! - `KernelFull` (Linux, Landlock ABI available — kernel 5.13+): seccomp syscall allowlist +
67//!   socket-domain denial + Landlock filesystem scoping, which is also what makes
68//!   `capabilities.shell.allow` enforceable (exec is a Landlock right, not a syscall filter).
69//! - `KernelSeccompOnly` (Linux, Landlock unavailable — kernel <5.13): seccomp syscall allowlist +
70//!   socket-domain denial only. Filesystem scope stays convention-only (`current_dir`) — a
71//!   documented gap, not a bug — and, since the exec supervisor was retired, so does
72//!   `shell.allow`: with no Landlock domain there is no kernel-level exec mediation on this tier
73//!   at all. `W-SEC-002` says so. The syscall allowlist and the socket-domain denial are identical
74//!   on both tiers: they are seccomp rules, so they do not depend on Landlock in any way.
75//! - `EnvironmentOnly` (macOS / any non-Linux target): no kernel primitive attempted at all.
76//!   Permanent, not a placeholder for a future slice.
77//!
78//! Tier detection is always a runtime capability probe (attempt Landlock ruleset
79//! construction, inspect the resulting `RulesetStatus`; fork a child and really call
80//! `unshare(CLONE_NEWUSER | CLONE_NEWNS)` followed by a `mount(2)`) — never a hardcoded
81//! kernel-version string parse, which is fragile against distro backports, and never a config
82//! file. Each reading is taken live, and a staged session takes exactly one for every consumer
83//! of that session to read; see [`HostProbe`].
84//!
85//! ## Fail-closed invariant
86//!
87//! If kernel enforcement setup fails unexpectedly on a Linux host (not the expected
88//! "Landlock unsupported, degrade to `KernelSeccompOnly`" signal, but something like seccomp
89//! filter install failing, or the session's network namespace failing to be created before
90//! spawn) — `prepare_enforcement` returns `Err` and `execute_shell` must not call `.spawn()` at
91//! all.
92//! There is no code path where a Linux host silently runs a shell subprocess with zero
93//! enforcement because setup failed.
94
95use std::net::IpAddr;
96use std::path::{Path, PathBuf};
97use std::sync::Arc;
98
99use murmur_artifact::security_warnings::{
100    security_warning_link, W_SEC_001, W_SEC_002, W_SEC_005, W_SEC_010,
101};
102use murmur_artifact::InterpreterRuntimeGrant;
103
104use crate::types::CapabilityPolicy;
105
106/// Kernel-enforcement tier for the current host, in descending order of enforcement
107/// strength. Always host-probed at launch time — never sourced from the manifest.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub(crate) enum EnforcementTier {
110    /// Linux, and everything `KernelFull` has *plus* a private mount namespace pivoted onto a
111    /// composed root (see [`crate::sealed`]): the shipped AppArmor profile is loaded (or AppArmor
112    /// imposes no unprivileged-userns restriction), a real `unshare` + `mount` probe succeeded,
113    /// and the Landlock ABI is usable. Paths outside the composed root are *absent*, not merely
114    /// denied — and Landlock and seccomp still install inside it, as defence in depth.
115    ///
116    /// Ranked first because it is the strongest tier; the enum is documented weakest-last and
117    /// carries no `Ord`, so this position is documentation, not a comparison.
118    KernelSealed,
119    /// Linux, Landlock ABI available (kernel 5.13+): seccomp exec/network allowlisting
120    /// + Landlock filesystem scoping.
121    KernelFull,
122    /// Linux, Landlock unavailable (kernel <5.13): seccomp exec/network allowlisting
123    /// only. Filesystem scope stays convention-only (`current_dir`) — documented gap.
124    KernelSeccompOnly,
125    /// macOS (or any non-Linux target): no kernel sandboxing primitive exists. This is
126    /// PERMANENT, not a placeholder for a future slice.
127    EnvironmentOnly,
128    /// The operator chose to run the capsule unconfined (`ContainmentClass::Unconfined`), on any
129    /// host. Never a host reading: [`applied_tier`] is the only place it comes from. Subprocesses
130    /// get no network namespace, no `/tmp` mapping, no composed root, no capability drop, no
131    /// `no_new_privs`, no seccomp filter and no Landlock domain; they keep the fd-hygiene step,
132    /// their process group, and only the resource limits `capabilities.resources` declares.
133    Unconfined,
134}
135
136/// Pure decision: given whether the host is Linux, the outcome of a Landlock
137/// ruleset-restriction probe, and what [`crate::namespace_probe`] created, decide the tier. No
138/// syscalls here — fully unit-testable on any OS.
139///
140/// `KernelSealed` requires the whole conjunction — Linux, a fully enforced Landlock ruleset, and a
141/// sealed-root rehearsal that really built the composed root inside the capsule's network
142/// namespace. Any missing element falls back to the tier that element still supports; nothing
143/// here ever reports a mechanism the host did not demonstrate, and nothing here reads AppArmor.
144pub(crate) fn tier_from_probe(
145    is_linux: bool,
146    landlock_fully_enforced: Option<bool>,
147    namespaces: &crate::namespace_probe::NamespaceReading,
148) -> EnforcementTier {
149    if !is_linux {
150        return EnforcementTier::EnvironmentOnly;
151    }
152    match landlock_fully_enforced {
153        Some(true) if namespaces.sealed_root_created() => EnforcementTier::KernelSealed,
154        Some(true) => EnforcementTier::KernelFull,
155        Some(false) | None => EnforcementTier::KernelSeccompOnly,
156    }
157}
158
159/// The tier a session applies on a host whose tier is `host_tier`: never stronger than the host
160/// can back and never stronger than the capsule asked for.
161///
162/// A capsule declaring less than `sealed` on a sealed-capable host runs on
163/// [`EnforcementTier::KernelFull`]: the composed root would otherwise delete host paths its
164/// `interpreter_runtime` grants legitimately point at — a narrowing of `scoped`'s behaviour, not
165/// added security. The declared floor is honoured, not merely met.
166///
167/// Called only by [`crate::containment::ContainmentDecision::decide`], which reads the session's
168/// achieved class off the tier this returns, so the class a report names and the tier the
169/// session's commands run on are one decision.
170pub(crate) fn applied_tier(
171    host_tier: EnforcementTier,
172    declared: murmur_artifact::ContainmentClass,
173) -> EnforcementTier {
174    if declared == murmur_artifact::ContainmentClass::Unconfined {
175        return EnforcementTier::Unconfined;
176    }
177    match host_tier {
178        EnforcementTier::KernelSealed if declared < murmur_artifact::ContainmentClass::Sealed => {
179            EnforcementTier::KernelFull
180        }
181        other => other,
182    }
183}
184
185/// The host facts the tier decision is made from, taken together as one reading.
186///
187/// A staged session takes exactly one of these and every consumer of that session reads it — the
188/// containment-floor refusal, the `ScopeReport` written to the trace, the `/tmp` mapping, the
189/// `E-CAP-005` check at staging and at launch, and the tier [`ShellEnforcement::resolve`]
190/// installs. Two readings of a host that changed between them would let the trace describe a
191/// session that never ran under what it claims.
192#[derive(Debug, Clone)]
193pub struct HostProbe {
194    landlock_fully_enforced: Option<bool>,
195    /// The kernel's Landlock ABI, from `linux_enforce::kernel_landlock_abi`. `None` off Linux and
196    /// on a Linux kernel without Landlock.
197    landlock_kernel_abi: Option<u8>,
198    namespaces: crate::namespace_probe::NamespaceReading,
199}
200
201/// Probes taken since this process started. See [`HostProbe::probes_taken`].
202static PROBES_TAKEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
203
204impl HostProbe {
205    /// Reads the host as it is right now: on Linux a real Landlock ruleset restriction attempt
206    /// (`RulesetStatus::FullyEnforced` → `Some(true)`, `PartiallyEnforced`/`NotEnforced` or any
207    /// construction error → `Some(false)`, no Landlock ABI → `None`) plus
208    /// [`crate::namespace_probe`]'s three attempts: the session's network namespace, a command's
209    /// private mount namespace inside it, and the composed root's nested namespace and pivot. Off
210    /// Linux it probes nothing and claims nothing.
211    ///
212    /// Taken once per staged session, not once per process. That is affordable because both
213    /// halves do their work in a forked child and leave the calling process exactly as they found
214    /// it — `linux_enforce::probe_landlock_full_access` forks so the runtime is never placed
215    /// into a Landlock domain by a capability check, and the namespace probe forks because
216    /// `unshare(CLONE_NEWUSER)` is irreversible for the task that calls it. Neither is
217    /// idempotency-sensitive, so a reading costs no more than the syscalls it makes: four
218    /// `fork`/`waitpid` pairs and three small `/proc` reads.
219    ///
220    /// The window this leaves open is one session wide. Between `runtime::stage_session` and the
221    /// last subprocess that session spawns the host is not re-read; a change inside that window is
222    /// caught by the mechanism installation itself failing closed, not by a second probe. A
223    /// process that stages repeatedly — `mur eval`, which stages once per dataset case — judges
224    /// each session against the host as it was when that session was staged.
225    pub fn probe() -> HostProbe {
226        PROBES_TAKEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
227        #[cfg(target_os = "linux")]
228        let probe = HostProbe {
229            landlock_fully_enforced: linux_enforce::probe_landlock_full_access(),
230            landlock_kernel_abi: linux_enforce::kernel_landlock_abi(),
231            namespaces: crate::namespace_probe::probe_namespaces(),
232        };
233        #[cfg(not(target_os = "linux"))]
234        let probe = HostProbe {
235            landlock_fully_enforced: None,
236            landlock_kernel_abi: None,
237            namespaces: crate::namespace_probe::probe_namespaces(),
238        };
239        probe
240    }
241
242    /// A probe value assembled from known halves, without touching the host. The seam the
243    /// fail-closed tests use to assert what an all-negative reading resolves to on any OS.
244    #[cfg(test)]
245    pub(crate) fn from_parts(
246        landlock_fully_enforced: Option<bool>,
247        namespaces: crate::namespace_probe::NamespaceReading,
248    ) -> HostProbe {
249        HostProbe {
250            landlock_fully_enforced,
251            landlock_kernel_abi: None,
252            namespaces,
253        }
254    }
255
256    /// This probe with its kernel Landlock ABI reading replaced. The seam the ABI tests use to
257    /// resolve a session as a kernel reporting `abi` would, on a host that reports another.
258    #[cfg(test)]
259    pub(crate) fn with_landlock_kernel_abi(self, abi: Option<u8>) -> HostProbe {
260        HostProbe {
261            landlock_kernel_abi: abi,
262            ..self
263        }
264    }
265
266    /// The tier this reading supports. `pub(crate)` because [`EnforcementTier`] is.
267    pub(crate) fn tier(&self) -> EnforcementTier {
268        tier_from_probe(
269            cfg!(target_os = "linux"),
270            self.landlock_fully_enforced,
271            &self.namespaces,
272        )
273    }
274
275    /// What the namespace probe attempted, and the readings it took with the attempts.
276    pub fn namespaces(&self) -> &crate::namespace_probe::NamespaceReading {
277        &self.namespaces
278    }
279
280    /// The `E-CAP-005` failure this reading's network attempt is, or `None` when it created the
281    /// namespace (and always off Linux).
282    pub fn egress_namespace_failure(
283        &self,
284    ) -> Option<crate::network_namespace::EgressNamespaceFailure> {
285        self.namespaces.egress_namespace_failure()
286    }
287
288    /// Whether this host can give a command a private mount namespace inside the session's user
289    /// namespace: the probe's mount attempt created one.
290    pub(crate) fn mount_namespace_usable(&self) -> bool {
291        cfg!(target_os = "linux") && self.namespaces.mount_namespace_created()
292    }
293
294    /// Why this reading cannot back `sealed`, or `None` when nothing keeps it below. `None`
295    /// exactly when [`Self::tier`] is `KernelSealed`.
296    pub fn sealed_shortfall(&self) -> Option<crate::sealed::SealedShortfall> {
297        crate::sealed::sealed_shortfall(
298            cfg!(target_os = "linux"),
299            self.landlock_fully_enforced == Some(true),
300            &self.namespaces,
301        )
302    }
303
304    /// Where this host's permission to create an unprivileged user namespace comes from, or
305    /// `None` off Linux, where the question has no answer rather than a negative one.
306    pub fn userns_grant(&self) -> Option<crate::sealed::UsernsGrant> {
307        if cfg!(target_os = "linux") {
308            Some(self.namespaces.userns_grant)
309        } else {
310            None
311        }
312    }
313
314    /// The kernel's Landlock ABI version, or `None` when the kernel has no Landlock (off Linux
315    /// always). The ABI the session's ruleset is built at is [`ruleset_landlock_abi`] of this.
316    pub fn landlock_kernel_abi(&self) -> Option<u8> {
317        self.landlock_kernel_abi
318    }
319
320    /// How many probes this process has taken, counting from zero at startup and never
321    /// decreasing.
322    ///
323    /// The observability hook the freshness tests assert against — that staging twice advances it
324    /// by two, and that one `stage_session` advances it by one — and nothing else reads it. No
325    /// runtime decision depends on the count.
326    pub fn probes_taken() -> u64 {
327        PROBES_TAKEN.load(std::sync::atomic::Ordering::Relaxed)
328    }
329}
330
331/// The tier this host can back, from a reading taken here. The seam the kernel tests resolve the
332/// host they run on through; a session reads its own [`HostProbe`].
333#[cfg(all(test, target_os = "linux"))]
334pub(crate) fn detect_enforcement_tier() -> EnforcementTier {
335    HostProbe::probe().tier()
336}
337
338/// Why this host cannot back `sealed`, or `None` when nothing keeps it below, from a reading
339/// taken here.
340///
341/// Takes its own probe, as its neighbours do. What a session needs — that the refusal an operator
342/// sees and the tier the runtime installs cannot disagree about the host — is upheld by
343/// `runtime::stage_session` passing one [`HostProbe`] to both, not by this function.
344pub fn detect_sealed_shortfall() -> Option<crate::sealed::SealedShortfall> {
345    HostProbe::probe().sealed_shortfall()
346}
347
348/// Where this host's unprivileged-user-namespace permission comes from, or `None` off Linux, where
349/// the question has no answer rather than a negative one.
350///
351/// Takes its own probe, as its neighbours do; a session reads the provenance off its own
352/// [`HostProbe`] instead, so what it is shown and the tier it installs describe one host at one
353/// moment.
354pub fn detect_userns_grant() -> Option<crate::sealed::UsernsGrant> {
355    HostProbe::probe().userns_grant()
356}
357
358/// The kernel's Landlock ABI version, or `None` when there is no Landlock (off Linux always), from
359/// a reading taken here.
360///
361/// Takes its own probe, as its neighbours do; a session reads it off its own [`HostProbe`].
362pub fn detect_landlock_abi() -> Option<u8> {
363    HostProbe::probe().landlock_kernel_abi()
364}
365
366/// The highest Landlock ABI a capsule's ruleset is built at.
367///
368/// v2 adds `Refer` (moving and hard-linking between folders) and v3 adds `Truncate`, both granted
369/// on the workdir — see `linux_enforce::WORKDIR_ACCESS_RIGHTS_NO_EXEC`. v4 is not taken: it
370/// handles TCP `bind`/`connect`, and declaring those rights would deny every TCP connection the
371/// rules do not re-grant, which is the network namespace and egress proxy's boundary to draw. v5
372/// (`IoctlDev`) and v6 (abstract unix sockets and signals) each move a different boundary again.
373pub(crate) const LANDLOCK_TARGET_ABI: u8 = 3;
374
375/// The Landlock ABI this session's ruleset is built at: the kernel's, capped at
376/// [`LANDLOCK_TARGET_ABI`], on the two tiers that install a Landlock domain, and `None` on every
377/// other tier or when the kernel reports no Landlock.
378///
379/// Pure, so the ABI the trace records and the ABI the child builds at are the same function of
380/// the same [`HostProbe`] reading.
381pub(crate) fn ruleset_landlock_abi(kernel_abi: Option<u8>, tier: EnforcementTier) -> Option<u8> {
382    match tier {
383        EnforcementTier::KernelFull | EnforcementTier::KernelSealed => kernel_abi
384            .filter(|abi| *abi >= 1)
385            .map(|abi| abi.min(LANDLOCK_TARGET_ABI)),
386        EnforcementTier::KernelSeccompOnly
387        | EnforcementTier::EnvironmentOnly
388        | EnforcementTier::Unconfined => None,
389    }
390}
391
392/// The lines `mur doctor` prints under its `Landlock` heading for a kernel reporting
393/// `kernel_abi`: what the kernel offers, what the runtime builds at, and what a capsule loses on
394/// this host below [`LANDLOCK_TARGET_ABI`].
395///
396/// Pure and cross-platform, so a test can check the exact words for a kernel this host is not.
397pub fn landlock_doctor_lines(kernel_abi: Option<u8>) -> Vec<String> {
398    if !cfg!(target_os = "linux") {
399        return vec!["kernel ABI: n/a (Landlock is a Linux mechanism)".to_string()];
400    }
401    let Some(kernel) = kernel_abi.filter(|abi| *abi >= 1) else {
402        return vec![
403            "kernel ABI: none (no Landlock on this host)".to_string(),
404            "ruleset ABI: n/a (no Landlock domain is installed)".to_string(),
405        ];
406    };
407    let built = kernel.min(LANDLOCK_TARGET_ABI);
408    let mut lines = vec![
409        format!("kernel ABI: {kernel}"),
410        format!("ruleset ABI: {built} (the kernel's, capped at {LANDLOCK_TARGET_ABI})"),
411    ];
412    if built < 2 {
413        lines.push(
414            "moving or hard-linking a file between folders inside the work folder fails with \
415             EXDEV (Invalid cross-device link) on this host; Linux 5.19 (Landlock ABI v2) \
416             permits it. Tools that fall back to copy-and-delete (mv, pip, git) still work; uv \
417             and other tools that rename atomically between folders do not."
418                .to_string(),
419        );
420    }
421    if built < 3 {
422        lines.push(
423            "truncate(2) on a file outside the work folder is not mediated below Landlock ABI v3 \
424             (Linux 6.2)."
425                .to_string(),
426        );
427    }
428    lines
429}
430
431/// Resolves every host in `network_allow` (via `crate::network_policy::parse_network_allow_rules`,
432/// the same parser already used for the WASI-HTTP-facing allowlist) to concrete IP addresses
433/// via DNS, once. Returns the deduplicated union of all resolved addresses across all allowed
434/// hosts.
435///
436/// Hosts that fail to resolve are silently skipped, matching `resolve_exec_allowlist`'s precedent
437/// below: the resolved set feeds *only* the kernel-level (Landlock/seccomp) allow set for
438/// `shell.allow` subprocesses, so a host contributing zero IPs merely *shrinks* that set (deny
439/// direction, fail-closed). A DNS miss must not be fatal — resolution happens unconditionally at
440/// staging time, before any WASM loads and regardless of whether the capsule declares
441/// `shell.allow` at all, so hard-failing here turned any transient resolver outage or
442/// non-resolving allowlist host into an E-IO-003 that blocked the whole run.
443///
444/// This does not widen what the capsule may reach: the WASM component's own outbound WASI-HTTP
445/// calls are gated separately by `network_policy::NetworkAllowRule::matches`, a host-pattern
446/// match that never consults DNS. Malformed host *syntax* is still a hard error — it is caught by
447/// `parse_network_allow_rules` above, before this loop.
448pub(crate) fn resolve_network_allowlist_ips(
449    network_allow: &[String],
450) -> Result<Vec<IpAddr>, String> {
451    let rules = crate::network_policy::parse_network_allow_rules(network_allow)
452        .map_err(|error| error.to_string())?;
453
454    let mut ips = std::collections::BTreeSet::new();
455    for rule in &rules {
456        let host = rule.host.as_str();
457        match crate::dns_resolver::resolve(host) {
458            crate::dns_resolver::Resolution::Resolved(addresses) => ips.extend(addresses),
459            crate::dns_resolver::Resolution::DoesNotExist => {}
460            // Skipped like any other host that contributed no address, but said out loud: a
461            // resolver that did not answer is a transient condition an operator can act on,
462            // where a name that does not exist is a manifest to correct.
463            crate::dns_resolver::Resolution::DidNotAnswer(reason) => crate::runtime_err!(
464                "[capsule-runtime] warning: the network allowlist host '{host}' could not be \
465                 resolved at launch: {reason} (a subprocess reaching it by literal address is \
466                 denied for this run)"
467            ),
468        }
469    }
470
471    Ok(ips.into_iter().collect())
472}
473
474/// Resolves each `capabilities.shell.allow` entry to the canonical filesystem path(s) of the
475/// binary it names, once, at launch time. Entries containing a path separator are
476/// canonicalized directly; bare names (the common case, per the manifest schema's
477/// "bare binary name" rule) are looked up in the host `PATH` the way `execvp` would, then
478/// canonicalized (resolving symlinks, `..`, etc.).
479///
480/// Entries that resolve to nothing (binary not installed) are silently skipped — that only
481/// *shrinks* the kernel-level allow set (deny direction, fail-closed) and matches reality:
482/// an exec of a binary that does not exist at launch fails anyway, and a binary *created
483/// after launch* under an allowlisted name (the attack this exists to stop) must not become
484/// allowlisted by virtue of its name.
485///
486/// This canonical set — not the raw name strings — is what `resolve_landlock_grants` derives the
487/// narrow read+execute Landlock grants from, so `cp /usr/bin/nc ./bash && ./bash` is denied: the
488/// copy lives under the workdir, which carries no `Execute` right by default, and the grant that
489/// does carry one names the launch-time `bash`'s own inode, not a basename.
490pub(crate) fn resolve_exec_allowlist(shell_allow: &[String]) -> Vec<PathBuf> {
491    let path_dirs: Vec<PathBuf> = std::env::var_os("PATH")
492        .map(|path| std::env::split_paths(&path).collect())
493        .unwrap_or_default();
494    resolve_exec_allowlist_in(shell_allow, &path_dirs)
495}
496
497/// Testable core of `resolve_exec_allowlist`, with the `PATH` directory list injected. Every
498/// entry is a bare binary name (the manifest refuses any other, `E-MAN-003`), and every
499/// executable `PATH` match is included (not just the first): all of them are binaries the
500/// operator's launch-time `PATH` exposes under that allowlisted name, and on merged-`/usr` distros
501/// `/bin/x` and `/usr/bin/x` canonicalize to the same entry anyway.
502pub(crate) fn resolve_exec_allowlist_in(
503    shell_allow: &[String],
504    path_dirs: &[PathBuf],
505) -> Vec<PathBuf> {
506    let mut resolved = std::collections::BTreeSet::new();
507    for entry in shell_allow {
508        if entry.is_empty() {
509            continue;
510        }
511        for dir in path_dirs {
512            let candidate = dir.join(entry);
513            if is_executable_file(&candidate) {
514                if let Ok(canonical) = std::fs::canonicalize(&candidate) {
515                    resolved.insert(canonical);
516                }
517            }
518        }
519    }
520    resolved.into_iter().collect()
521}
522
523/// Resolves the program name a shell tool invoked to the canonical filesystem path of the
524/// binary that `execvp` would actually run, for reporting purposes: `shell-event.binary`
525/// on the hook side and the `binary` key of `trace.jsonl`'s `shell` record.
526///
527/// Unlike [`resolve_exec_allowlist`] — which collects *every* `PATH` match because each one needs
528/// its own Landlock grant — this returns the **first** match, which is the one `execvp` picks and
529/// therefore the one that ran. Resolution rules are otherwise identical (same `PATH` source, same
530/// [`is_executable_file`] test, same `canonicalize`), so a reported path is always a member of the
531/// set the Landlock grants were derived from.
532///
533/// A name that resolves to nothing falls back to the bare invoked name, unchanged — never
534/// an error, never a panic, matching `resolve_exec_allowlist`'s "entries that resolve to
535/// nothing are silently skipped" precedent. Observability must not be able to fail a shell
536/// call that the OS is perfectly willing to run.
537pub(crate) fn resolve_invoked_binary_path(binary: &str) -> String {
538    let path_dirs: Vec<PathBuf> = std::env::var_os("PATH")
539        .map(|path| std::env::split_paths(&path).collect())
540        .unwrap_or_default();
541    resolve_invoked_binary_path_in(binary, &path_dirs)
542}
543
544/// Testable core of [`resolve_invoked_binary_path`], with the `PATH` directory list
545/// injected. `pub(crate)` because [`crate::reachability`] needs the same first-match resolution
546/// against an *injected* directory list — its own unit tests point `PATH` at a temp directory of
547/// synthetic script fixtures rather than at whatever the machine running the suite has installed.
548pub(crate) fn resolve_invoked_binary_path_in(binary: &str, path_dirs: &[PathBuf]) -> String {
549    let fallback = || binary.to_string();
550    if binary.is_empty() {
551        return fallback();
552    }
553    if binary.contains('/') {
554        return match std::fs::canonicalize(binary) {
555            Ok(canonical) => canonical.to_string_lossy().into_owned(),
556            Err(_) => fallback(),
557        };
558    }
559    for dir in path_dirs {
560        let candidate = dir.join(binary);
561        if is_executable_file(&candidate) {
562            if let Ok(canonical) = std::fs::canonicalize(&candidate) {
563                return canonical.to_string_lossy().into_owned();
564            }
565        }
566    }
567    fallback()
568}
569
570/// The first executable named `binary` on this process's `PATH`, or `None` when there is none.
571///
572/// First match wins, which is the one `execvp` would run — the same rule
573/// [`resolve_invoked_binary_path_in`] reports under, so a caller naming a binary and a caller
574/// reporting one cannot disagree about which file `PATH` means. The path is returned as found,
575/// not canonicalized: callers here name a binary to a person rather than derive a grant from it.
576pub fn find_on_path(binary: &str) -> Option<PathBuf> {
577    std::env::split_paths(&std::env::var_os("PATH")?)
578        .map(|dir| dir.join(binary))
579        .find(|candidate| is_executable_file(candidate))
580}
581
582pub(crate) fn is_executable_file(path: &Path) -> bool {
583    use std::os::unix::fs::PermissionsExt;
584    std::fs::metadata(path)
585        .map(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
586        .unwrap_or(false)
587}
588
589// ---- derived Landlock read+execute grant set ----
590//
591// On `KernelFull`, Landlock scopes the shell subprocess tree to the workdir. The workdir rule
592// grants `linux_enforce::workdir_access_rights` (every right of the ABI the ruleset is built at
593// except device-node and unix-socket creation, and `Execute` unless `workdir_exec`), but — once
594// `restrict_self()` lands — `Execute`/`ReadFile` are then denied on every path *outside* the
595// workdir, including the `shell.allow` binaries themselves, their ELF interpreter (dynamic loader),
596// and every shared library they pull in. That silently defeats the seccomp exec-allowlist: each
597// allowlisted `execve` fails with EACCES before it runs.
598//
599// The functions below derive the exact extra paths that must get a narrow read+execute grant
600// outside the workdir: the canonical `shell.allow` binaries (already resolved by
601// `resolve_exec_allowlist`), each one's `PT_INTERP`, and the transitive closure of the shared
602// libraries their ELF `DT_NEEDED` entries name (resolved via `DT_RPATH`/`DT_RUNPATH` first, then a
603// fixed set of standard library directories). Nothing broader — no directory is granted wholesale.
604//
605// All of this is *parsing and filesystem resolution*, so it runs in the parent at launch time
606// (inside `ShellEnforcement::resolve`) and the resulting `Vec<PathBuf>` is threaded into the
607// forked child's `pre_exec`, which only opens each path and adds a Landlock rule (no parsing in
608// the async-signal-safe window). Every resolution step is shrink-not-fail, matching
609// `resolve_exec_allowlist`: an entry that does not exist, does not parse as ELF, or names a
610// soname that does not resolve simply contributes nothing further — it never errors out.
611
612/// Standard Linux shared-library search directories used as the fallback resolution set for a
613/// `DT_NEEDED` soname when the binary declares no `DT_RPATH`/`DT_RUNPATH` (or it doesn't match).
614/// Covers both supported platform targets (`linux/x86_64` and `linux/aarch64`), including the
615/// Debian/Ubuntu multiarch-triplet subdirectories. Extend here for a platform this list does not
616/// cover — it is the single source of truth.
617pub(crate) const DEFAULT_LIBRARY_SEARCH_DIRS: &[&str] = &[
618    "/lib",
619    "/lib64",
620    "/usr/lib",
621    "/usr/lib64",
622    "/lib/x86_64-linux-gnu",
623    "/usr/lib/x86_64-linux-gnu",
624    "/lib/aarch64-linux-gnu",
625    "/usr/lib/aarch64-linux-gnu",
626];
627
628/// The subset of an ELF binary's dynamic-linking metadata that decides which extra files the
629/// dynamic loader will touch at load time: the interpreter path (`PT_INTERP`), the direct
630/// shared-library dependencies (`DT_NEEDED` sonames), and the runtime library search paths
631/// (`DT_RPATH`/`DT_RUNPATH`, already split on `:`, `$ORIGIN` left unexpanded here).
632#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
633#[serde(deny_unknown_fields)]
634pub(crate) struct ElfDependencies {
635    pub(crate) interp: Option<String>,
636    pub(crate) needed: Vec<String>,
637    pub(crate) runpaths: Vec<String>,
638}
639
640/// An ELF image the parsers below read by position: its length, and the bytes at an offset. Each
641/// parser asks only for the headers and tables it decodes, so an image read from a file costs a
642/// few `pread`s, never the whole file.
643pub(crate) trait ElfSource {
644    /// The image's length in bytes.
645    fn size(&self) -> u64;
646
647    /// The `len` bytes at `offset`, or `None` when they do not lie wholly inside the image or
648    /// cannot be read.
649    fn read_at(&self, offset: u64, len: u64) -> Option<std::borrow::Cow<'_, [u8]>>;
650}
651
652/// An image already in memory, such as a native tool the runtime has just written.
653impl ElfSource for [u8] {
654    fn size(&self) -> u64 {
655        self.len() as u64
656    }
657
658    fn read_at(&self, offset: u64, len: u64) -> Option<std::borrow::Cow<'_, [u8]>> {
659        let start = usize::try_from(offset).ok()?;
660        let end = start.checked_add(usize::try_from(len).ok()?)?;
661        self.get(start..end).map(std::borrow::Cow::Borrowed)
662    }
663}
664
665/// An image read from an open file with `pread`, its length the file's size when it was opened.
666pub(crate) struct ElfFile {
667    file: std::fs::File,
668    metadata: std::fs::Metadata,
669}
670
671impl ElfFile {
672    /// Opens the host file at `path` for reading.
673    pub(crate) fn open_host(path: &Path) -> std::io::Result<Self> {
674        let file = std::fs::File::open(path)?;
675        let metadata = file.metadata()?;
676        Ok(Self { file, metadata })
677    }
678
679    /// The open file's metadata, read from the handle when it was opened.
680    #[cfg(target_os = "linux")]
681    pub(crate) fn metadata(&self) -> &std::fs::Metadata {
682        &self.metadata
683    }
684}
685
686impl ElfSource for ElfFile {
687    fn size(&self) -> u64 {
688        self.metadata.len()
689    }
690
691    fn read_at(&self, offset: u64, len: u64) -> Option<std::borrow::Cow<'_, [u8]>> {
692        use std::os::unix::fs::FileExt;
693
694        if offset.checked_add(len)? > self.size() {
695            return None;
696        }
697        let mut bytes = vec![0; usize::try_from(len).ok()?];
698        self.file.read_exact_at(&mut bytes, offset).ok()?;
699        Some(std::borrow::Cow::Owned(bytes))
700    }
701}
702
703fn read_u16(bytes: &[u8], off: usize, le: bool) -> Option<u16> {
704    let raw: [u8; 2] = bytes.get(off..off + 2)?.try_into().ok()?;
705    Some(if le {
706        u16::from_le_bytes(raw)
707    } else {
708        u16::from_be_bytes(raw)
709    })
710}
711
712fn read_u32(bytes: &[u8], off: usize, le: bool) -> Option<u32> {
713    let raw: [u8; 4] = bytes.get(off..off + 4)?.try_into().ok()?;
714    Some(if le {
715        u32::from_le_bytes(raw)
716    } else {
717        u32::from_be_bytes(raw)
718    })
719}
720
721fn read_u64(bytes: &[u8], off: usize, le: bool) -> Option<u64> {
722    let raw: [u8; 8] = bytes.get(off..off + 8)?.try_into().ok()?;
723    Some(if le {
724        u64::from_le_bytes(raw)
725    } else {
726        u64::from_be_bytes(raw)
727    })
728}
729
730/// Whether the ELF identification at the start of `header` is ELF64 little-endian (`true`) or
731/// big-endian (`false`); `None` for anything else.
732fn elf64_little_endian(header: &[u8]) -> Option<bool> {
733    // EI_CLASS: 2 == ELFCLASS64. EI_DATA: 1 == little-endian, 2 == big-endian.
734    if header.get(0..4)? != b"\x7fELF" || *header.get(4)? != 2 {
735        return None;
736    }
737    match header.get(5)? {
738        1 => Some(true),
739        2 => Some(false),
740        _ => None,
741    }
742}
743
744/// The `len` bytes at `offset`, or as many of them as `image` holds; empty when `offset` is at or
745/// past its end.
746fn read_clamped<S: ElfSource + ?Sized>(
747    image: &S,
748    offset: u64,
749    len: u64,
750) -> Option<std::borrow::Cow<'_, [u8]>> {
751    let available = image.size().saturating_sub(offset).min(len);
752    if available == 0 {
753        return Some(std::borrow::Cow::Borrowed(&[]));
754    }
755    image.read_at(offset, available)
756}
757
758/// Reads a NUL-terminated string starting at the beginning of `region` (used for both the
759/// `PT_INTERP` segment and dynamic-string-table entries).
760fn cstr_at_start(region: &[u8]) -> String {
761    let nul = region.iter().position(|&b| b == 0).unwrap_or(region.len());
762    String::from_utf8_lossy(&region[..nul]).into_owned()
763}
764
765/// Maps a virtual address into a file offset using the ELF's `PT_LOAD` segments — required
766/// because `DT_STRTAB` records the string table's virtual address, not its file offset.
767fn vaddr_to_file_offset(vaddr: u64, loads: &[(u64, u64, u64)]) -> Option<u64> {
768    for &(seg_vaddr, seg_off, seg_filesz) in loads {
769        if vaddr >= seg_vaddr && vaddr < seg_vaddr.checked_add(seg_filesz)? {
770            return Some(seg_off + (vaddr - seg_vaddr));
771        }
772    }
773    None
774}
775
776/// Pure ELF64 parser: extracts the dynamic-linking metadata that determines which extra files the
777/// loader touches. Returns `None` for anything that is not a well-formed ELF64 image (bad magic,
778/// 32-bit, truncated headers) — a non-ELF or unparseable binary simply contributes nothing to the
779/// grant set (shrink-not-fail). Deliberately hand-rolled (no crate dependency) so it is fully
780/// unit-testable off-Linux with synthetic in-memory fixtures.
781///
782/// Reads only the 64-byte header, the program header table, the `PT_INTERP` bytes, the
783/// `PT_DYNAMIC` array and the dynamic string table. Every read is bounded by the image's length,
784/// so the answer is the same whether `image` is in memory or a file read by position.
785///
786/// Only ELF64 is parsed: both supported Linux targets (`x86_64`, `aarch64`) are 64-bit, so a
787/// 32-bit image is not a binary this runtime would exec and is treated as unparseable.
788pub(crate) fn parse_elf_dependencies<S: ElfSource + ?Sized>(image: &S) -> Option<ElfDependencies> {
789    // e_ident (16) + fixed ELF64 header fields extend to offset 64.
790    let header = image.read_at(0, 64)?;
791    let le = elf64_little_endian(&header)?;
792
793    let e_phoff = read_u64(&header, 32, le)?;
794    let e_phentsize = u64::from(read_u16(&header, 54, le)?);
795    let e_phnum = u64::from(read_u16(&header, 56, le)?);
796    // Each Elf64_Phdr is 56 bytes; a smaller entsize is not this layout.
797    if e_phentsize < 56 {
798        return None;
799    }
800
801    let mut interp: Option<String> = None;
802    let mut dynamic_region: Option<(u64, u64)> = None; // (file offset, size)
803    let mut loads: Vec<(u64, u64, u64)> = Vec::new(); // (p_vaddr, p_offset, p_filesz)
804
805    // Every field read below lies in the first 40 bytes of an entry, so the table ends 40 bytes
806    // into its last entry.
807    let table = match e_phnum.checked_sub(1) {
808        None => std::borrow::Cow::Borrowed(&[][..]),
809        Some(last) => image.read_at(e_phoff, last.checked_mul(e_phentsize)?.checked_add(40)?)?,
810    };
811    for i in 0..e_phnum {
812        let base = usize::try_from(i * e_phentsize).ok()?;
813        let p_type = read_u32(&table, base, le)?;
814        let p_offset = read_u64(&table, base + 8, le)?;
815        let p_vaddr = read_u64(&table, base + 16, le)?;
816        let p_filesz = read_u64(&table, base + 32, le)?;
817        match p_type {
818            1 => loads.push((p_vaddr, p_offset, p_filesz)), // PT_LOAD
819            2 => dynamic_region = Some((p_offset, p_filesz)), // PT_DYNAMIC
820            3 => {
821                // PT_INTERP: a NUL-terminated interpreter path in the file image.
822                interp = Some(cstr_at_start(&image.read_at(p_offset, p_filesz)?));
823            }
824            _ => {}
825        }
826    }
827
828    // A static binary (no PT_DYNAMIC) contributes only itself — no interp, no needed libs.
829    let Some((dyn_off, dyn_size)) = dynamic_region else {
830        return Some(ElfDependencies {
831            interp,
832            needed: Vec::new(),
833            runpaths: Vec::new(),
834        });
835    };
836
837    let mut needed_offsets: Vec<u64> = Vec::new();
838    let mut runpath_offsets: Vec<u64> = Vec::new();
839    let mut strtab_vaddr: Option<u64> = None;
840    let mut strsz: Option<u64> = None;
841
842    dyn_off.checked_add(dyn_size)?;
843    // Entries after `DT_NULL` are never read, so a segment that runs past the end of the file is
844    // read as far as the file goes, and an entry before `DT_NULL` beyond that fails the parse.
845    let dynamic = read_clamped(image, dyn_off, dyn_size)?;
846    let mut off = 0u64;
847    while off + 16 <= dyn_size {
848        let entry = dynamic.get(usize::try_from(off).ok()?..usize::try_from(off + 16).ok()?)?;
849        let d_tag = read_u64(entry, 0, le)?;
850        let d_val = read_u64(entry, 8, le)?;
851        match d_tag {
852            0 => break,                             // DT_NULL — end of dynamic array
853            1 => needed_offsets.push(d_val),        // DT_NEEDED
854            5 => strtab_vaddr = Some(d_val),        // DT_STRTAB (virtual address)
855            10 => strsz = Some(d_val),              // DT_STRSZ
856            15 | 29 => runpath_offsets.push(d_val), // DT_RPATH | DT_RUNPATH
857            _ => {}
858        }
859        off += 16;
860    }
861
862    let mut needed = Vec::new();
863    let mut runpaths = Vec::new();
864    if let (Some(strtab_vaddr), Some(strsz)) = (strtab_vaddr, strsz) {
865        if let Some(strtab_off) = vaddr_to_file_offset(strtab_vaddr, &loads) {
866            // Prefer the declared string-table bounds, but tolerate an over-long DT_STRSZ by
867            // falling back to the rest of the file rather than dropping every soname.
868            let strtab = (strtab_off <= image.size())
869                .then(|| read_clamped(image, strtab_off, strsz))
870                .flatten();
871            if let Some(strtab) = strtab {
872                for offset in needed_offsets {
873                    if let Some(region) = strtab.get(offset as usize..) {
874                        needed.push(cstr_at_start(region));
875                    }
876                }
877                for offset in runpath_offsets {
878                    if let Some(region) = strtab.get(offset as usize..) {
879                        for part in cstr_at_start(region).split(':') {
880                            if !part.is_empty() {
881                                runpaths.push(part.to_string());
882                            }
883                        }
884                    }
885                }
886            }
887        }
888    }
889
890    Some(ElfDependencies {
891        interp,
892        needed,
893        runpaths,
894    })
895}
896
897/// Whether `image` is an ELF64 shared object (`e_type` `ET_DYN`), in either byte order. Reads only
898/// the first 18 bytes, so a caller sorting a directory reads no more of each file than that. A
899/// position-independent executable is `ET_DYN` too and answers `true`.
900#[cfg(target_os = "linux")]
901pub(crate) fn elf_is_shared_object<S: ElfSource + ?Sized>(image: &S) -> bool {
902    let Some(head) = image.read_at(0, 18) else {
903        return false;
904    };
905    elf64_little_endian(&head).is_some_and(|le| read_u16(&head, 16, le) == Some(3))
906}
907
908/// The name of every entry in an ELF64 image's dynamic symbol table (`.dynsym`), defined and
909/// undefined alike, in table order. `None` for anything that is not a well-formed ELF64 image or
910/// has no `.dynsym` section.
911///
912/// Reads the header, the section headers up to the `SHT_DYNSYM` one, that section and the string
913/// table its `sh_link` names, and nothing else of the image. A symbol the binary imports from a
914/// shared library is listed here as well as one it defines, so an interpreter built against
915/// `libpython` or `libperl` names its entry symbol the same way a statically linked one does. A
916/// binary stripped of its section headers names nothing.
917#[cfg(target_os = "linux")]
918pub(crate) fn elf_dynamic_symbol_names<S: ElfSource + ?Sized>(image: &S) -> Option<Vec<String>> {
919    let header = image.read_at(0, 64)?;
920    let le = elf64_little_endian(&header)?;
921    let e_shoff = read_u64(&header, 40, le)?;
922    let e_shentsize = u64::from(read_u16(&header, 58, le)?);
923    let e_shnum = u64::from(read_u16(&header, 60, le)?);
924    // Each Elf64_Shdr is 64 bytes; a smaller entsize is not this layout.
925    if e_shentsize < 64 {
926        return None;
927    }
928    // (sh_type, sh_offset, sh_size, sh_link, sh_entsize) of section `index`.
929    let section = |index: u64| -> Option<(u32, u64, u64, u64, u64)> {
930        let base = e_shoff.checked_add(index.checked_mul(e_shentsize)?)?;
931        let entry = image.read_at(base, 64)?;
932        Some((
933            read_u32(&entry, 4, le)?,
934            read_u64(&entry, 24, le)?,
935            read_u64(&entry, 32, le)?,
936            u64::from(read_u32(&entry, 40, le)?),
937            read_u64(&entry, 56, le)?,
938        ))
939    };
940    // SHT_DYNSYM == 11.
941    let (_, sym_off, sym_size, strtab_index, sym_entsize) = (0..e_shnum)
942        .filter_map(section)
943        .find(|(sh_type, ..)| *sh_type == 11)?;
944    // Each Elf64_Sym is 24 bytes.
945    if sym_entsize < 24 {
946        return None;
947    }
948    let (_, str_off, str_size, _, _) = section(strtab_index)?;
949    let strtab = image.read_at(str_off, str_size)?;
950    let symbols = image.read_at(sym_off, sym_size)?;
951    let mut names = Vec::new();
952    for symbol in symbols.chunks_exact(usize::try_from(sym_entsize).ok()?) {
953        let st_name = read_u32(symbol, 0, le)? as usize;
954        if st_name == 0 {
955            continue;
956        }
957        if let Some(region) = strtab.get(st_name..) {
958            names.push(cstr_at_start(region));
959        }
960    }
961    Some(names)
962}
963
964/// Expands a leading/embedded `$ORIGIN` (or `${ORIGIN}`) in an rpath entry to the directory of the
965/// binary that declared it — the dynamic loader's own rule for `DT_RUNPATH`/`DT_RPATH`.
966fn expand_origin(rpath: &str, origin_dir: &Path) -> PathBuf {
967    if rpath.contains("$ORIGIN") {
968        let origin = origin_dir.to_string_lossy();
969        PathBuf::from(
970            rpath
971                .replace("${ORIGIN}", &origin)
972                .replace("$ORIGIN", &origin),
973        )
974    } else {
975        PathBuf::from(rpath)
976    }
977}
978
979/// Resolves one `DT_NEEDED` soname to a canonical file path, mirroring the loader's search order:
980/// a soname containing a `/` is taken as a path (absolute, or relative to the binary's directory);
981/// otherwise the binary's own `DT_RUNPATH`/`DT_RPATH` entries (with `$ORIGIN` expanded) are tried
982/// first, then the standard `search_dirs`. Returns `None` (shrink-not-fail) if nothing matches.
983pub(crate) fn resolve_soname(
984    soname: &str,
985    origin_dir: &Path,
986    runpaths: &[String],
987    search_dirs: &[PathBuf],
988) -> Option<PathBuf> {
989    if soname.is_empty() {
990        return None;
991    }
992    if soname.contains('/') {
993        let candidate = if Path::new(soname).is_absolute() {
994            PathBuf::from(soname)
995        } else {
996            origin_dir.join(soname)
997        };
998        return std::fs::canonicalize(&candidate).ok();
999    }
1000    for rpath in runpaths {
1001        let candidate = expand_origin(rpath, origin_dir).join(soname);
1002        if candidate.is_file() {
1003            if let Ok(canonical) = std::fs::canonicalize(&candidate) {
1004                return Some(canonical);
1005            }
1006        }
1007    }
1008    for dir in search_dirs {
1009        let candidate = dir.join(soname);
1010        if candidate.is_file() {
1011            if let Ok(canonical) = std::fs::canonicalize(&candidate) {
1012                return Some(canonical);
1013            }
1014        }
1015    }
1016    None
1017}
1018
1019/// Derives the full set of canonical file paths that need a narrow read+execute Landlock grant
1020/// *outside* the workdir so the `shell.allow` binaries can actually run: each allowlisted binary,
1021/// its ELF interpreter, and the transitive closure of its `DT_NEEDED` shared libraries. Uses the
1022/// standard [`DEFAULT_LIBRARY_SEARCH_DIRS`] as the soname fallback search set.
1023///
1024/// Every step is shrink-not-fail (a binary that can't be read/parsed, or a soname that doesn't
1025/// resolve, contributes nothing further); the returned set is deduplicated and canonical. Runs in
1026/// the parent at launch time — never in the forked child's `pre_exec`.
1027pub(crate) fn resolve_landlock_grants(exec_allow_paths: &[PathBuf]) -> Vec<PathBuf> {
1028    let search_dirs: Vec<PathBuf> = DEFAULT_LIBRARY_SEARCH_DIRS
1029        .iter()
1030        .map(PathBuf::from)
1031        .collect();
1032    resolve_landlock_grants_in(exec_allow_paths, &search_dirs)
1033}
1034
1035/// Testable core of [`resolve_landlock_grants`], with the library search directories injected so
1036/// unit tests can point it at synthetic fixtures instead of the real system directories.
1037fn resolve_landlock_grants_in(
1038    exec_allow_paths: &[PathBuf],
1039    search_dirs: &[PathBuf],
1040) -> Vec<PathBuf> {
1041    resolve_landlock_grants_outside(exec_allow_paths, search_dirs, &[])
1042}
1043
1044/// [`resolve_landlock_grants_in`], skipping every path under one of the canonical `excluded`
1045/// roots: such a path is neither granted nor read for further dependencies. For a closure that
1046/// must not be decided by files the capsule can write.
1047pub(crate) fn resolve_landlock_grants_outside(
1048    exec_allow_paths: &[PathBuf],
1049    search_dirs: &[PathBuf],
1050    excluded: &[PathBuf],
1051) -> Vec<PathBuf> {
1052    let Ok(grants) = walk_dependency_closure(
1053        exec_allow_paths,
1054        search_dirs,
1055        excluded,
1056        |path| -> Result<_, std::convert::Infallible> { Ok(read_elf_dependencies(path)) },
1057    );
1058    grants
1059}
1060
1061/// The dependencies of the ELF image at `path`, read through [`ElfFile`]. `None` when it cannot
1062/// be opened or does not parse.
1063fn read_elf_dependencies(path: &Path) -> Option<ElfDependencies> {
1064    parse_elf_dependencies(&ElfFile::open_host(path).ok()?)
1065}
1066
1067/// The walk behind [`resolve_landlock_grants_outside`], with each visited file's dependencies
1068/// taken from `dependencies`, which is asked once per visited path. An error from it ends the
1069/// walk with that error.
1070pub(crate) fn walk_dependency_closure<E>(
1071    exec_allow_paths: &[PathBuf],
1072    search_dirs: &[PathBuf],
1073    excluded: &[PathBuf],
1074    mut dependencies: impl FnMut(&Path) -> Result<Option<ElfDependencies>, E>,
1075) -> Result<Vec<PathBuf>, E> {
1076    let mut grants: std::collections::BTreeSet<PathBuf> = std::collections::BTreeSet::new();
1077    let mut visited: std::collections::BTreeSet<PathBuf> = std::collections::BTreeSet::new();
1078    let mut queue: std::collections::VecDeque<PathBuf> = std::collections::VecDeque::new();
1079
1080    for path in exec_allow_paths {
1081        // The allowlist paths are already canonical/existing (from `resolve_exec_allowlist`), but
1082        // canonicalize defensively — this both makes dedup against interp/library paths exact and
1083        // drops any seed that no longer exists (shrink-not-fail: a vanished binary contributes
1084        // nothing, and could not be exec'd anyway).
1085        if let Ok(canonical) = std::fs::canonicalize(path) {
1086            queue.push_back(canonical);
1087        }
1088    }
1089
1090    while let Some(path) = queue.pop_front() {
1091        if excluded.iter().any(|root| path.starts_with(root)) {
1092            continue;
1093        }
1094        if !visited.insert(path.clone()) {
1095            continue;
1096        }
1097        // The binary/library itself is granted. If it lives inside the workdir, the workdir's own
1098        // full-access rule already covers it and this extra read+execute rule is simply redundant.
1099        grants.insert(path.clone());
1100
1101        let Some(deps) = dependencies(&path)? else {
1102            continue;
1103        };
1104        let origin_dir = path.parent().unwrap_or_else(|| Path::new("/"));
1105
1106        if let Some(interp) = &deps.interp {
1107            if let Ok(canonical) = std::fs::canonicalize(interp) {
1108                queue.push_back(canonical);
1109            }
1110        }
1111        for soname in &deps.needed {
1112            if let Some(resolved) = resolve_soname(soname, origin_dir, &deps.runpaths, search_dirs)
1113            {
1114                queue.push_back(resolved);
1115            }
1116        }
1117    }
1118
1119    Ok(grants.into_iter().collect())
1120}
1121
1122/// One resolved Landlock filesystem grant outside the workdir: a canonical path plus the two
1123/// independent bits that decide the rule's access set.
1124///
1125/// `list_dir` decides enumerability (`ReadDir`):
1126///
1127///   - `false` → no `getdents64` on the granted directory itself. Landlock's read rights apply to
1128///     the whole subtree beneath a granted directory, so a file *inside* it can still be opened by
1129///     its exact name — the listing is what is denied. This is what every derived
1130///     `DT_NEEDED`-closure grant gets — they are individual files, where `ReadDir` was always a
1131///     no-op anyway (Landlock's `ReadDir` only has meaning on a directory inode).
1132///   - `true` → adds enumerability, which a path-based interpreter's import machinery needs on
1133///     each `sys.path` entry (CPython's `FileFinder` `listdir`-caches each one). Set by an author
1134///     writing `list_dir: true` next to a specific `interpreter_runtime` directory, on each
1135///     directory a recognised interpreter reports on its own import path
1136///     ([`resolve_derived_interpreter_grants`]), and by the whole-tree resolvers
1137///     ([`resolve_staged_runtime_landlock_grants`], [`resolve_sealed_runtime_landlock_grants`]
1138///     and, per entry, the directory half of [`resolve_sealed_etc_landlock_grants`]) where a tree
1139///     is walked by definition — never inferred from anything else, and never applied to an
1140///     ancestor or sibling of a granted directory.
1141///
1142/// `ReadDir` is meaningful only on a directory, and the kernel enforces that: `landlock_add_rule`
1143/// returns `EINVAL` for a directory-only right on a non-directory. `open_landlock_fds` therefore
1144/// narrows `list_dir` to `false` for any grant whose fd does not `fstat` as a directory, so this
1145/// field is a request rather than a promise.
1146///
1147/// `executable` decides `Execute`, which is this runtime's exec allowlist: a path without an
1148/// `Execute` rule cannot be `execve`d, and a path with one can. This is the canonical statement of
1149/// that rule; the grant resolvers below cross-reference it rather than restating it.
1150///
1151///   - `true` → `Execute + ReadFile`, for a path the manifest asked to *run*: a `shell.allow`
1152///     binary, its `DT_NEEDED` closure, and the interpreter/staged trees an author named.
1153///   - `false` → `ReadFile` only (readable, and with `list_dir` enumerable, but not runnable).
1154///     For a grant the *tier* issues rather than the manifest: see
1155///     [`resolve_sealed_runtime_landlock_grants`], where granting `Execute` over `/usr`, `/bin`
1156///     and `/sbin` wholesale would turn `shell.allow` into a no-op on `sealed`, and
1157///     [`resolve_sealed_etc_landlock_grants`], where `/etc/alternatives` is a directory of
1158///     symlinks into exactly those trees.
1159///
1160/// Affects `execve` only: a shared object mapped `PROT_EXEC` by `dlopen(3)` needs `ReadFile`, not
1161/// `Execute`, so an interpreter still loads its C extensions out of an `executable: false` tree.
1162#[derive(Debug, Clone, PartialEq, Eq)]
1163pub(crate) struct LandlockGrant {
1164    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1165    pub(crate) path: PathBuf,
1166    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1167    pub(crate) list_dir: bool,
1168    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1169    pub(crate) executable: bool,
1170    /// When set, the rule is added only if `path` still opens to this inode, and is otherwise
1171    /// dropped (shrink-not-fail). For a grant on a capsule-writable path: the capsule can replace
1172    /// the file, or a directory above it, with a symlink to any host binary, and an unpinned grant
1173    /// would follow it and put `Execute` on that binary. A pin tells one live file from another,
1174    /// not new content from old: an inode number freed by a delete can be reused by the next file.
1175    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1176    pub(crate) pinned: Option<FileIdentity>,
1177}
1178
1179/// A file's `(st_dev, st_ino)`: which inode a path named when the runtime recorded it.
1180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1181pub(crate) struct FileIdentity {
1182    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1183    dev: u64,
1184    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1185    ino: u64,
1186}
1187
1188impl FileIdentity {
1189    #[cfg(unix)]
1190    pub(crate) fn of(metadata: &std::fs::Metadata) -> FileIdentity {
1191        use std::os::unix::fs::MetadataExt;
1192        FileIdentity {
1193            dev: metadata.dev(),
1194            ino: metadata.ino(),
1195        }
1196    }
1197}
1198
1199impl LandlockGrant {
1200    /// Wraps each derived `DT_NEEDED`-closure file path as a non-listable grant. A regular file
1201    /// has no meaningful `ReadDir`, so `list_dir: false` costs nothing. `executable: true`: this
1202    /// closure is exactly the set of paths `shell.allow` asked to run — see [`LandlockGrant`].
1203    fn non_listable_files(paths: Vec<PathBuf>) -> Vec<LandlockGrant> {
1204        paths
1205            .into_iter()
1206            .map(|path| LandlockGrant {
1207                path,
1208                list_dir: false,
1209                executable: true,
1210                pinned: None,
1211            })
1212            .collect()
1213    }
1214}
1215
1216/// Turns each `capabilities.shell.interpreter_runtime` directory into a [`LandlockGrant`] carrying
1217/// exactly the `list_dir` its author wrote. Pure and syscall-free (the declared paths need not
1218/// exist here — `apply_landlock_scope` skips any that fail to open), so it is unit-testable on
1219/// every platform, including non-Linux targets.
1220///
1221/// There is deliberately no path here that could widen a grant to a whole install prefix: it emits
1222/// one grant per explicitly declared directory and copies the author's `list_dir` verbatim,
1223/// inferring enumerability from nothing. A recognised interpreter's own import path is granted
1224/// separately, by [`resolve_derived_interpreter_grants`]; this is the route for an interpreter the
1225/// runtime does not recognise.
1226pub(crate) fn resolve_interpreter_runtime_grants(
1227    grants: &[InterpreterRuntimeGrant],
1228) -> Vec<LandlockGrant> {
1229    grants
1230        .iter()
1231        .flat_map(|grant| {
1232            grant.dirs.iter().map(|dir| LandlockGrant {
1233                path: PathBuf::from(&dir.path),
1234                list_dir: dir.list_dir,
1235                // Executable: an `interpreter_runtime` directory is named by an author precisely so
1236                // the interpreter it belongs to can run out of it, helper binaries included.
1237                executable: true,
1238                pinned: None,
1239            })
1240        })
1241        .collect()
1242}
1243
1244/// One [`LandlockGrant`] per entry staging derived from a recognised interpreter (see
1245/// [`crate::interpreter_runtime`]): each directory on its import path readable and listable, and
1246/// each zip archive on it and each extension-module library readable only.
1247///
1248/// `executable: false` on every one (see [`LandlockGrant`]): the manifest asked to run the
1249/// interpreter, which its own ELF closure grants, not the files it imports. A C extension module
1250/// still loads, because `dlopen(3)` needs `ReadFile` alone. The derivation never names `/` or an
1251/// ancestor of the interpreter's directory, so no grant here widens to an install prefix.
1252pub(crate) fn resolve_derived_interpreter_grants(
1253    derived: &[crate::interpreter_runtime::DerivedInterpreterRuntime],
1254) -> Vec<LandlockGrant> {
1255    let read_only = |path: &Path, list_dir: bool| LandlockGrant {
1256        path: path.to_path_buf(),
1257        list_dir,
1258        executable: false,
1259        pinned: None,
1260    };
1261    derived
1262        .iter()
1263        .flat_map(|runtime| {
1264            runtime
1265                .entries()
1266                .map(|(path, is_dir)| read_only(path, is_dir))
1267                .chain(runtime.libraries.iter().map(|path| read_only(path, false)))
1268                .collect::<Vec<_>>()
1269        })
1270        .collect()
1271}
1272
1273/// One [`LandlockGrant`] per host trust-store path staging found (see [`crate::trust_store`]): a
1274/// directory readable and listable, a CA bundle file readable.
1275///
1276/// `executable: false` on every one (see [`LandlockGrant`]): a certificate is read, never run. No
1277/// write right, as on every [`LandlockGrant`]. The network gate (a non-empty `shell.allow` and a
1278/// non-empty `network.allow`) lives in [`crate::trust_store::derive_trust_store`], so a policy
1279/// that fails it reaches here with nothing to grant; the derivation never names `/` or an
1280/// ancestor of a trust-store path, so no grant here reaches `/etc/ssl/private`.
1281///
1282/// Issued on every tier and consulted only on `KernelFull` and `KernelSealed`. On `KernelSealed`
1283/// a path the composed root does not bind stays unreachable: this grant adds no bind.
1284pub(crate) fn resolve_trust_store_grants(
1285    paths: &[crate::trust_store::TrustStorePath],
1286) -> Vec<LandlockGrant> {
1287    paths
1288        .iter()
1289        .map(|entry| LandlockGrant {
1290            path: entry.path.clone(),
1291            list_dir: entry.directory,
1292            executable: false,
1293            pinned: None,
1294        })
1295        .collect()
1296}
1297
1298/// The host directories a composed root must carry *beyond* [`crate::sealed::SEALED_RUNTIME_PATHS`]
1299/// for this capsule's `shell.allow` to keep working: the containing directory of each resolved
1300/// allowlisted binary, each declared `interpreter_runtime` directory, and each directory a
1301/// recognised interpreter reported on its import path with the parent directory of each library
1302/// its extension modules need.
1303///
1304/// Whole directories, deduplicated, and only the ones not already inside a fixed runtime path.
1305/// This is the deliberate opposite of `resolve_landlock_grants`: no ELF parsing, no `DT_NEEDED`
1306/// closure, no per-file grant — a bind mount carries a directory's whole contents, so the closure
1307/// derivation `scoped` needs has nothing left to do here.
1308///
1309/// Every entry is **optional** by design — see `sealed::PlanBuilder::mirror`. A host missing one
1310/// makes the composed root narrower rather than refusing the launch, which is why
1311/// `capabilities.shell.staged_runtime` does *not* come through here: a declared runtime tree must
1312/// fail closed, so it is resolved separately by [`resolve_staged_runtime_dirs`] and planned as a
1313/// required bind. This function is additive to that, not replaced by it.
1314///
1315/// Pure and syscall-free, so it is unit-testable on any platform.
1316pub(crate) fn resolve_sealed_bind_dirs(
1317    exec_allow_paths: &[PathBuf],
1318    policy: &CapabilityPolicy,
1319) -> Vec<PathBuf> {
1320    let mut dirs: Vec<PathBuf> = Vec::new();
1321    let candidates = exec_allow_paths
1322        .iter()
1323        .filter_map(|binary| binary.parent().map(Path::to_path_buf))
1324        .chain(
1325            policy
1326                .shell_interpreter_runtime
1327                .iter()
1328                .flat_map(|grant| grant.dirs.iter().map(|dir| PathBuf::from(&dir.path))),
1329        )
1330        .chain(
1331            policy
1332                .shell_derived_interpreter_runtime
1333                .iter()
1334                .flat_map(|runtime| {
1335                    runtime
1336                        .entries()
1337                        .filter(|(_, is_dir)| *is_dir)
1338                        .map(|(path, _)| path.to_path_buf())
1339                        .chain(
1340                            runtime
1341                                .libraries
1342                                .iter()
1343                                .filter_map(|library| library.parent().map(Path::to_path_buf)),
1344                        )
1345                        .collect::<Vec<_>>()
1346                }),
1347        );
1348
1349    for candidate in candidates {
1350        push_sealed_bind_dir(&mut dirs, candidate);
1351    }
1352    dirs
1353}
1354
1355/// Every directory this session may run something out of, for the host-path check
1356/// ([`crate::host_paths::check_execute_roots`]): the directory of each file its Landlock grants
1357/// mark executable (each resolved `shell.allow` binary, its ELF interpreter and `DT_NEEDED`
1358/// closure) and each executable directory grant (`interpreter_runtime` directories,
1359/// `staged_runtime` trees). Called by `stage_session` and `mur run --explain-scope`.
1360pub(crate) fn host_path_execute_roots(
1361    policy: &CapabilityPolicy,
1362) -> Vec<crate::host_paths::ExecuteRoot> {
1363    let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
1364    execute_roots_from(&resolve_landlock_grants(&exec_allow_paths), policy)
1365}
1366
1367/// [`host_path_execute_roots`] from an already-resolved executable closure.
1368fn execute_roots_from(
1369    closure: &[PathBuf],
1370    policy: &CapabilityPolicy,
1371) -> Vec<crate::host_paths::ExecuteRoot> {
1372    let mut roots: Vec<crate::host_paths::ExecuteRoot> = Vec::new();
1373    let mut push = |dir: PathBuf, because: PathBuf| {
1374        let dir = std::fs::canonicalize(&dir).unwrap_or(dir);
1375        if dir.is_absolute() && !roots.iter().any(|root| root.dir == dir) {
1376            roots.push(crate::host_paths::ExecuteRoot { dir, because });
1377        }
1378    };
1379    for file in closure {
1380        if let Some(parent) = file.parent() {
1381            push(parent.to_path_buf(), file.clone());
1382        }
1383    }
1384    for grant in &policy.shell_interpreter_runtime {
1385        for dir in &grant.dirs {
1386            push(PathBuf::from(&dir.path), PathBuf::from(&dir.path));
1387        }
1388    }
1389    for dir in resolve_staged_runtime_dirs(policy) {
1390        push(dir.clone(), dir);
1391    }
1392    roots
1393}
1394
1395/// What `tier` does with a host path.
1396pub(crate) fn host_path_mechanism(tier: EnforcementTier) -> crate::host_paths::HostPathMechanism {
1397    match tier {
1398        EnforcementTier::KernelSealed => crate::host_paths::HostPathMechanism::Bind,
1399        EnforcementTier::KernelFull => crate::host_paths::HostPathMechanism::Landlock,
1400        EnforcementTier::KernelSeccompOnly
1401        | EnforcementTier::EnvironmentOnly
1402        | EnforcementTier::Unconfined => crate::host_paths::HostPathMechanism::None,
1403    }
1404}
1405
1406/// Adds `candidate` to `dirs` as an optional composed-root bind unless it is relative, already
1407/// inside a fixed [`crate::sealed::SEALED_RUNTIME_PATHS`] entry, or already listed. The one filter
1408/// [`resolve_sealed_bind_dirs`] and [`ShellEnforcement::for_native_entrypoint`] share.
1409fn push_sealed_bind_dir(dirs: &mut Vec<PathBuf>, candidate: PathBuf) {
1410    if !candidate.is_absolute() {
1411        return;
1412    }
1413    if crate::sealed::SEALED_RUNTIME_PATHS
1414        .iter()
1415        .any(|root| candidate.starts_with(root))
1416    {
1417        return;
1418    }
1419    if dirs.contains(&candidate) {
1420        return;
1421    }
1422    dirs.push(candidate);
1423}
1424
1425/// The most bytes [`shebang_interpreter`] reads from the head of a native entrypoint. The
1426/// kernel's own `#!` buffer (`BINPRM_BUF_SIZE`) is 256 bytes, so a longer line names nothing the
1427/// kernel would run.
1428const SHEBANG_READ_LIMIT: u64 = 256;
1429
1430/// The interpreter the kernel runs when it execs the script `image`: the first token of a leading
1431/// `#!` line, when that token is an absolute path. `None` for an ELF binary, a relative
1432/// interpreter and a line with no token.
1433///
1434/// Looks at no more than the first [`SHEBANG_READ_LIMIT`] bytes. Only the interpreter itself is
1435/// named: for `#!/usr/bin/env python3` that is `env`, and `python3` is whatever `env` then finds,
1436/// which needs its own `capabilities.shell.allow` entry like any other program a tool runs.
1437pub(crate) fn shebang_interpreter(image: &[u8]) -> Option<PathBuf> {
1438    let head = &image[..image.len().min(SHEBANG_READ_LIMIT as usize)];
1439    let line = crate::reachability::shebang_line_of(head)?;
1440    let interpreter = PathBuf::from(line.split_whitespace().next()?);
1441    interpreter.is_absolute().then_some(interpreter)
1442}
1443
1444/// What the kernel executes to start one installed native tool, derived once, when the runtime
1445/// writes the tool into the session workdir, from the image bytes it writes.
1446///
1447/// The workdir is capsule-writable, so nothing here is read back from it: the `#!` interpreter and
1448/// the `PT_INTERP`/`DT_NEEDED` entries come from the runtime's own copy of the image, the
1449/// dependency walk searches only absolute locations and neither grants nor reads a file under the
1450/// accessible workdir, and the entrypoint's own grant is pinned to the inode the runtime wrote. A
1451/// symlink or any other existing file put in the entrypoint's place loses its `Execute` rule. A
1452/// capsule that rewrites the entrypoint runs its own code under these grants, including when it
1453/// deletes the file and the filesystem hands a new one the freed inode number.
1454#[derive(Debug, Clone, PartialEq, Eq)]
1455pub(crate) struct NativeEntrypoint {
1456    path: PathBuf,
1457    pinned: Option<FileIdentity>,
1458    interpreter: Option<PathBuf>,
1459    closure: Vec<PathBuf>,
1460}
1461
1462impl NativeEntrypoint {
1463    /// Derives the grants for the tool the runtime has just written at `path` from `image`, the
1464    /// bytes it wrote. `accessible_workdir` is the capsule-writable tree the walk stays out of.
1465    pub(crate) fn derive(path: &Path, image: &[u8], accessible_workdir: &Path) -> NativeEntrypoint {
1466        let search_dirs: Vec<PathBuf> = DEFAULT_LIBRARY_SEARCH_DIRS
1467            .iter()
1468            .map(PathBuf::from)
1469            .collect();
1470        Self::derive_in(path, image, accessible_workdir, &search_dirs)
1471    }
1472
1473    fn derive_in(
1474        path: &Path,
1475        image: &[u8],
1476        accessible_workdir: &Path,
1477        search_dirs: &[PathBuf],
1478    ) -> NativeEntrypoint {
1479        let interpreter = shebang_interpreter(image);
1480        let mut seeds: Vec<PathBuf> = interpreter.iter().cloned().collect();
1481        if let Some(deps) = parse_elf_dependencies(image) {
1482            // Only absolute locations are searched. `$ORIGIN` is the entrypoint's own directory in
1483            // the capsule-writable workdir, and a relative entry resolves against this process's
1484            // working directory, which may be the workdir too: a symlink planted there would
1485            // carry the lookup to any host file, and canonicalising it hides that from the
1486            // exclusion below. A library the tool ships beside itself is readable through the
1487            // workdir's own rule and needs no grant.
1488            let absolute = |entry: &&String| Path::new(entry.as_str()).is_absolute();
1489            seeds.extend(deps.interp.iter().filter(absolute).map(PathBuf::from));
1490            let runpaths: Vec<String> = deps
1491                .runpaths
1492                .iter()
1493                .filter(|entry| absolute(entry) && !entry.contains("ORIGIN"))
1494                .cloned()
1495                .collect();
1496            seeds.extend(
1497                deps.needed
1498                    .iter()
1499                    .filter(|soname| !soname.contains('/') || absolute(soname))
1500                    .filter_map(|soname| {
1501                        resolve_soname(soname, Path::new("/"), &runpaths, search_dirs)
1502                    }),
1503            );
1504        }
1505        let excluded = std::fs::canonicalize(accessible_workdir)
1506            .unwrap_or_else(|_| accessible_workdir.to_path_buf());
1507        NativeEntrypoint {
1508            path: path.to_path_buf(),
1509            pinned: pinned_identity(path),
1510            interpreter,
1511            closure: resolve_landlock_grants_outside(&seeds, search_dirs, &[excluded]),
1512        }
1513    }
1514
1515    /// The installed entrypoint, the path the tool is spawned from.
1516    pub(crate) fn path(&self) -> &Path {
1517        &self.path
1518    }
1519}
1520
1521/// The identity of the regular file at `path`, not following a final symlink. `None` when there is
1522/// none, which leaves the entrypoint without a grant rather than with an unpinned one.
1523#[cfg(unix)]
1524fn pinned_identity(path: &Path) -> Option<FileIdentity> {
1525    std::fs::symlink_metadata(path)
1526        .ok()
1527        .filter(std::fs::Metadata::is_file)
1528        .map(|meta| FileIdentity::of(&meta))
1529}
1530
1531#[cfg(not(unix))]
1532fn pinned_identity(_path: &Path) -> Option<FileIdentity> {
1533    None
1534}
1535
1536/// The `source_path` of each declared `capabilities.shell.staged_runtime` grant, as the composed
1537/// root's *required* read-only binds.
1538///
1539/// Deliberately not folded into [`resolve_sealed_bind_dirs`], and deliberately not filtered the
1540/// way that function filters. Both differences follow from the same fact — these fail closed:
1541///
1542///   * A path already inside [`crate::sealed::SEALED_RUNTIME_PATHS`] is **not** dropped here.
1543///     Dropping it would be sound for an optional bind (the fixed list already covers it) but it
1544///     would quietly convert a required grant into a dependency on an unrelated list staying the
1545///     way it is today. `plan_composed_root` runs the required loop first and deduplicates by
1546///     target, so naming an already-covered path costs one registration and stays required.
1547///   * A non-existent path is **not** filtered out, here or anywhere. The whole point is that a
1548///     grant naming a tree this host does not have refuses the launch — see
1549///     `sealed::PlanBuilder::require_bind`.
1550///
1551/// Relative paths are still skipped: a relative `source_path` cannot be re-based under the
1552/// composed root's base at all, so it names nothing to fail *about*. The manifest parser is the
1553/// layer that rejects one.
1554///
1555/// Pure and syscall-free, so it is unit-testable on any platform.
1556pub(crate) fn resolve_staged_runtime_dirs(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1557    let mut dirs: Vec<PathBuf> = Vec::new();
1558    for grant in &policy.shell_staged_runtime {
1559        let path = PathBuf::from(&grant.source_path);
1560        if !path.is_absolute() || dirs.contains(&path) {
1561            continue;
1562        }
1563        dirs.push(path);
1564    }
1565    dirs
1566}
1567
1568/// One listable [`LandlockGrant`] per staged runtime directory.
1569///
1570/// The bind mount alone does not make a staged tree usable, and the reason is easy to miss: the
1571/// composed root is not the only thing standing between the subprocess and a path. `sealed` still
1572/// installs Landlock *inside* the root as defence in depth (see `applied_tier` and the
1573/// `KernelSealed` arm of `prepare_enforcement`), and Landlock denies any path with no matching
1574/// rule. A staged tree that is bind-mounted but ungranted is therefore present, read-only, and
1575/// unreadable — `open()` returns `EACCES` — which is indistinguishable from the capability not
1576/// working at all.
1577///
1578/// So a grant is emitted here for exactly the same directories [`resolve_staged_runtime_dirs`]
1579/// binds, and the two are resolved from the same list so they cannot drift apart. `list_dir: true`
1580/// is not configurable, unlike `interpreter_runtime`'s per-directory flag: a staged tree is a whole
1581/// runtime a program walks (an interpreter enumerating its stdlib, a toolchain resolving its
1582/// libexec), and the author already made the enumerability decision by naming the tree. Landlock
1583/// rules attach to the opened inode, so a rule taken on the host path in the parent covers the
1584/// same inode reached through the bind inside the root.
1585///
1586/// Read and execute only — never write. The bind is `MS_RDONLY` regardless, so this is the second
1587/// of two independent reasons the tree cannot be mutated from inside a session.
1588///
1589/// Pure and syscall-free, so it is unit-testable on any platform.
1590pub(crate) fn resolve_staged_runtime_landlock_grants(
1591    policy: &CapabilityPolicy,
1592) -> Vec<LandlockGrant> {
1593    resolve_staged_runtime_dirs(policy)
1594        .into_iter()
1595        .map(|path| LandlockGrant {
1596            path,
1597            list_dir: true,
1598            executable: true,
1599            pinned: None,
1600        })
1601        .collect()
1602}
1603
1604/// One listable [`LandlockGrant`] per [`crate::sealed::SEALED_RUNTIME_PATHS`] entry, and only on
1605/// `KernelSealed`.
1606///
1607/// `plan_composed_root` bind-mounts `/usr`, `/bin`, `/sbin`, `/lib`… read-only into every composed
1608/// root, but the Landlock ruleset installed inside that root denies any path with no matching rule.
1609/// The `shell.allow` ELF closure grants those files one at a time and non-listably, so a binary
1610/// opens by exact name while the directory holding it cannot be enumerated — enough to start an
1611/// interpreter, not enough to run it: CPython dies in `init_fs_encoding` because it cannot
1612/// `getdents64` `/usr/lib/python3.N` to find `encodings`. This grant makes the bound tree
1613/// enumerable.
1614///
1615/// `list_dir: true` is not configurable: a whole runtime tree a program walks is not a
1616/// case-by-case author choice. Writes are moot — the binds are `MS_RDONLY`.
1617///
1618/// `executable: false` (see [`LandlockGrant`]): this is the only grant covering whole host trees
1619/// the manifest never named, so it must enumerate them without becoming permission to run them.
1620///
1621/// Sealed-tier only: on `KernelFull` (where `scoped` runs) Landlock applies over the real host
1622/// filesystem, so `ReadDir` on `/usr` would expose host directory shape; under `KernelSealed` the
1623/// tree is a private read-only bind holding only what was staged, so enumerating it reveals
1624/// nothing. Empty on every other tier.
1625///
1626/// Pure and syscall-free (paths need not exist here — `apply_landlock_scope` skips any that fail
1627/// to open), so unit-testable on every platform.
1628pub(crate) fn resolve_sealed_runtime_landlock_grants(tier: EnforcementTier) -> Vec<LandlockGrant> {
1629    if tier != EnforcementTier::KernelSealed {
1630        return Vec::new();
1631    }
1632    crate::sealed::SEALED_RUNTIME_PATHS
1633        .iter()
1634        .map(|path| LandlockGrant {
1635            path: PathBuf::from(path),
1636            list_dir: true,
1637            executable: false,
1638            pinned: None,
1639        })
1640        .collect()
1641}
1642
1643/// One read [`LandlockGrant`] per [`crate::sealed::SEALED_ETC_PATHS`] entry, and only on
1644/// `KernelSealed`.
1645///
1646/// Exactly the same shape of bug as [`resolve_sealed_runtime_landlock_grants`], one directory over:
1647/// `plan_composed_root` bind-mounts the fixed `/etc` allowlist read-only into every composed root,
1648/// and the Landlock ruleset installed inside that root then denies every one of them, because a
1649/// path with no matching rule is denied. The mount is present and unopenable — the worst of both
1650/// answers. Most visibly this is the TLS trust store: `load_verify_locations` on
1651/// `/etc/ssl/certs/ca-certificates.crt` returns `EACCES`, so `pip`, `curl`, `wget` and `git` all
1652/// fail certificate verification over an otherwise-working TCP connection.
1653///
1654/// `list_dir` is per entry, taken from the `directory` bit each [`crate::sealed::SEALED_EtcPath`]
1655/// carries (see [`crate::sealed::SealedEtcPath`]) — unlike the runtime-path list, this one mixes
1656/// directories, regular files and symlinks.
1657/// Directories get `ReadDir` because enumeration is how the trust store is actually consumed
1658/// (`c_rehash` symlink lookup, `os.listdir('/etc/ssl/certs')`, `SSL_CERT_DIR`); files and symlinks
1659/// get `ReadFile` only, the same convention as [`LandlockGrant::non_listable_files`].
1660///
1661/// `executable: false` throughout (see [`LandlockGrant`] for what `Execute` decides). Nothing in
1662/// `/etc` is a program a manifest asked to run, and `/etc/alternatives` in particular is a
1663/// directory of symlinks *into* `/usr/bin`, so granting `Execute` here would be a second,
1664/// undeclared route to the exec bypass `resolve_sealed_runtime_landlock_grants` withholds it to
1665/// prevent.
1666///
1667/// No write right, ever: the binds are `MS_RDONLY`, and `/etc/resolv.conf` or `/etc/hosts` becoming
1668/// writable inside a capsule would be a name-resolution hijack of the capsule's own egress.
1669///
1670/// Sealed-tier only, for the same reason as the runtime-path grant: on `KernelFull` — the tier a
1671/// `scoped` capsule runs on — Landlock applies over the real host `/etc`, where these rules would
1672/// hand out the host's own trust store, `resolv.conf` and account databases. Under `KernelSealed`
1673/// the paths resolve inside a private read-only bind of an allowlist that was curated precisely so
1674/// reading it reveals nothing sensitive. Empty on every other tier.
1675/// The trust store alone is granted on every Landlock tier, under the network gate, by
1676/// [`resolve_trust_store_grants`].
1677///
1678/// Pure and syscall-free (paths need not exist here — `apply_landlock_scope` skips any that fail to
1679/// open), so unit-testable on every platform.
1680pub(crate) fn resolve_sealed_etc_landlock_grants(tier: EnforcementTier) -> Vec<LandlockGrant> {
1681    if tier != EnforcementTier::KernelSealed {
1682        return Vec::new();
1683    }
1684    crate::sealed::SEALED_ETC_PATHS
1685        .iter()
1686        .map(|entry| LandlockGrant {
1687            path: PathBuf::from(entry.path),
1688            list_dir: entry.directory,
1689            executable: false,
1690            pinned: None,
1691        })
1692        .collect()
1693}
1694
1695// ---- fixed capsule device set ------------------------------------------------------------
1696//
1697// Everything above derives its grants from the manifest: `shell.allow` binaries and their
1698// `DT_NEEDED` closure, `interpreter_runtime` directories. The device set below derives from
1699// nothing. It is a fixed property of the sandbox, like `linux_enforce::WORKDIR_ACCESS_RIGHTS`,
1700// present on every capsule that reaches `KernelFull` — there is no manifest key that adds a
1701// device, and none that takes one away.
1702//
1703// Why it has to exist at all: once `restrict_self()` lands, a path outside the workdir with no
1704// matching rule is denied, and `/dev/null` is outside every workdir. Programs treat `/dev/null`
1705// as infallible — `open("/dev/null")` failing is a case almost nothing handles — so without an
1706// explicit rule an ordinary tool dies in a way that reads as a runtime bug, not a policy denial.
1707
1708/// One fixed device path granted to every `KernelFull` capsule, with the access level it gets.
1709///
1710/// Deliberately *not* a [`LandlockGrant`]: that type means "an executable or library path the
1711/// manifest asked for, granted read(+list)+execute, never write". A device is a different intent
1712/// (I/O access to a character device, possibly writable) and a different lifetime (fixed at
1713/// compile time, not resolved per capsule), so it gets its own type rather than overloading
1714/// `list_dir` into a third meaning.
1715///
1716/// `writable` is the whole reason this type exists as more than a path list:
1717///
1718///   - `false` → `ReadFile` only. The device is readable and nothing more.
1719///   - `true` → `ReadFile | WriteFile`. Granted to exactly one path, `/dev/null`, which with the
1720///     session's temporary folder is one of the two writable paths outside the workdir.
1721///
1722/// Only `mod linux_enforce` consumes this outside of tests, so on a non-Linux build the type and
1723/// the constant below are dead — the same deliberate cross-platform exception the rest of this
1724/// module's Linux-only surface carries.
1725#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1726#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1727pub(crate) struct CapsuleDeviceGrant {
1728    pub(crate) path: &'static str,
1729    pub(crate) writable: bool,
1730}
1731
1732/// The complete device set a capsule's shell subprocess tree can reach outside its workdir.
1733/// Three entries, no more — `apply_landlock_scope` adds one `PathBeneath` rule per entry, and
1734/// every device path *not* listed here (`/dev/random`, `/dev/full`, `/dev/tty`, `/dev/sda`, …)
1735/// stays denied by the same mechanism that already denies every other unlisted path: with
1736/// `handle_access` declaring the full right-set of the ABI the ruleset is built at, "no rule" means
1737/// "denied", not "ungoverned".
1738///
1739///   - **`/dev/null` — read *and* write.** This is the one deliberate exception to "nothing
1740///     outside the workdir is writable". A read-only grant would be broken, not merely strict:
1741///     bare-host `strace` shows Python's `subprocess.DEVNULL` opening it `O_RDWR|O_CLOEXEC`, and a
1742///     shell `2>/dev/null` redirect opening it `O_WRONLY|O_CREAT`. Both fail without `WriteFile`,
1743///     and `subprocess.DEVNULL` is reachable from any Python tool a capsule might run. Granting
1744///     write on a character device whose write side is defined to discard everything gives up no
1745///     confidentiality and no integrity — there is no state behind it to reach.
1746///     (The `O_CREAT` in the redirect costs nothing extra: Landlock only checks `MakeReg` when a
1747///     file is actually created, and `/dev/null` already exists.)
1748///   - **`/dev/zero` — read only.** Zero-fill reads and `MAP_PRIVATE` anonymous-mapping fallbacks
1749///     in older allocators. Writes to it are discarded like `/dev/null`'s, but nothing needs to
1750///     write there, so it does not get `WriteFile`.
1751///   - **`/dev/urandom` — read only.** Not for `getrandom(2)`, which is a syscall needing no
1752///     filesystem grant at all, but because OpenSSL and older glibc paths still `open()` the
1753///     device outright and fall over if they cannot.
1754///
1755/// **Why `/dev/random` is excluded.** Not because writing to it is dangerous: a plain `write()` to
1756/// `/dev/random` mixes bytes into the pool but credits **zero** entropy — crediting requires the
1757/// `RNDADDENTROPY` ioctl, which requires `CAP_SYS_ADMIN`, which the shell child has already
1758/// dropped. It is excluded for a duller reason: since Linux 5.6 `/dev/random` blocks until the CRNG
1759/// is initialized and `/dev/urandom` does not, and no workload needs the blocking variant when the
1760/// non-blocking one is granted. It stays out because nothing has demonstrated a need for it.
1761///
1762/// Widening this list is allowed but must be evidence-driven: add a fourth entry only after a real
1763/// workload has been observed failing on the missing device, and record that failure in the same
1764/// place this list is documented (`docs/content/reference/capsule-device-set-manual-verification.md`).
1765///
1766/// **Future shape.** Enumerating devices path by path is the `scoped`-containment-class answer.
1767/// Once a `sealed` class exists, the capsule gets a private `/dev` tmpfs carrying the OCI default
1768/// device set, and device access stops being a Landlock rule list entirely. A `sealed`
1769/// implementation should *retire* this constant, not merge with it.
1770#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1771pub(crate) const CAPSULE_DEVICE_GRANTS: &[CapsuleDeviceGrant] = &[
1772    CapsuleDeviceGrant {
1773        path: "/dev/null",
1774        writable: true,
1775    },
1776    CapsuleDeviceGrant {
1777        path: "/dev/zero",
1778        writable: false,
1779    },
1780    CapsuleDeviceGrant {
1781        path: "/dev/urandom",
1782        writable: false,
1783    },
1784];
1785
1786/// Network decision for one destination IP.
1787/// Empty allowlist denies everything (a capsule that declared no `network.allow` hosts has
1788/// no reason to open any subprocess socket).
1789#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1790pub(crate) fn network_ip_allowed(ip: IpAddr, network_allow_ips: &[IpAddr]) -> bool {
1791    network_allow_ips.contains(&ip)
1792}
1793
1794// `socket(2)`'s `domain` argument values, as Linux's ABI defines them. Spelled out as literals
1795// rather than `libc::AF_*`: the filter these feed is
1796// always compiled *for a Linux child*, so the numbers must be Linux's regardless of what host the
1797// build runs on — and `denied_socket_domains` has to stay compilable and unit-testable on a macOS
1798// non-Linux targets, where `libc::AF_NETLINK` and `libc::AF_PACKET` do not exist at all (the `libc`
1799// crate defines them only under `linux_like`). `libc::AF_UNIX` does exist on macOS, but taking one
1800// of the three from `libc` and two from literals would be worse than taking all three the same
1801// way. Values are stable kernel ABI: `include/linux/socket.h`.
1802const LINUX_AF_UNIX: i32 = 1;
1803const LINUX_AF_INET: i32 = 2;
1804const LINUX_AF_INET6: i32 = 10;
1805const LINUX_AF_NETLINK: i32 = 16;
1806const LINUX_AF_PACKET: i32 = 17;
1807
1808/// The `socket(2)` domains the child's seccomp filter refuses outright, given the capsule's
1809/// `capabilities.network.unix_sockets` declaration.
1810///
1811/// This is the *whole* policy decision behind the `socket()` rule in `install_seccomp_filter` —
1812/// deliberately pure, `libseccomp`-free, and platform-independent so it can be tested on any dev
1813/// machine (same split as `resolve_landlock_grants` vs. `apply_landlock_scope`).
1814///
1815/// Why a domain check is a classic BPF rule and not a notify:
1816/// `socket()`'s `domain` is a plain integer in a register, so the kernel's own BPF can compare it
1817/// directly. That is exactly what `connect()`'s destination address is *not* — it sits behind a
1818/// pointer BPF cannot dereference, so deciding on it needs a userspace round-trip that reads the
1819/// calling task's memory (see this module's header). Denying the domain at *creation* time needs
1820/// no round-trip, and is structurally immune to the TOCTOU class of problem that reading a
1821/// pointed-to argument out of another task's memory invites.
1822///
1823/// The three families, and why they are not symmetric:
1824///   - `AF_UNIX` is gated, not banned. `/var/run/docker.sock` is host root, so it cannot be open
1825///     by default — but "talk to a local daemon socket" is a legitimate thing for an agent to
1826///     need, so a capsule can declare `capabilities.network.unix_sockets: true` and take it back.
1827///     Note this is coarse: the opt-in is per *capsule*, per *domain* — not per socket path.
1828///   - `AF_NETLINK` (routing tables, interface and firewall state) and `AF_PACKET` (raw frame
1829///     capture and injection) are denied unconditionally, with no manifest key to widen them.
1830///     Neither has a shell-tool use case comparable to a local daemon socket, and both are
1831///     strictly more dangerous than one. Widening them would be a deliberate future decision with
1832///     its own justification, not an oversight to be fixed by adding a flag here.
1833///
1834/// `AF_INET`/`AF_INET6` are never denied here: they stay governed by the capsule's own network
1835/// namespace and egress proxy against `capabilities.network.allow`. This function only ever
1836/// *adds* denials, and is paired with [`allowed_socket_domains`], which names the families that
1837/// get a positive rule — since the filter's default action is a deny, a family named by neither
1838/// function is refused with the filter default (`EPERM`) rather than this rule's `EACCES`.
1839#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1840pub(crate) fn denied_socket_domains(unix_sockets_allowed: bool) -> Vec<i32> {
1841    let mut denied = vec![LINUX_AF_NETLINK, LINUX_AF_PACKET];
1842    if !unix_sockets_allowed {
1843        denied.push(LINUX_AF_UNIX);
1844    }
1845    denied
1846}
1847
1848/// The `socket(2)` domains the child's seccomp filter permits, given the capsule's
1849/// `capabilities.network.unix_sockets` declaration. The exact complement of
1850/// [`denied_socket_domains`] over the families anything in a shell toolchain actually opens.
1851///
1852/// This exists because the filter's default action is `Errno(EPERM)`, not `Allow`: `socket` needs
1853/// a positive rule or no socket of any family could be created at all. It is deliberately *not*
1854/// expressed as one unconditional `Allow` rule on `socket` — see `install_seccomp_filter` for why
1855/// that would silently delete the [`denied_socket_domains`] rules — and deliberately not as a
1856/// "not any of the denied families" rule either, so the two functions stay a pair of disjoint,
1857/// equality-matched sets that a test can check against each other.
1858///
1859/// The cost of naming families positively is that a family in neither set (`AF_ALG`, `AF_VSOCK`,
1860/// `AF_BLUETOOTH`, `AF_XDP`, ...) is now refused rather than allowed. That is stricter than
1861/// Docker's default profile, which allows every family except `AF_ALG`/`AF_VSOCK`; no shell,
1862/// coreutils, git, compiler or interpreter workload opens one, and the whole point of a
1863/// default-deny filter is that an unenumerated case fails closed. Add a family here (with a
1864/// reason) if a real workload needs it.
1865#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1866pub(crate) fn allowed_socket_domains(unix_sockets_allowed: bool) -> Vec<i32> {
1867    let mut allowed = vec![LINUX_AF_INET_DOMAIN, LINUX_AF_INET6_DOMAIN];
1868    if unix_sockets_allowed {
1869        allowed.push(LINUX_AF_UNIX);
1870    }
1871    allowed
1872}
1873
1874/// `socket(2)` `domain` values for the two IP families, named apart from the bare `LINUX_AF_*`
1875/// numbers above so a rule's argument type reads as a domain rather than as a loose integer.
1876///
1877/// Literals from the same `include/linux/socket.h` list as their neighbours above, for the same
1878/// reason: the filter is always compiled for a Linux child.
1879#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1880const LINUX_AF_INET_DOMAIN: i32 = LINUX_AF_INET;
1881#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1882const LINUX_AF_INET6_DOMAIN: i32 = LINUX_AF_INET6;
1883
1884/// The syscalls the child's seccomp filter permits outright, modelled on the OCI/Docker default
1885/// seccomp profile (reconciled against `containerd`'s `contrib/seccomp/seccomp_default.go`, which
1886/// is the maintained source of the same list Docker ships).
1887///
1888/// The filter's **default action is a deny** (`Errno(EPERM)`), so this array is the whole of what
1889/// a shell subprocess may call, minus two carve-outs handled by rules rather than by name here:
1890///
1891///   - `socket` is absent: it carries argument-conditional rules (see [`denied_socket_domains`] /
1892///     [`allowed_socket_domains`]), and an unconditional `Allow` rule would discard them.
1893///
1894/// `execve`/`execveat` are ordinary allowed syscalls here: they permit only *reaching the kernel*,
1895/// where the Landlock domain decides the exec on the path it resolved itself (see this module's
1896/// header for why they no longer carry `Notify` rules). Permitting them is not optional — the
1897/// child's very first act after this filter loads is the `execve` that turns it into the tool
1898/// binary, so a denial here refuses every capsule outright.
1899///
1900/// Everything else is refused by the default action. Without this list `io_uring_setup`, `bpf`,
1901/// `userfaultfd`, `perf_event_open`, `open_by_handle_at`, `keyctl` and `ptrace` are all reachable
1902/// from a capsule shell on bare metal, while the same probe under Docker gets `EPERM` for each.
1903/// `io_uring` is the one that
1904/// matters most: it has historically bypassed LSM path hooks, so leaving it reachable undermines
1905/// the Landlock filesystem boundary rather than merely widening the syscall surface. See
1906/// [`SECCOMP_MUST_STAY_DENIED`] for the full set that must never be added back.
1907///
1908/// Names, not numbers: `libseccomp` resolves each one for the architecture the filter is being
1909/// built on. A name this host's `libseccomp` does not know (a syscall newer than the library) or
1910/// that does not exist on this architecture (`open`, `dup2`, `poll`, ... on aarch64) is skipped,
1911/// leaving that syscall denied by default — see `install_seccomp_filter`. That is why legacy
1912/// x86_64-only spellings sit next to their modern equivalents here: on x86_64 glibc uses `open`
1913/// and `stat`, on aarch64 it uses `openat` and `newfstatat`, and the same array serves both.
1914#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1915pub(crate) const SECCOMP_SYSCALL_ALLOWLIST: &[&str] = &[
1916    // ---- process / thread lifecycle ----
1917    // `execve`/`execveat` reach the kernel; the Landlock domain decides them. See this array's
1918    // doc comment for the mechanism that replaced their former `Notify` rules.
1919    "execve",
1920    "execveat",
1921    // `clone`/`clone3` are allowed *unconditionally*, which is this filter's one significant
1922    // widening relative to Docker: Docker masks `clone`'s namespace-creation flags with an
1923    // argument comparison and forces `clone3` to `ENOSYS` so glibc falls back to the masked
1924    // `clone`. Reproducing that faithfully is possible for `clone` but pointless in isolation:
1925    // `unshare`/`setns`/`mount`/`pivot_root`/`chroot` all stay denied, so a new namespace stays
1926    // largely inert.
1927    "clone",
1928    "clone3",
1929    "fork",
1930    "vfork",
1931    "exit",
1932    "exit_group",
1933    "wait4",
1934    "waitid",
1935    "kill",
1936    "tkill",
1937    "tgkill",
1938    "rt_sigaction",
1939    "rt_sigprocmask",
1940    "rt_sigreturn",
1941    "rt_sigsuspend",
1942    "rt_sigpending",
1943    "rt_sigtimedwait",
1944    "rt_sigqueueinfo",
1945    "rt_tgsigqueueinfo",
1946    "sigaltstack",
1947    // The kernel re-issues an interrupted, restartable syscall as `restart_syscall`; denying it
1948    // would break every `sleep`/`read` that takes a signal.
1949    "restart_syscall",
1950    "pause",
1951    "set_tid_address",
1952    "set_robust_list",
1953    "get_robust_list",
1954    // glibc 2.35+ registers a restartable sequence on every thread start.
1955    "rseq",
1956    "prctl",
1957    "arch_prctl",
1958    "getpid",
1959    "getppid",
1960    "gettid",
1961    "getpgrp",
1962    "getpgid",
1963    "setpgid",
1964    "getsid",
1965    "setsid",
1966    "getpriority",
1967    "setpriority",
1968    "ioprio_get",
1969    "ioprio_set",
1970    "sched_yield",
1971    "sched_getaffinity",
1972    "sched_setaffinity",
1973    "sched_getparam",
1974    "sched_setparam",
1975    "sched_getscheduler",
1976    "sched_setscheduler",
1977    "sched_getattr",
1978    "sched_setattr",
1979    "sched_get_priority_max",
1980    "sched_get_priority_min",
1981    "sched_rr_get_interval",
1982    "getcpu",
1983    "capget",
1984    "capset",
1985    "prlimit64",
1986    "getrlimit",
1987    "setrlimit",
1988    "getrusage",
1989    "getitimer",
1990    "setitimer",
1991    "times",
1992    "uname",
1993    "sysinfo",
1994    // pidfd process handles: they only ever address a process the caller could already signal.
1995    "pidfd_open",
1996    "pidfd_send_signal",
1997    // ---- memory ----
1998    "mmap",
1999    "munmap",
2000    "mprotect",
2001    "mremap",
2002    "madvise",
2003    "brk",
2004    "mlock",
2005    "mlock2",
2006    "munlock",
2007    "mlockall",
2008    "munlockall",
2009    "membarrier",
2010    "msync",
2011    "mincore",
2012    "memfd_create",
2013    "pkey_alloc",
2014    "pkey_free",
2015    "pkey_mprotect",
2016    "shmget",
2017    "shmat",
2018    "shmdt",
2019    "shmctl",
2020    // ---- file I/O ----
2021    "open",
2022    "openat",
2023    "openat2",
2024    "creat",
2025    "close",
2026    "close_range",
2027    "read",
2028    "write",
2029    "pread64",
2030    "pwrite64",
2031    "readv",
2032    "writev",
2033    "preadv",
2034    "pwritev",
2035    "preadv2",
2036    "pwritev2",
2037    "lseek",
2038    "dup",
2039    "dup2",
2040    "dup3",
2041    "fcntl",
2042    "flock",
2043    "fsync",
2044    "fdatasync",
2045    "sync",
2046    "syncfs",
2047    "sync_file_range",
2048    "fadvise64",
2049    "readahead",
2050    "truncate",
2051    "ftruncate",
2052    "fallocate",
2053    "stat",
2054    "fstat",
2055    "lstat",
2056    "newfstatat",
2057    "statx",
2058    "access",
2059    "faccessat",
2060    "faccessat2",
2061    "getdents",
2062    "getdents64",
2063    "getcwd",
2064    "chdir",
2065    "fchdir",
2066    "mkdir",
2067    "mkdirat",
2068    "rmdir",
2069    "unlink",
2070    "unlinkat",
2071    "rename",
2072    "renameat",
2073    "renameat2",
2074    "link",
2075    "linkat",
2076    "symlink",
2077    "symlinkat",
2078    "readlink",
2079    "readlinkat",
2080    "chmod",
2081    "fchmod",
2082    "fchmodat",
2083    // glibc 2.39+ reaches for `fchmodat2` first; it has no `EPERM` fallback path, only an
2084    // `ENOSYS` one, so denying it would surface as a hard `chmod` failure.
2085    "fchmodat2",
2086    "chown",
2087    "fchown",
2088    "fchownat",
2089    "lchown",
2090    "umask",
2091    "utime",
2092    "utimes",
2093    "utimensat",
2094    "futimesat",
2095    // `mknod`/`mknodat` are allowed at the syscall layer and denied where the actual escape
2096    // lives: the Landlock workdir grant withholds `MakeChar`/`MakeBlock`/`MakeSock` (see
2097    // `linux_enforce::WORKDIR_ACCESS_RIGHTS`) and the child drops `CAP_MKNOD` before execve.
2098    "mknod",
2099    "mknodat",
2100    "sendfile",
2101    "copy_file_range",
2102    "splice",
2103    "tee",
2104    "vmsplice",
2105    "ioctl",
2106    "poll",
2107    "ppoll",
2108    "select",
2109    "pselect6",
2110    "epoll_create",
2111    "epoll_create1",
2112    "epoll_ctl",
2113    "epoll_wait",
2114    "epoll_pwait",
2115    "epoll_pwait2",
2116    "eventfd",
2117    "eventfd2",
2118    "pipe",
2119    "pipe2",
2120    "inotify_init",
2121    "inotify_init1",
2122    "inotify_add_watch",
2123    "inotify_rm_watch",
2124    "signalfd",
2125    "signalfd4",
2126    "timerfd_create",
2127    "timerfd_settime",
2128    "timerfd_gettime",
2129    "getxattr",
2130    "lgetxattr",
2131    "fgetxattr",
2132    "setxattr",
2133    "lsetxattr",
2134    "fsetxattr",
2135    "listxattr",
2136    "llistxattr",
2137    "flistxattr",
2138    "removexattr",
2139    "lremovexattr",
2140    "fremovexattr",
2141    "statfs",
2142    "fstatfs",
2143    // Harmless without its partner: `open_by_handle_at` (the syscall that turns a handle back
2144    // into an open file, ignoring the path it was reached by) stays denied.
2145    "name_to_handle_at",
2146    // Legacy POSIX AIO. Unlike `io_uring` it operates only on already-open fds and performs no
2147    // path resolution of its own, so it carries none of the LSM-bypass property that got the
2148    // `io_uring_*` family denied.
2149    "io_setup",
2150    "io_destroy",
2151    "io_submit",
2152    "io_cancel",
2153    "io_getevents",
2154    // ---- sockets ----
2155    // `socket` is NOT here (argument-conditional rules — see this array's doc comment).
2156    //
2157    // `connect`/`sendto` ARE here: allowing them permits only *reaching the kernel*, where the
2158    // capsule's own network namespace has no route to anything except the egress proxy. The
2159    // destination check lives there, not in this filter — see `crate::network_namespace`.
2160    "connect",
2161    "sendto",
2162    "socketpair",
2163    "bind",
2164    "listen",
2165    "accept",
2166    "accept4",
2167    "getsockname",
2168    "getpeername",
2169    "getsockopt",
2170    "setsockopt",
2171    "shutdown",
2172    "sendmsg",
2173    "sendmmsg",
2174    "recvfrom",
2175    "recvmsg",
2176    "recvmmsg",
2177    // ---- time ----
2178    "clock_gettime",
2179    "clock_getres",
2180    "clock_nanosleep",
2181    "nanosleep",
2182    "gettimeofday",
2183    "time",
2184    "timer_create",
2185    "timer_settime",
2186    "timer_gettime",
2187    "timer_getoverrun",
2188    "timer_delete",
2189    "alarm",
2190    // ---- System V / POSIX IPC ----
2191    "msgget",
2192    "msgsnd",
2193    "msgrcv",
2194    "msgctl",
2195    "semget",
2196    "semop",
2197    "semctl",
2198    "semtimedop",
2199    "mq_open",
2200    "mq_unlink",
2201    "mq_timedsend",
2202    "mq_timedreceive",
2203    "mq_notify",
2204    "mq_getsetattr",
2205    // ---- misc ----
2206    "getrandom",
2207    "futex",
2208    "futex_waitv",
2209    "getuid",
2210    "geteuid",
2211    "getgid",
2212    "getegid",
2213    "setuid",
2214    "setgid",
2215    "setreuid",
2216    "setregid",
2217    "setresuid",
2218    "setresgid",
2219    "getresuid",
2220    "getresgid",
2221    "getgroups",
2222    "setgroups",
2223    "setfsuid",
2224    "setfsgid",
2225];
2226
2227/// The syscalls that must **never** appear in [`SECCOMP_SYSCALL_ALLOWLIST`], with the reason each
2228/// one is out. A `#[test]` asserts the two lists stay disjoint, so re-permitting one of these can
2229/// only ever be a deliberate edit to *both* lists rather than an unnoticed line added to the
2230/// allowlist while reconciling it against a newer upstream profile.
2231///
2232/// Three groups, and they are not equally negotiable:
2233///
2234///   1. **Demonstrated reachable** — a probe reached these on bare metal while Docker refused
2235///      them: `io_uring_setup`/`io_uring_enter`/
2236///      `io_uring_register` (historically bypasses LSM path hooks, so it is a candidate route
2237///      around Landlock itself), `bpf`, `userfaultfd`, `perf_event_open`, `open_by_handle_at`,
2238///      `keyctl`/`add_key`/`request_key`, `process_vm_readv`/`process_vm_writev`, and `ptrace`
2239///      (which also has an availability dimension: a probe left wedged in `ptrace_stop` blocked
2240///      `mur` in `futex_wait` for twenty minutes). Docker's default profile actually *allows*
2241///      `ptrace`/`process_vm_readv`/`process_vm_writev` on kernels ≥ 4.8; this filter is
2242///      deliberately stricter there.
2243///   2. **Privileged / host-state operations** with no shell-toolchain use case: namespace and
2244///      mount manipulation, module loading, reboot, swap, accounting, quota, raw port I/O,
2245///      clock setting, NUMA policy, `fanotify`.
2246///   3. **Denied instead of argument-conditioned.** Docker allows these only for specific
2247///      argument values, which a per-syscall-name allowlist cannot express and which would
2248///      otherwise have to be routed through a userspace decision this filter no longer makes.
2249///      Omitting them outright costs nothing, because nothing in bash, coreutils, git, or a
2250///      standard compiler/interpreter toolchain needs them: `personality`, `kcmp`, `seccomp`
2251///      itself (a nested filter cannot loosen this one, but it can slow it down for no benefit).
2252// The list is a specification, not an input to the filter build: its one consumer is the
2253// regression test asserting no entry ever reappears in the allowlist. Deleting it would delete
2254// the thing that test checks against.
2255#[allow(dead_code)]
2256pub(crate) const SECCOMP_MUST_STAY_DENIED: &[&str] = &[
2257    // 1. demonstrated reachable on bare metal
2258    "io_uring_setup",
2259    "io_uring_enter",
2260    "io_uring_register",
2261    "bpf",
2262    "userfaultfd",
2263    "perf_event_open",
2264    "process_vm_readv",
2265    "process_vm_writev",
2266    "open_by_handle_at",
2267    "keyctl",
2268    "add_key",
2269    "request_key",
2270    "ptrace",
2271    // 2. privileged / host-state
2272    "mount",
2273    "umount2",
2274    "pivot_root",
2275    "chroot",
2276    "unshare",
2277    "setns",
2278    "init_module",
2279    "finit_module",
2280    "delete_module",
2281    "kexec_load",
2282    "kexec_file_load",
2283    "reboot",
2284    "swapon",
2285    "swapoff",
2286    "acct",
2287    "quotactl",
2288    "nfsservctl",
2289    "iopl",
2290    "ioperm",
2291    "vm86",
2292    "vm86old",
2293    "_sysctl",
2294    "sysfs",
2295    "uselib",
2296    "create_module",
2297    "get_kernel_syms",
2298    "query_module",
2299    "lookup_dcookie",
2300    "clock_adjtime",
2301    "clock_settime",
2302    "settimeofday",
2303    "stime",
2304    "adjtimex",
2305    "set_mempolicy",
2306    "get_mempolicy",
2307    "mbind",
2308    "migrate_pages",
2309    "move_pages",
2310    "fanotify_init",
2311    "fanotify_mark",
2312    // 3. denied instead of argument-conditioned
2313    "seccomp",
2314    "personality",
2315    "kcmp",
2316];
2317
2318/// The largest BPF program `linux_enforce::build_seccomp_filter` may compile to before this
2319/// project treats the filter as having outgrown its headroom. Held against the *measured* export
2320/// of the real filter by a test, not against an estimate: growing
2321/// [`SECCOMP_SYSCALL_ALLOWLIST`] past this fails `cargo test` with the measured count in the
2322/// message, instead of surfacing as a spawn that dies inside `pre_exec` on some host.
2323///
2324/// Set well below [`BPF_MAXINSNS_CEILING`] on purpose. It is a project budget, not a kernel one:
2325/// crossing it is a prompt to re-measure and decide, not evidence that the kernel would refuse.
2326/// `docs/content/reference/seccomp-filter-instruction-budget.md` records the measurement this
2327/// number was chosen against.
2328#[allow(dead_code)]
2329pub(crate) const SECCOMP_FILTER_INSTRUCTION_BUDGET: usize = 512;
2330
2331/// The kernel's `BPF_MAXINSNS` — the most instructions a *single* seccomp filter may contain.
2332/// A longer program is refused by `seccomp(SECCOMP_SET_MODE_FILTER)` with `EINVAL`, before the
2333/// filter is attached to anything. Measured rather than quoted: a test brackets it by loading
2334/// two filters that straddle it in forked children.
2335#[allow(dead_code)]
2336pub(crate) const BPF_MAXINSNS_CEILING: usize = 4096;
2337
2338/// The kernel's `MAX_INSNS_PER_PATH` — the most instructions the *whole chain* of filters
2339/// attached to one task may total, counting every filter it already carries plus a small
2340/// per-filter accounting overhead. An attach that would cross it is refused with `ENOMEM`,
2341/// which is the only seccomp path in the kernel that returns that errno. Measured rather than
2342/// quoted: a test stacks filters in a forked child until the refusal arrives.
2343#[allow(dead_code)]
2344pub(crate) const SECCOMP_MAX_INSNS_PER_PATH: usize = 32768;
2345
2346/// Bundles the resolved, host-independent enforcement inputs for one capsule session.
2347#[derive(Debug, Clone)]
2348pub(crate) struct ShellEnforcement {
2349    pub(crate) tier: EnforcementTier,
2350    /// Every `capabilities.network.allow` host resolved to concrete addresses once, at launch,
2351    /// by [`resolve_network_allowlist_ips`].
2352    ///
2353    /// The narrower of the egress proxy's two checks — the one applied to a destination the
2354    /// capsule reached without ever resolving a name for it (a hardcoded literal). See
2355    /// `egress_proxy::EgressPolicy::allows_connection`, which consults exactly this list through
2356    /// [`network_ip_allowed`].
2357    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2358    pub(crate) network_allow_ips: Vec<IpAddr>,
2359    /// The same allowlist in its *parsed*, name-keyed form — the ordinary path. The egress proxy
2360    /// checks a name when it resolves it and again when the connection to the address that name
2361    /// produced is accepted, which is what a resolved-IP set alone cannot express (one address,
2362    /// many tenants) in either direction.
2363    ///
2364    /// The same `NetworkAllowRule` type and the same parser the WASI-HTTP path uses, so a manifest
2365    /// entry cannot mean one thing to a WASM guest and another to a subprocess.
2366    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2367    pub(crate) network_allow_rules: Vec<crate::network_policy::NetworkAllowRule>,
2368    /// The TCP ports the capsule's network namespace opens listeners on, derived from the
2369    /// allowlist by `egress_proxy::egress_listen_ports`. A port no allow entry implies gets no
2370    /// listener at all, so a connection to it is refused by the kernel with nothing in userspace
2371    /// consulted — the strongest form the refusal can take.
2372    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2373    pub(crate) egress_tcp_ports: Vec<u16>,
2374    /// `capabilities.network.unix_sockets`, threaded through to the seccomp `socket()` rule.
2375    /// `false` (the default) means the forked child cannot create an `AF_UNIX` socket at all, so
2376    /// a local daemon socket — `/var/run/docker.sock` above all — is unreachable regardless of
2377    /// what any Landlock ABI does or does not mediate for pathname sockets. Consumed only by
2378    /// `linux_enforce::install_seccomp_filter` (via `denied_socket_domains`); resolved on every
2379    /// platform for parity but never read off Linux.
2380    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2381    pub(crate) unix_sockets_allowed: bool,
2382    /// `capabilities.filesystem.workdir_exec`, threaded through to the workdir's own Landlock
2383    /// rule. `false` (the default) withholds the `Execute` right from it, which is what makes
2384    /// `shell_allow` enforceable at all now that the exec supervisor is gone: nothing the capsule
2385    /// writes into its workdir can run, under any name. Consumed only by
2386    /// `linux_enforce::workdir_access_rights` on the Landlock tiers; resolved on every platform
2387    /// for parity but never read off Linux.
2388    ///
2389    /// It has no seccomp counterpart on `KernelSeccompOnly` — without a Landlock domain there is
2390    /// nothing to withhold — which is part of why that tier cannot reach `scoped`.
2391    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2392    pub(crate) workdir_exec: bool,
2393    /// The Landlock ABI the child's ruleset is built at: [`ruleset_landlock_abi`] of the session's
2394    /// [`HostProbe`] and [`Self::tier`], decided here so the ABI `session_start` records is the one
2395    /// installed. `None` on the tiers with no Landlock domain, and off Linux always.
2396    ///
2397    /// On `KernelFull`/`KernelSealed` a `None` builds at ABI v1, the narrowest set, so a missing
2398    /// reading can only narrow what the capsule may do. `Some(1)` likewise reproduces exactly
2399    /// what a 5.13–5.18 kernel installs, which is how the tests force that ruleset on a newer one.
2400    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2401    pub(crate) landlock_abi: Option<u8>,
2402    /// Narrow read (never write) Landlock grants *outside* the workdir so the allowlisted
2403    /// binaries can actually exec, dynamically link, and (for a path-based interpreter) reach their
2404    /// stdlib. Seven origins, combined here:
2405    ///
2406    ///   - the `DT_NEEDED`-closure files (`shell.allow` binaries, their ELF interpreter, their
2407    ///     shared-library closure), from `resolve_landlock_grants` — each wrapped `list_dir: false`
2408    ///     (they are individual files, where `ReadDir` was always a no-op);
2409    ///   - one grant per `capabilities.shell.interpreter_runtime` directory, from
2410    ///     `resolve_interpreter_runtime_grants` — each carrying exactly the `list_dir` its author
2411    ///     declared;
2412    ///   - one non-executable grant per entry a recognised interpreter reported on its import
2413    ///     path at staging, and per library its extension modules need, from
2414    ///     `resolve_derived_interpreter_grants` — listable for a directory;
2415    ///   - one non-executable read grant per host trust-store path staging found for a capsule
2416    ///     with a non-empty `shell.allow` and `network.allow`, from `resolve_trust_store_grants` —
2417    ///     listable for a directory;
2418    ///   - one listable grant per `capabilities.shell.staged_runtime` tree, from
2419    ///     `resolve_staged_runtime_landlock_grants`;
2420    ///   - on `KernelSealed` **only**, one listable but *non-executable* grant per fixed
2421    ///     [`crate::sealed::SEALED_RUNTIME_PATHS`] entry, from
2422    ///     `resolve_sealed_runtime_landlock_grants` — the composed root's own bind-mounted runtime
2423    ///     tree, which nothing else grants;
2424    ///   - on `KernelSealed` **only**, one non-executable read grant per fixed
2425    ///     [`crate::sealed::SEALED_ETC_PATHS`] entry, from `resolve_sealed_etc_landlock_grants`
2426    ///     (`list_dir` per entry, since that list mixes directories and files) — the composed
2427    ///     root's `/etc` allowlist, likewise granted by nothing else.
2428    ///
2429    /// The closure, `interpreter_runtime` and `staged_runtime` grants carry `executable: true`;
2430    /// the derived interpreter grants, the trust-store grants and the two tier-issued ones do not,
2431    /// and that asymmetry is load-bearing — see [`LandlockGrant`].
2432    ///
2433    /// Resolved once at launch (in the parent) and threaded into the forked child's `pre_exec`,
2434    /// where `apply_landlock_scope` turns each into a per-path `PathBeneath` rule with an access
2435    /// set that depends on `list_dir` and `executable`. Only consulted on `KernelFull` and
2436    /// `KernelSealed`; resolved on every platform for parity but never read off Linux.
2437    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2438    pub(crate) landlock_grants: Vec<LandlockGrant>,
2439    /// Host directories this capsule needs bind-mounted read-only into its composed root *beyond*
2440    /// the fixed [`crate::sealed::SEALED_RUNTIME_PATHS`]: the containing directory of each
2441    /// resolved `shell.allow` binary, and each `capabilities.shell.interpreter_runtime` directory.
2442    ///
2443    /// Whole directories, never files: a bind carries a directory's whole contents, so this must
2444    /// not grow into a second ELF-closure derivation. Consumed only on `KernelSealed`; resolved on
2445    /// every platform for parity, exactly like `landlock_grants` above.
2446    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2447    pub(crate) sealed_bind_dirs: Vec<PathBuf>,
2448    /// The `source_path` of each declared `capabilities.shell.staged_runtime` grant, bind-mounted
2449    /// read-only into the composed root at its own absolute path.
2450    ///
2451    /// Separate from `sealed_bind_dirs` above because the failure semantics are opposite, and that
2452    /// is the capability: `sealed_bind_dirs` shrinks the root when the host is missing something,
2453    /// while a staged tree that cannot be mounted aborts the composed-root construction before
2454    /// `pivot_root`, which surfaces as `RuntimeError::SealedRootConstructionFailed` (`E-RUN-014`)
2455    /// and is session-fatal. A capsule never gets a shell tool call inside a root missing a
2456    /// runtime tree it declared. Consumed only on `KernelSealed`; resolved on every platform for
2457    /// parity, exactly like the two fields above.
2458    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2459    pub(crate) staged_runtime_dirs: Vec<PathBuf>,
2460    /// Fully-resolved OS-level bounds for every subprocess this session spawns — `rlimit(2)`
2461    /// ceilings applied before `execve`, plus the values the cgroup scope below was built from.
2462    /// Unlike everything above it, this is enforced on **every** platform: `setrlimit` is POSIX,
2463    /// so macOS gets the per-process bounds even though it can never get the aggregate (cgroup)
2464    /// half. See [`crate::resources`].
2465    pub(crate) resource_limits: crate::resources::HostResourceLimits,
2466    /// The manifest's own `capabilities.resources` block, every field as declared. The
2467    /// [`EnforcementTier::Unconfined`] tier sets an rlimit only for a field declared here, where
2468    /// every other tier sets [`Self::resource_limits`].
2469    pub(crate) declared_resources: murmur_artifact::ResourceCapabilities,
2470    /// The session's cgroup v2 scope, when the host could delegate one (Linux only, and only
2471    /// for capsules that can actually spawn a native subprocess). `None` on macOS always, and on
2472    /// Linux only for capsules with no subprocess capability at all — a Linux capsule that *can*
2473    /// spawn one and could not be given a scope never reaches here, because the launch is
2474    /// refused first with `RuntimeError::CgroupDelegationUnavailable`.
2475    ///
2476    /// Its presence decides each spawn's `RLIMIT_NPROC` ([`Self::nproc_bound`]): a spawn that
2477    /// joins it is bounded by `pids.max` alone. A native tool can still be dispatched on a
2478    /// session whose policy required no scope, so a spawn path never assumes one is here.
2479    pub(crate) cgroup_scope: Option<Arc<crate::cgroup::CgroupScope>>,
2480    /// The session's periodic workdir-size check. Consulted before every subprocess spawn, so a
2481    /// disk filler stops writing at the first spawn after the ceiling is crossed rather than
2482    /// only at the next agent turn.
2483    pub(crate) workdir_guard: Option<Arc<crate::resources::WorkdirGuard>>,
2484    /// The session's one network namespace, which every command on a kernel tier joins.
2485    ///
2486    /// Shared by every clone of this enforcement, so a listener one command starts is reachable
2487    /// from the next. A fresh, not-yet-created namespace from [`Self::resolve`] and
2488    /// [`Self::environment_only`]; a plan inside a session takes its session's through
2489    /// [`Self::with_session_netns`]. Released by [`Self::end_session`].
2490    pub(crate) session_netns: Arc<crate::network_namespace::SessionNetns>,
2491    /// The session's runtime directory and its `sealed-etc/` sibling: `$MURMUR_RUNTIME_DIR` in
2492    /// every command's environment, a read-only Landlock rule on the Landlock tiers, a read-only
2493    /// bind on `KernelSealed`, and the source of the composed root's `/etc/passwd` and
2494    /// `/etc/group`. `None` outside a session, where a command is told of no runtime directory
2495    /// and a sealed root cannot be composed.
2496    pub(crate) runtime_dir: Option<crate::runtime_dir::RuntimeDirRoots>,
2497    /// The session's runtime directory itself, removed by [`Self::remove_runtime_dir`] on the
2498    /// process exits that run no destructor. Set only on the session's own enforcement.
2499    pub(crate) runtime_dir_owner: Option<crate::runtime_dir::SharedRuntimeDir>,
2500    /// How a literal `/tmp` reaches the session's temporary folder (`runtime_dir`'s `temp`):
2501    /// the session's [`crate::temp_dir::temp_mapping`] answer, through [`Self::with_temp_mapping`],
2502    /// or [`crate::temp_dir::tier_temp_mapping`] of [`Self::tier`] until one is handed in. Read
2503    /// only while the enforcement holds a folder.
2504    pub(crate) temp_mapping: crate::temp_dir::TempMapping,
2505    /// The durable store of the one native tool this enforcement spawns, set by
2506    /// [`Self::for_native_entrypoint`] on that spawn's clone alone; `None` on the session's own
2507    /// enforcement, which every shell command uses.
2508    ///
2509    /// On `KernelFull` and `KernelSealed` it gets a read-write Landlock rule without `Execute`,
2510    /// re-exposed writable behind a private `/tmp`, and on `KernelSealed` a verified read-write
2511    /// bind at its own absolute path, required like the home folder's. On the tiers with no
2512    /// filesystem scope nothing is added: the tool is handed the path in its environment and
2513    /// reaches it as every subprocess there reaches every host file.
2514    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2515    pub(crate) native_state_dir: Option<PathBuf>,
2516    /// The host folders `~/.murmur/config.yaml` lends this session ([`CapabilityPolicy::host_paths`]),
2517    /// in config order. On `KernelFull` and `KernelSealed` each gets a Landlock rule, `ReadFile |
2518    /// ReadDir` for `read` and the workdir's rights without `Execute` for `read-write`, taken on a
2519    /// descriptor opened at every spawn, fail-closed: a host path that can no longer be opened
2520    /// fails the spawn naming it. On `KernelSealed` each is also a required, verified bind at its
2521    /// own absolute path. Nothing is added on the tiers with no filesystem scope.
2522    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2523    pub(crate) host_paths: Vec<crate::host_paths::HostPath>,
2524    /// On `KernelSealed` only: every execute root (see [`host_path_execute_roots`]) inside a
2525    /// `read-write` host path, bound read-only and required on top of it so the capsule cannot
2526    /// rewrite what it may run. Empty on every other tier, where such an entry is refused at
2527    /// staging instead.
2528    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2529    pub(crate) host_path_read_only_binds: Vec<PathBuf>,
2530}
2531
2532impl ShellEnforcement {
2533    /// Resolves tier + network allowlist + canonical exec allowlist once, at launch time.
2534    ///
2535    /// The rlimit half of [`crate::resources`] is resolved here too (it needs nothing but the
2536    /// policy), while the cgroup scope and workdir guard are session-scoped live handles the
2537    /// caller creates and attaches with [`Self::with_host_bounding`].
2538    ///
2539    /// `decision` is the session's [`crate::containment::ContainmentDecision`], and its applied
2540    /// tier is the one installed: whether a sealed-capable host builds a composed root is decided
2541    /// there, from the declared floor, so the tier installed here is the one the session's trace
2542    /// names.
2543    ///
2544    /// `probe` is the reading `decision` was made from — for a session, the [`HostProbe`]
2545    /// `runtime::stage_session` took and carried on the `StagedSession` — and supplies the
2546    /// kernel's Landlock ABI.
2547    pub(crate) fn resolve(
2548        policy: &CapabilityPolicy,
2549        decision: &crate::containment::ContainmentDecision,
2550        probe: HostProbe,
2551    ) -> Result<Self, String> {
2552        let tier = decision.applied_tier();
2553        let network_allow_ips = resolve_network_allowlist_ips(&policy.network_allow)?;
2554        let network_allow_rules =
2555            crate::network_policy::parse_network_allow_rules(&policy.network_allow)
2556                .map_err(|error| error.to_string())?;
2557        let egress_tcp_ports = crate::egress_proxy::egress_listen_ports(&network_allow_rules);
2558        let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
2559        // The `DT_NEEDED`-closure files (individual files → non-listable), one grant per
2560        // author-declared `interpreter_runtime` directory (each with its own `list_dir`), and one
2561        // read-only grant per entry a recognised interpreter reported on its own import path at
2562        // staging. A directory neither the manifest names nor an allowlisted interpreter reports
2563        // never receives a rule, regardless of what it holds.
2564        let closure = resolve_landlock_grants(&exec_allow_paths);
2565        // Re-checked here, not only at staging: a plan step or a session check resolves its own
2566        // enforcement, and none may install a read-write host path over what it runs on
2567        // `KernelFull`. On `KernelSealed` the execute roots inside one come back to be bound
2568        // read-only on top of it.
2569        let host_path_read_only_binds = crate::host_paths::check_execute_roots(
2570            &policy.host_paths,
2571            &execute_roots_from(&closure, policy),
2572            host_path_mechanism(tier),
2573        )
2574        .map_err(|refusal| refusal.to_string())?;
2575        let mut landlock_grants = LandlockGrant::non_listable_files(closure);
2576        landlock_grants.extend(resolve_interpreter_runtime_grants(
2577            &policy.shell_interpreter_runtime,
2578        ));
2579        landlock_grants.extend(resolve_derived_interpreter_grants(
2580            &policy.shell_derived_interpreter_runtime,
2581        ));
2582        // Plus one read-only grant per host trust-store path staging found. Not tier-gated: the
2583        // network gate lives in the derivation, which leaves the field empty for a capsule whose
2584        // commands cannot reach the network.
2585        landlock_grants.extend(resolve_trust_store_grants(&policy.shell_trust_store));
2586        // Plus one per staged runtime tree. Without this the tree is bind-mounted into the
2587        // composed root and then denied by the Landlock ruleset installed inside it — see
2588        // `resolve_staged_runtime_landlock_grants`.
2589        landlock_grants.extend(resolve_staged_runtime_landlock_grants(policy));
2590        // Plus, on `KernelSealed` only, one listable grant per fixed `SEALED_RUNTIME_PATHS`
2591        // directory — the composed root binds them but Landlock inside the root still denies
2592        // enumerating them. Gated on `tier`, not on the manifest: on `KernelFull` (the tier a
2593        // `scoped` capsule runs on) the same grant would expose real host directory shape. The
2594        // resolver applies that gate itself and returns nothing on every other tier.
2595        landlock_grants.extend(resolve_sealed_runtime_landlock_grants(tier));
2596        // And, on `KernelSealed` only, one read grant per fixed `SEALED_ETC_PATHS` entry — the same
2597        // mounted-but-denied bug one directory over, and the reason TLS certificate verification
2598        // failed inside a composed root. Tier-gated by the resolver itself, exactly as above: on
2599        // `KernelFull` these rules would apply to the *host's* /etc.
2600        landlock_grants.extend(resolve_sealed_etc_landlock_grants(tier));
2601        let sealed_bind_dirs = resolve_sealed_bind_dirs(&exec_allow_paths, policy);
2602        let staged_runtime_dirs = resolve_staged_runtime_dirs(policy);
2603        Ok(Self {
2604            tier,
2605            network_allow_ips,
2606            network_allow_rules,
2607            egress_tcp_ports,
2608            unix_sockets_allowed: policy.unix_sockets_allowed,
2609            workdir_exec: policy.workdir_exec_allowed,
2610            landlock_abi: ruleset_landlock_abi(probe.landlock_kernel_abi(), tier),
2611            landlock_grants,
2612            sealed_bind_dirs,
2613            staged_runtime_dirs,
2614            resource_limits: policy.resources,
2615            declared_resources: policy.declared_resources,
2616            cgroup_scope: None,
2617            workdir_guard: None,
2618            session_netns: Arc::new(crate::network_namespace::SessionNetns::new()),
2619            runtime_dir: None,
2620            runtime_dir_owner: None,
2621            temp_mapping: crate::temp_dir::tier_temp_mapping(tier),
2622            native_state_dir: None,
2623            host_paths: policy.host_paths.clone(),
2624            host_path_read_only_binds,
2625        })
2626    }
2627
2628    /// [`Self::resolve`] for a capsule declaring `declared`, decided from `probe` with the
2629    /// runtime directory outside the workdir: the seam tests resolve an enforcement through.
2630    #[cfg(test)]
2631    pub(crate) fn resolve_declared(
2632        policy: &CapabilityPolicy,
2633        declared: murmur_artifact::ContainmentClass,
2634        probe: HostProbe,
2635    ) -> Result<Self, String> {
2636        let decision = crate::containment::ContainmentDecision::decide(
2637            probe.tier(),
2638            declared,
2639            policy.workdir_exec_allowed,
2640            false,
2641        );
2642        Self::resolve(policy, &decision, probe)
2643    }
2644
2645    /// Attach the session's runtime directory, and with `owner` the directory itself, so a
2646    /// process exit that runs no destructor removes it ([`Self::remove_runtime_dir`]).
2647    pub(crate) fn with_runtime_dir(
2648        mut self,
2649        roots: crate::runtime_dir::RuntimeDirRoots,
2650        owner: Option<crate::runtime_dir::SharedRuntimeDir>,
2651    ) -> Self {
2652        self.runtime_dir = Some(roots);
2653        self.runtime_dir_owner = owner;
2654        self
2655    }
2656
2657    /// The runtime directory's absolute path, the value of `$MURMUR_RUNTIME_DIR`.
2658    pub(crate) fn runtime_dir_path(&self) -> Option<&Path> {
2659        self.runtime_dir.as_ref().map(|roots| roots.runtime.path())
2660    }
2661
2662    /// The session's records directory, held. `None` for an enforcement held outside a session.
2663    pub(crate) fn records_root(&self) -> Option<&crate::workdir_fs::WorkdirRoot> {
2664        self.runtime_dir
2665            .as_ref()
2666            .and_then(|roots| roots.records.as_ref())
2667    }
2668
2669    /// The records directory's absolute path, the value of `$MURMUR_RECORDS_DIR`. `None` for an
2670    /// enforcement held outside a session.
2671    pub(crate) fn records_dir_path(&self) -> Option<&Path> {
2672        self.records_root()
2673            .map(crate::workdir_fs::WorkdirRoot::path)
2674    }
2675
2676    /// The session's temporary folder's absolute path. `None` for an enforcement held outside a
2677    /// session.
2678    pub(crate) fn temp_dir_path(&self) -> Option<&Path> {
2679        self.runtime_dir
2680            .as_ref()
2681            .and_then(|roots| roots.temp.as_ref())
2682            .map(crate::workdir_fs::WorkdirRoot::path)
2683    }
2684
2685    /// The session's home folder, `home/` in its records directory, held. `None` for an
2686    /// enforcement held outside a session, which runs no command.
2687    pub(crate) fn home_root(&self) -> Option<&crate::workdir_fs::WorkdirRoot> {
2688        self.runtime_dir
2689            .as_ref()
2690            .and_then(|roots| roots.home.as_ref())
2691    }
2692
2693    /// The home folder's absolute path, the value of `$HOME` and the synthetic passwd's home
2694    /// directory on every tier. `None` for an enforcement held outside a session.
2695    pub(crate) fn home_dir_path(&self) -> Option<&Path> {
2696        self.home_root().map(crate::workdir_fs::WorkdirRoot::path)
2697    }
2698
2699    /// Hand in the session's [`crate::temp_dir::temp_mapping`] answer, decided for this
2700    /// enforcement's tier.
2701    pub(crate) fn with_temp_mapping(mut self, mapping: crate::temp_dir::TempMapping) -> Self {
2702        self.temp_mapping = mapping;
2703        self
2704    }
2705
2706    /// What this enforcement installs for `/tmp` while it holds a folder: the composed root's bind
2707    /// on `KernelSealed`, a private mount namespace on `KernelFull` and `KernelSeccompOnly` when
2708    /// [`Self::temp_mapping`] says so, and nothing otherwise. Keyed on [`Self::tier`] as well, so
2709    /// no tier is ever told of a mechanism it does not install.
2710    pub(crate) fn installed_temp_mapping(&self) -> crate::temp_dir::TempMapping {
2711        use crate::temp_dir::TempMapping;
2712        match (self.tier, self.temp_mapping) {
2713            (
2714                EnforcementTier::KernelFull | EnforcementTier::KernelSeccompOnly,
2715                TempMapping::MountNamespace,
2716            ) => TempMapping::MountNamespace,
2717            (
2718                EnforcementTier::KernelFull | EnforcementTier::KernelSeccompOnly,
2719                TempMapping::NotMapped(reason),
2720            ) => TempMapping::NotMapped(reason),
2721            (tier, _) => crate::temp_dir::tier_temp_mapping(tier),
2722        }
2723    }
2724
2725    /// The value of `TMPDIR`, `TMP` and `TEMP` in every command this enforcement spawns: `/tmp`
2726    /// when a literal `/tmp` is the session's temporary folder, the folder's absolute path when
2727    /// not, and `None` when the enforcement holds no folder.
2728    pub(crate) fn tmpdir_value(&self) -> Option<String> {
2729        self.temp_dir_path()
2730            .map(|folder| self.installed_temp_mapping().tmpdir_value(folder))
2731    }
2732
2733    /// Removes the session's runtime directory now, ahead of a process exit that runs no
2734    /// destructor. Does nothing on an enforcement that does not own one.
2735    pub(crate) fn remove_runtime_dir(&self) {
2736        if let Some(owner) = &self.runtime_dir_owner {
2737            owner.remove();
2738        }
2739    }
2740
2741    /// Attach the session-scoped host-bounding handles: the cgroup v2 scope (Linux, when the
2742    /// capsule can spawn a subprocess) and the workdir-size guard (every platform).
2743    ///
2744    /// Separate from [`Self::resolve`] because both are *live* per-session resources with
2745    /// lifetimes and side effects — a directory under `/sys/fs/cgroup` and a running thread —
2746    /// while `resolve` is a pure resolution of manifest and host facts. The caller owns their
2747    /// creation so the fail-closed launch refusal can happen with a typed error before this is
2748    /// ever reached.
2749    pub(crate) fn with_host_bounding(
2750        mut self,
2751        cgroup_scope: Option<Arc<crate::cgroup::CgroupScope>>,
2752        workdir_guard: Option<Arc<crate::resources::WorkdirGuard>>,
2753    ) -> Self {
2754        self.cgroup_scope = cgroup_scope;
2755        self.workdir_guard = workdir_guard;
2756        self
2757    }
2758
2759    /// The `RLIMIT_NPROC` decision for one spawn, made in the parent before its `pre_exec`
2760    /// closure is installed.
2761    ///
2762    /// [`crate::resources::NprocBound::CgroupPidsMax`] when the spawn will join a scope that
2763    /// places it, because both closures that receive this run the join fatally before `execve`.
2764    /// Otherwise [`crate::resources::NprocBound::Headroom`], with a baseline measured by
2765    /// `crate::resources::uid_task_count` on this call — a `/proc` walk on Linux — so call it
2766    /// once per spawn and never from inside `pre_exec`.
2767    pub(crate) fn nproc_bound(&self) -> crate::resources::NprocBound {
2768        if self
2769            .cgroup_scope
2770            .as_ref()
2771            .is_some_and(|scope| scope.places_subprocesses())
2772        {
2773            crate::resources::NprocBound::CgroupPidsMax
2774        } else {
2775            crate::resources::NprocBound::Headroom {
2776                baseline: crate::resources::uid_task_count().unwrap_or(0),
2777            }
2778        }
2779    }
2780
2781    /// The latched workdir-size breach, if the guard has seen one.
2782    pub(crate) fn workdir_breach(&self) -> Option<crate::resources::WorkdirBreach> {
2783        self.workdir_guard.as_ref().and_then(|guard| guard.breach())
2784    }
2785
2786    /// Refuse to spawn once the workdir ceiling has been crossed.
2787    ///
2788    /// Called by both subprocess spawn paths before `Command::spawn()`. Stopping at the spawn
2789    /// boundary is what keeps a disk filler from writing another byte while the session unwinds:
2790    /// the periodic check notices the breach, and the very next subprocess never starts.
2791    pub(crate) fn check_workdir_budget(&self) -> Result<(), String> {
2792        match self.workdir_breach() {
2793            Some(breach) => Err(crate::resources::workdir_breach_message(breach)),
2794            None => Ok(()),
2795        }
2796    }
2797
2798    /// The permanent macOS/non-Linux value, and also what tests should construct directly to
2799    /// exercise `execute_shell` without depending on real kernel enforcement machinery. This
2800    /// is not a fake test-only bypass — it's the literal value real macOS hosts get. (Only
2801    /// test code calls this directly today — production always goes through `resolve()`,
2802    /// which independently arrives at the same tier value on non-Linux hosts.)
2803    #[cfg_attr(not(test), allow(dead_code))]
2804    pub(crate) fn environment_only() -> Self {
2805        Self {
2806            tier: EnforcementTier::EnvironmentOnly,
2807            network_allow_ips: Vec::new(),
2808            network_allow_rules: Vec::new(),
2809            egress_tcp_ports: Vec::new(),
2810            // Denied, matching every other grant this constructor zeroes out. Inert on this tier
2811            // (no seccomp filter is installed at all), but it must not read as "allowed".
2812            unix_sockets_allowed: false,
2813            // Denied, like every other grant this constructor zeroes out. Inert on this tier (no
2814            // Landlock domain exists to withhold anything from), but it must not read as "allowed".
2815            workdir_exec: false,
2816            // No Landlock domain on this tier. A test that spreads this over a Landlock tier
2817            // builds at ABI v1, the narrowest set.
2818            landlock_abi: None,
2819            landlock_grants: Vec::new(),
2820            sealed_bind_dirs: Vec::new(),
2821            staged_runtime_dirs: Vec::new(),
2822            // Defaults, not "no limits": the rlimit ceilings apply unchanged on this tier, so
2823            // zeroing them out here would misrepresent what a real macOS host does.
2824            resource_limits: crate::resources::HostResourceLimits::default(),
2825            declared_resources: murmur_artifact::ResourceCapabilities::default(),
2826            cgroup_scope: None,
2827            workdir_guard: None,
2828            session_netns: Arc::new(crate::network_namespace::SessionNetns::new()),
2829            runtime_dir: None,
2830            runtime_dir_owner: None,
2831            temp_mapping: crate::temp_dir::tier_temp_mapping(EnforcementTier::EnvironmentOnly),
2832            native_state_dir: None,
2833            host_paths: Vec::new(),
2834            host_path_read_only_binds: Vec::new(),
2835        }
2836    }
2837
2838    /// This enforcement holding a session's runtime directory roots, home folder included, in a
2839    /// fresh directory kept past the test: a clone may outlive the value. For a test whose
2840    /// commands need only to run.
2841    #[cfg(test)]
2842    pub(crate) fn with_test_session_dirs(self) -> Self {
2843        let base = tempfile::tempdir().unwrap().keep();
2844        self.with_runtime_dir(test_runtime_dir(&base), None)
2845    }
2846
2847    /// Run this enforcement's commands in `session_netns` rather than in a namespace of its own.
2848    ///
2849    /// For a plan submitted inside a session: its shell steps are that session's commands, so
2850    /// they share its loopback and its egress proxy.
2851    pub(crate) fn with_session_netns(
2852        mut self,
2853        session_netns: Arc<crate::network_namespace::SessionNetns>,
2854    ) -> Self {
2855        self.session_netns = session_netns;
2856        self
2857    }
2858
2859    /// This enforcement for one native tool spawn: a clone whose exec allowlist also holds what
2860    /// the kernel itself executes to start `entrypoint`.
2861    ///
2862    /// That is the entrypoint, pinned to the inode the runtime installed, and the closure
2863    /// [`NativeEntrypoint`] derived from the installed image: the absolute interpreter on its `#!`
2864    /// line, and the `PT_INTERP`/`DT_NEEDED` closure — the native-tool counterpart of what
2865    /// `capabilities.shell.allow` derives for a shell binary. The entrypoint needs its own
2866    /// `Execute` rule because it sits in the runtime directory, whose rule grants reading and
2867    /// nothing else. On `KernelSealed` the interpreter's directory is also bound into the composed
2868    /// root when no fixed runtime path carries it; the entrypoint needs no bind, because the root
2869    /// binds the runtime directory at its own path.
2870    ///
2871    /// `state_dir` is the store the tool's own entry declared, from
2872    /// [`crate::types::NativeToolSettings::state_dir`]; it becomes [`Self::native_state_dir`] on
2873    /// this clone, so the store is granted to this tool's process tree and to no shell command or
2874    /// other tool. Sibling Landlock domains cannot `ptrace` each other or read each other's
2875    /// `/proc/<pid>/fd`, so a descriptor the tool holds on it does not leak either.
2876    ///
2877    /// Nothing else is added, and nothing is read from the workdir here. Whatever the tool reads
2878    /// or runs once started is granted by the capsule, exactly as for a shell command. Every clone
2879    /// shares `session_netns`, so the tool joins the session's one namespace and egress proxy.
2880    pub(crate) fn for_native_entrypoint(
2881        &self,
2882        entrypoint: &NativeEntrypoint,
2883        state_dir: Option<&Path>,
2884    ) -> ShellEnforcement {
2885        let mut enforcement = self.clone();
2886        enforcement.native_state_dir = state_dir.map(Path::to_path_buf);
2887        if let Some(pinned) = entrypoint.pinned {
2888            enforcement.landlock_grants.push(LandlockGrant {
2889                path: entrypoint.path.clone(),
2890                list_dir: false,
2891                executable: true,
2892                pinned: Some(pinned),
2893            });
2894        }
2895        enforcement
2896            .landlock_grants
2897            .extend(LandlockGrant::non_listable_files(
2898                entrypoint.closure.clone(),
2899            ));
2900        if self.tier == EnforcementTier::KernelSealed {
2901            if let Some(dir) = entrypoint.interpreter.as_deref().and_then(Path::parent) {
2902                push_sealed_bind_dir(&mut enforcement.sealed_bind_dirs, dir.to_path_buf());
2903                // An interpreter inside a read-write host path is bound read-only on top of it,
2904                // as the session's own execute roots are.
2905                let inside_read_write = self.host_paths.iter().any(|host_path| {
2906                    host_path.access == crate::host_paths::HostPathAccess::ReadWrite
2907                        && dir.starts_with(&host_path.path)
2908                });
2909                if inside_read_write
2910                    && !enforcement
2911                        .host_path_read_only_binds
2912                        .iter()
2913                        .any(|bound| bound == dir)
2914                {
2915                    enforcement
2916                        .host_path_read_only_binds
2917                        .push(dir.to_path_buf());
2918                }
2919            }
2920        }
2921        enforcement
2922    }
2923
2924    /// End the session: kill every process it started, then release its network namespace.
2925    ///
2926    /// Killing goes through the cgroup scope, which every spawn joins before `execve` and no fork
2927    /// escapes, so a server backgrounded with `&`, a `setsid` double fork and a demoted command
2928    /// still running are all in it. The namespace is released only once the scope is empty (or
2929    /// [`SESSION_END_KILL_WAIT`] has passed), so its proxy never stops under a process still
2930    /// using it. Idempotent; every spawn on this enforcement afterwards is refused.
2931    pub(crate) fn end_session(&self) {
2932        self.kill_session_processes();
2933        self.session_netns.end();
2934    }
2935
2936    /// Kill every process in this enforcement's cgroup scope and wait, bounded by
2937    /// [`SESSION_END_KILL_WAIT`], for the scope to empty. Does nothing without a scope.
2938    ///
2939    /// The half of [`Self::end_session`] a plan runs at its own end: a plan's scope is its own,
2940    /// and the namespace may be its session's.
2941    pub(crate) fn kill_session_processes(&self) {
2942        let Some(scope) = self.cgroup_scope.as_ref() else {
2943            return;
2944        };
2945        if let Err(error) = scope.kill_all() {
2946            crate::runtime_err!(
2947                "[capsule-runtime] warning: could not kill the processes in cgroup scope {}: \
2948                 {error}",
2949                scope.path().display()
2950            );
2951        }
2952        if !scope.wait_until_empty(SESSION_END_KILL_WAIT) {
2953            crate::runtime_err!(
2954                "[capsule-runtime] warning: cgroup scope {} still held processes {} ms after \
2955                 they were killed",
2956                scope.path().display(),
2957                SESSION_END_KILL_WAIT.as_millis()
2958            );
2959        }
2960    }
2961}
2962
2963/// How long [`ShellEnforcement::end_session`] waits for a killed scope's `cgroup.procs` to empty.
2964pub(crate) const SESSION_END_KILL_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
2965
2966/// Calls [`ShellEnforcement::end_session`] when dropped.
2967///
2968/// Held by a launch for its whole length, so a session that ends by an error return still kills
2969/// what it started. Every path that ends a session normally calls `end_session` itself first, at
2970/// the point its teardown needs it; the call this makes then finds nothing left to do.
2971pub(crate) struct EndSessionOnDrop(ShellEnforcement);
2972
2973impl EndSessionOnDrop {
2974    pub(crate) fn new(enforcement: ShellEnforcement) -> Self {
2975        Self(enforcement)
2976    }
2977}
2978
2979impl Drop for EndSessionOnDrop {
2980    fn drop(&mut self) {
2981        self.0.end_session();
2982    }
2983}
2984
2985/// Core messages shared between the stderr line and `logs/bootstrap.log` line for each tier
2986/// warning, matching the shared-const convention used by `runtime::BASH_NETWORK_BYPASS_WARNING`.
2987/// Full detail lives on the diagnostics doc page (`security_warning_link`); keep each to
2988/// one or two concise sentences.
2989///
2990/// The Landlock filesystem scope now derives a narrow read+execute grant (the `shell.allow`
2991/// binaries, their ELF interpreter, and their shared-library closure) *outside* the workdir, in
2992/// addition to the workdir's own grant (which withholds device-node and unix-socket creation, see
2993/// `linux_enforce::WORKDIR_ACCESS_RIGHTS`) — so allowlisted programs can actually exec and
2994/// dynamically link, and the only writable paths outside the workdir are the session's temporary
2995/// and home folders, any `read-write` host path ([`crate::host_paths`]) and `/dev/null`, one of the
2996/// three fixed devices in `CAPSULE_DEVICE_GRANTS`. Seccomp
2997/// additionally refuses the `socket(2)` domains in `denied_socket_domains` — closing the
2998/// `/var/run/docker.sock` path, which no Landlock ABI mediates for pathname sockets. The forked
2999/// shell child also drops every Linux capability before `execve` (see
3000/// `linux_enforce::drop_all_capabilities`).
3001/// `KernelFull` warns (`W_SEC_005`) rather than staying silent: the tier is a filesystem and
3002/// syscall scope, not a namespace, so what it does and does not cover has to reach the operator.
3003const KERNEL_ENFORCEMENT_NOTICE: &str = "capabilities.shell.allow is non-empty and this host \
3004resolved to a Linux kernel-enforcement tier (Landlock/seccomp). Landlock now grants a narrow, \
3005derived read+execute scope outside the workdir (the allowlisted binaries, their loader, and their \
3006shared libraries — nothing writable), read-only access to the directories an allowlisted \
3007CPython or Perl interpreter reports on its own import path, read-only access to the host's TLS \
3008trust store when capabilities.network.allow is also non-empty, each host_paths entry in \
3009~/.murmur/config.yaml at its own access, never executable, and no other directory granted \
3010wholesale, plus a fixed device set \
3011every capsule gets unconditionally: /dev/null read+write (with the session's temporary and home \
3012folders and any read-write host_paths entry, the only writable paths outside the workdir), \
3013/dev/zero and /dev/urandom read-only, and no other device — /dev/random, /dev/full, /dev/tty and \
3014raw block devices stay denied. The workdir's own grant also \
3015withholds device-node and unix-socket creation; seccomp refuses socket(AF_UNIX) outright unless \
3016capabilities.network.unix_sockets is declared, and always refuses AF_NETLINK/AF_PACKET, so a \
3017capsule cannot reach a host daemon socket such as /var/run/docker.sock; the seccomp filter now \
3018defaults to deny and permits only a fixed syscall allowlist modelled on the OCI/Docker default \
3019profile, so io_uring, bpf, userfaultfd, perf_event_open, ptrace and similar are refused outright; \
3020and the forked shell child \
3021drops every Linux capability and sets no_new_privs before execve. Paths outside the workdir are \
3022denied, not absent: stat, access and readlink on an ungranted path still succeed.";
3023
3024/// `KernelSealed`'s counterpart to [`KERNEL_ENFORCEMENT_NOTICE`], carrying the same `W_SEC_005`
3025/// code: a silent strongest tier would imply the boundary has no documented exceptions, and it
3026/// has one. Names what `sealed` adds over `scoped` so the warning is not a louder copy of it.
3027const KERNEL_SEALED_ENFORCEMENT_NOTICE: &str = "capabilities.shell.allow is non-empty and this \
3028host resolved to the sealed kernel-enforcement tier: the subprocess tree runs in a private mount \
3029namespace pivoted onto a composed root (host runtime bind-mounted read-only, the session workdir, \
3030its temporary folder at /tmp, its home folder at $HOME and any read-write host_paths entry the only \
3031writable paths, each host_paths entry in ~/.murmur/config.yaml bound at its own path, a private \
3032/dev tmpfs carrying the OCI default device set), with Landlock \
3033and seccomp still installed inside it as defence in depth. Paths outside that root are absent \
3034rather than denied — with one documented exception: /proc is a bind of the host's, because this \
3035tier creates no PID namespace and mounting a private procfs unprivileged needs one. Landlock \
3036refuses reading host /proc content at both scoped and sealed, so another process's command line, \
3037environment and memory maps cannot be read; what still reaches outside the capsule is stat(2) on \
3038/proc/<pid>, which shows that a process exists, its owner and its start time, and kill(2), which \
3039delivers a signal to any process of the same user.";
3040
3041const SECCOMP_ONLY_WARNING: &str = "capabilities.shell.allow is non-empty and this Linux kernel \
3042lacks Landlock (kernel <5.13) — filesystem access outside the capsule workdir is not \
3043kernel-enforced at all, and the seccomp exec/network enforcement that would apply has not been \
3044verified on real Linux hardware. The fixed capsule device set the Full tier applies (/dev/null \
3045read+write, /dev/zero and /dev/urandom read-only, every other device denied) is a Landlock \
3046mechanism, so it does not apply here either: on this tier every device under /dev is reachable \
3047exactly as it would be without any sandbox. Seccomp also refuses socket(AF_UNIX) unless \
3048capabilities.network.unix_sockets is declared, and always refuses AF_NETLINK/AF_PACKET — that \
3049rule needs no Landlock and so applies identically on this tier, but it has not been verified \
3050either. The same is true of the default-deny syscall allowlist (modelled on the OCI/Docker default \
3051profile), which refuses io_uring, bpf, userfaultfd, perf_event_open, ptrace and similar on this \
3052tier too. The forked shell child still drops every Linux capability and \
3053sets no_new_privs before execve on this tier, independently of Landlock, but that has not been \
3054verified either. Treat shell subprocess isolation as experimental on this host.";
3055
3056const ENVIRONMENT_ONLY_WARNING: &str = "capabilities.shell.allow is non-empty but this \
3057platform has no kernel-level subprocess sandbox (Landlock/seccomp are Linux-only) — \
3058enforcement is environment-only (synthetic HOME + credential env-stripping). This is \
3059permanent on this platform.";
3060
3061/// Tier-aware replacement for calling `runtime::warn_if_bash_network_bypass` unconditionally.
3062///
3063///   - `KernelFull` → warns (if `shell_allow` is non-empty) that the Linux kernel enforcement
3064///     is unverified and must not be trusted yet — NOT silent. See the const doc above for why.
3065///   - `KernelSeccompOnly` → warns (if `shell_allow` is non-empty) that Landlock is unavailable
3066///     (filesystem scope unenforced) AND that the remaining seccomp enforcement is unverified.
3067///   - `EnvironmentOnly` → warns (if `shell_allow` is non-empty) that there is no kernel
3068///     sandboxing primitive at all on this platform, and this is permanent. Also still calls
3069///     `runtime::warn_if_bash_network_bypass`, which remains accurate on this tier since
3070///     nothing is kernel-enforced.
3071///
3072/// Pure tier→warning-text decision, split out of `warn_for_enforcement_tier` so tests can
3073/// assert what each tier warns without capturing stderr.
3074pub(crate) fn tier_warning(
3075    tier: EnforcementTier,
3076    shell_allow_is_empty: bool,
3077) -> Option<(&'static str, &'static str)> {
3078    if shell_allow_is_empty {
3079        return None;
3080    }
3081    match tier {
3082        EnforcementTier::KernelSealed => Some((W_SEC_005, KERNEL_SEALED_ENFORCEMENT_NOTICE)),
3083        EnforcementTier::KernelFull => Some((W_SEC_005, KERNEL_ENFORCEMENT_NOTICE)),
3084        EnforcementTier::KernelSeccompOnly => Some((W_SEC_002, SECCOMP_ONLY_WARNING)),
3085        EnforcementTier::EnvironmentOnly => Some((W_SEC_001, ENVIRONMENT_ONLY_WARNING)),
3086        // The launch prints `W-SEC-036` instead, which names what the capsule can reach.
3087        EnforcementTier::Unconfined => None,
3088    }
3089}
3090
3091const NO_AGGREGATE_BOUNDING_WARNING: &str = "this capsule can spawn native subprocesses, but \
3092this platform has no cgroup v2 (Linux-only), so no aggregate bound exists across the subprocess \
3093tree: nothing caps its total memory, task count or CPU. Per-process rlimits from \
3094capabilities.resources still apply (hard, not soft), but RLIMIT_NPROC is a per-uid ceiling and \
3095does NOT stop a fork bomb of distinct, short-lived processes — only a cgroup's pids.max does. \
3096This is permanent on this platform, not a placeholder for a future slice.";
3097
3098/// Pure decision for the aggregate-bounding warning, split out of
3099/// [`warn_for_missing_aggregate_bounding`] the same way [`tier_warning`] is split out of
3100/// [`warn_for_enforcement_tier`], so tests can assert it without capturing stderr.
3101///
3102/// Fires only where the gap is both real and unclosable: a non-Linux host running a capsule that
3103/// can spawn a subprocess. On Linux the same condition is not a warning at all — a capsule that
3104/// can spawn a subprocess and has no scope never launches (`CgroupDelegationUnavailable`), so
3105/// there is no running session left to warn about.
3106pub(crate) fn aggregate_bounding_warning(
3107    is_linux: bool,
3108    requires_bounding: bool,
3109    has_scope: bool,
3110) -> Option<(&'static str, &'static str)> {
3111    if is_linux || !requires_bounding || has_scope {
3112        return None;
3113    }
3114    Some((W_SEC_010, NO_AGGREGATE_BOUNDING_WARNING))
3115}
3116
3117/// Fires at every launch, not just once.
3118pub(crate) fn warn_for_missing_aggregate_bounding(
3119    workdir: &crate::workdir_fs::WorkdirRoot,
3120    requires_bounding: bool,
3121    has_scope: bool,
3122) {
3123    let is_linux = cfg!(target_os = "linux");
3124    if let Some((code, message)) =
3125        aggregate_bounding_warning(is_linux, requires_bounding, has_scope)
3126    {
3127        let link = security_warning_link(code);
3128        crate::runtime_err!("[capsule-runtime] warning[{code}]: {message} ({link})");
3129        crate::agent::append_bootstrap_log(
3130            workdir,
3131            &format!("[capability-policy] warning[{code}]: {message} ({link})"),
3132        );
3133    }
3134}
3135
3136/// Fires at every launch, not just once.
3137pub(crate) fn warn_for_enforcement_tier(
3138    tier: EnforcementTier,
3139    workdir: &crate::workdir_fs::WorkdirRoot,
3140    policy: &CapabilityPolicy,
3141) {
3142    if let Some((code, message)) = tier_warning(tier, policy.shell_allow.is_empty()) {
3143        let link = security_warning_link(code);
3144        crate::runtime_err!("[capsule-runtime] warning[{code}]: {message} ({link})");
3145        crate::agent::append_bootstrap_log(
3146            workdir,
3147            &format!("[capability-policy] warning[{code}]: {message} ({link})"),
3148        );
3149    }
3150    if tier == EnforcementTier::EnvironmentOnly {
3151        crate::runtime::warn_if_bash_network_bypass(workdir, policy);
3152    }
3153}
3154
3155/// What `prepare_enforcement` hands back alongside the configured `Command`: on a kernel tier, the
3156/// read end of the child's diagnostic pipe.
3157///
3158/// The session's egress proxy is not here. It belongs to the session's
3159/// [`crate::network_namespace::SessionNetns`] and outlives every command, so a process a command
3160/// backgrounds keeps its allowlisted egress after the command returns.
3161#[derive(Debug)]
3162pub(crate) enum SupervisorHandle {
3163    Noop,
3164    #[cfg(target_os = "linux")]
3165    Linux {
3166        /// Read end of the `CLOEXEC` diagnostic pipe whose write end was moved into the forked
3167        /// child's `pre_exec` closure. On a successful `execve` the write end closes
3168        /// automatically with nothing written (so a read yields immediate EOF); on a `pre_exec`
3169        /// setup failure the child writes the real error message here before returning `Err`,
3170        /// letting `execute_shell` fold that legible detail into the error it returns instead of
3171        /// the bare, undifferentiated `EINVAL` that `Command::spawn()` surfaces.
3172        diag_read: std::os::fd::OwnedFd,
3173    },
3174}
3175
3176impl SupervisorHandle {
3177    /// Best-effort read of any failure detail the forked child wrote to the diagnostic pipe
3178    /// before its `pre_exec` closure returned `Err`. Only consulted by `execute_shell` when
3179    /// `Command::spawn()` itself returns `Err` — on the success path this is never called, so
3180    /// there is zero cost there.
3181    ///
3182    /// The caller must have already released every *parent-side* copy of the pipe's write end
3183    /// (the one captured by the `pre_exec` closure stored in the `Command`) — otherwise the
3184    /// read below never sees EOF. `execute_shell` guarantees this by dropping the `Command`
3185    /// before calling this. Returns `None` when there is no pipe (the `Noop` handle used for
3186    /// `EnvironmentOnly`/non-Linux) or when the child wrote nothing (e.g. a failure with no
3187    /// message, or a `spawn` failure that never ran `pre_exec` at all, like `fork()` itself
3188    /// failing).
3189    pub(crate) fn read_diagnostic(&self) -> Option<String> {
3190        match self {
3191            SupervisorHandle::Noop => None,
3192            #[cfg(target_os = "linux")]
3193            SupervisorHandle::Linux { diag_read, .. } => {
3194                use std::os::fd::AsRawFd;
3195                linux_enforce::read_diagnostic_pipe(diag_read.as_raw_fd())
3196            }
3197        }
3198    }
3199}
3200
3201// Forced-failure seam for the fail-closed contract: inducing a real kernel-level setup failure
3202// requires already-hostile conditions, so tests simulate one here instead. Only compiled into
3203// test builds; production `prepare_enforcement` has no bypass or injection point.
3204#[cfg(test)]
3205thread_local! {
3206    pub(crate) static FORCE_PREPARE_FAILURE: std::cell::Cell<bool> =
3207        const { std::cell::Cell::new(false) };
3208}
3209
3210#[cfg(test)]
3211fn forced_prepare_failure() -> Result<(), String> {
3212    if FORCE_PREPARE_FAILURE.with(|flag| flag.get()) {
3213        return Err("sandbox: kernel enforcement setup failed (forced by test seam)".to_string());
3214    }
3215    Ok(())
3216}
3217
3218// Child-side (`pre_exec`) forced-failure seams, one per distinct setup step that can fail
3219// independently: the explicit `no_new_privs` `prctl` and the Landlock ruleset construction.
3220// Unlike `FORCE_PREPARE_FAILURE` (which fails the *outer*, pre-fork `prepare_enforcement` path),
3221// these are read from inside the forked child's `pre_exec` closure — `fork()` copy-on-write
3222// duplicates the calling thread's TLS block, so a flag set `true` in the parent thread before
3223// `command.spawn()` remains readable as `true` inside `pre_exec` with no cross-process IPC.
3224// Only compiled into Linux test builds (their only readers — `set_no_new_privs` and
3225// `apply_landlock_scope` — are Linux-only); production has no bypass or injection point.
3226#[cfg(all(test, target_os = "linux"))]
3227thread_local! {
3228    pub(crate) static FORCE_NO_NEW_PRIVS_FAILURE: std::cell::Cell<bool> =
3229        const { std::cell::Cell::new(false) };
3230    pub(crate) static FORCE_LANDLOCK_FAILURE: std::cell::Cell<bool> =
3231        const { std::cell::Cell::new(false) };
3232    /// Fails the composed-root step of a `KernelSealed` launch. Exists because the real step can
3233    /// only fail on a host that can attempt it at all, and the thing under test — that a
3234    /// composed-root failure keeps its identity from the child's diagnostic pipe all the way to
3235    /// the CLI's `E-RUN-014` — must be verifiable on any Linux host, including the CI containers
3236    /// that can never reach `KernelSealed`. Short-circuits before any real namespace syscall.
3237    pub(crate) static FORCE_SEALED_ROOT_FAILURE: std::cell::Cell<bool> =
3238        const { std::cell::Cell::new(false) };
3239    /// Fails the `/tmp` mapping of a `MountNamespace` spawn at its first step, before any real
3240    /// namespace syscall, so a failed mapping's path is testable on any Linux host.
3241    pub(crate) static FORCE_TMP_MAP_FAILURE: std::cell::Cell<bool> =
3242        const { std::cell::Cell::new(false) };
3243}
3244
3245/// RAII arm for [`FORCE_TMP_MAP_FAILURE`].
3246#[cfg(all(test, target_os = "linux"))]
3247pub(crate) struct ForceTmpMapFailureGuard;
3248
3249#[cfg(all(test, target_os = "linux"))]
3250impl ForceTmpMapFailureGuard {
3251    pub(crate) fn new() -> Self {
3252        FORCE_TMP_MAP_FAILURE.with(|flag| flag.set(true));
3253        Self
3254    }
3255}
3256
3257#[cfg(all(test, target_os = "linux"))]
3258impl Drop for ForceTmpMapFailureGuard {
3259    fn drop(&mut self) {
3260        FORCE_TMP_MAP_FAILURE.with(|flag| flag.set(false));
3261    }
3262}
3263
3264/// RAII arm for [`FORCE_SEALED_ROOT_FAILURE`]. Lives beside the flag rather than in one test
3265/// module because two of them need it: `sandbox`'s (which checks what `execute_shell` returns)
3266/// and `runtime`'s (which checks what the dispatch layer does with it).
3267#[cfg(all(test, target_os = "linux"))]
3268pub(crate) struct ForceSealedRootFailureGuard;
3269
3270#[cfg(all(test, target_os = "linux"))]
3271impl ForceSealedRootFailureGuard {
3272    pub(crate) fn new() -> Self {
3273        FORCE_SEALED_ROOT_FAILURE.with(|flag| flag.set(true));
3274        Self
3275    }
3276}
3277
3278#[cfg(all(test, target_os = "linux"))]
3279impl Drop for ForceSealedRootFailureGuard {
3280    fn drop(&mut self) {
3281        FORCE_SEALED_ROOT_FAILURE.with(|flag| flag.set(false));
3282    }
3283}
3284
3285/// A `KernelSealed` enforcement with no grants, for the forced-failure tests. The parent-side
3286/// half of the sealed mechanism still runs for real against it (the composed root is planned and
3287/// its spec built); only the child's execution of that spec is short-circuited by the seam above,
3288/// which is what makes these tests run identically on a sealed-capable host and on one that could
3289/// never reach `KernelSealed` at all.
3290#[cfg(all(test, target_os = "linux"))]
3291pub(crate) fn sealed_test_enforcement() -> ShellEnforcement {
3292    // Kept past the test: the enforcement may be cloned into a thread that outlives the value.
3293    let base = tempfile::tempdir().unwrap().keep();
3294    ShellEnforcement {
3295        tier: EnforcementTier::KernelSealed,
3296        ..ShellEnforcement::environment_only().with_test_session_dirs()
3297    }
3298    .with_runtime_dir(test_runtime_dir(&base), None)
3299}
3300
3301/// `runtime/`, `sealed-etc/`, `harness/` and a records directory holding a temporary folder and
3302/// a home folder, created under `base`, held as a session's runtime directory roots.
3303#[cfg(test)]
3304pub(crate) fn test_runtime_dir(base: &Path) -> crate::runtime_dir::RuntimeDirRoots {
3305    let open = |sub: &str| {
3306        let path = base.join(sub);
3307        std::fs::create_dir_all(&path).unwrap();
3308        crate::workdir_fs::WorkdirRoot::open(&path).unwrap()
3309    };
3310    crate::runtime_dir::RuntimeDirRoots::new(
3311        open(crate::runtime_dir::RUNTIME_SUBDIR),
3312        open(crate::runtime_dir::SEALED_ETC_SUBDIR),
3313        open(crate::runtime_dir::HARNESS_SUBDIR),
3314    )
3315    .with_records(open(crate::records_dir::RECORDS_DIR_NAME))
3316    .with_temp(open(&format!(
3317        "{}/{}",
3318        crate::records_dir::RECORDS_DIR_NAME,
3319        crate::temp_dir::SESSION_TEMP_DIR_NAME
3320    )))
3321    .with_home(open(&format!(
3322        "{}/{}",
3323        crate::records_dir::RECORDS_DIR_NAME,
3324        crate::records_dir::HOME_SUBDIR
3325    )))
3326}
3327
3328/// Attach the OS-level *process* bounds — hard rlimits, and cgroup membership where a scope
3329/// exists — to `command`, so they apply to the spawned process and everything it forks.
3330///
3331/// Deliberately independent of the tier machinery above. Landlock and seccomp are Linux kernel
3332/// primitives that exist on some hosts; `setrlimit` is POSIX and exists on all of them, and the
3333/// cgroup self-move is a single `write(2)` to a descriptor the parent already opened. So this is
3334/// the whole of `prepare_enforcement` on `EnvironmentOnly` and off Linux, through which every
3335/// shell command, plan shell step and native tool is spawned.
3336///
3337/// Everything the returned closure does runs in the forked child between `fork` and `execve`,
3338/// so it is restricted to syscalls: `getrlimit`/`setrlimit` pairs and one `write`. No
3339/// allocation, no locking, no path resolution.
3340#[cfg(unix)]
3341pub(crate) fn attach_process_limits(
3342    command: &mut std::process::Command,
3343    enforcement: &ShellEnforcement,
3344) {
3345    use std::os::unix::process::CommandExt;
3346
3347    if enforcement.tier == EnforcementTier::Unconfined {
3348        attach_unconfined_limits(command, enforcement);
3349        return;
3350    }
3351
3352    let limits = enforcement.resource_limits;
3353    let nproc = enforcement.nproc_bound();
3354    let cgroup_scope = enforcement.cgroup_scope.clone();
3355
3356    // SAFETY: the closure runs in the forked child before `execve`, where only async-signal-safe
3357    // operations are permitted. It performs `getrlimit`/`setrlimit` pairs and, when a cgroup
3358    // scope exists, a single `write` to a descriptor opened by the parent before the fork —
3359    // no allocation, no locks, no path lookups.
3360    #[allow(unsafe_code)]
3361    unsafe {
3362        command.pre_exec(move || {
3363            crate::resources::apply_hard_rlimits(&limits, nproc)?;
3364            if let Some(scope) = cgroup_scope.as_ref() {
3365                scope.join_current_process()?;
3366            }
3367            Ok(())
3368        });
3369    }
3370}
3371
3372/// [`attach_process_limits`] on [`EnforcementTier::Unconfined`]: only the rlimits
3373/// [`ShellEnforcement::declared_resources`] declares, the cgroup join when a scope exists, and on
3374/// Linux the fd-hygiene step. Nothing else: no `RLIMIT_CORE`, no default ceiling, so an
3375/// undeclared limit is the one `mur run` itself has.
3376///
3377/// Same `pre_exec` restrictions as [`attach_process_limits`].
3378#[cfg(unix)]
3379fn attach_unconfined_limits(command: &mut std::process::Command, enforcement: &ShellEnforcement) {
3380    use std::os::unix::process::CommandExt;
3381
3382    let declared = enforcement.declared_resources;
3383    let nproc = enforcement.nproc_bound();
3384    let cgroup_scope = enforcement.cgroup_scope.clone();
3385
3386    // SAFETY: the closure runs in the forked child before `execve`, where only async-signal-safe
3387    // operations are permitted. It performs `getrlimit`/`setrlimit` pairs, at most one `write` to
3388    // a descriptor opened by the parent before the fork, and on Linux one `close_range` (or a
3389    // bounded `fcntl` loop) — no allocation, no locks, no path lookups.
3390    #[allow(unsafe_code)]
3391    unsafe {
3392        command.pre_exec(move || {
3393            crate::resources::apply_declared_rlimits(&declared, nproc)?;
3394            if let Some(scope) = cgroup_scope.as_ref() {
3395                scope.join_current_process()?;
3396            }
3397            #[cfg(target_os = "linux")]
3398            linux_enforce::mark_inherited_fds_cloexec()?;
3399            Ok(())
3400        });
3401    }
3402}
3403
3404/// Lowest file descriptor the fd-hygiene step touches. Everything from here upward is marked
3405/// close-on-exec in the forked child; fds 0, 1 and 2 are deliberately left alone.
3406///
3407/// **Stdio is NOT marked close-on-exec, and is NOT reopened.** The `Stdio::piped()` handles a
3408/// spawn installs *before* `pre_exec` runs exist so the exec'd program inherits them across its
3409/// own `execve` and talks to the parent over them — `execute_shell` reads the subprocess's
3410/// stdout/stderr, and `dispatch_native_tool` additionally writes its `ToolInput` JSON to the
3411/// subprocess's stdin and parses its `ToolResult` JSON back off stdout. Marking 0/1/2
3412/// close-on-exec would leave the exec'd program with those descriptors closed, breaking every
3413/// invocation, and there is nothing meaningful to reopen them onto: the pipes themselves are the
3414/// intended destination. The gap this constant closes is everything *above*
3415/// stdio, where inheritance was never intended and was only ever prevented by the empirical
3416/// accident of nothing else happening to be open at spawn time.
3417///
3418/// Its only non-test reader is Linux-only (`linux_enforce::mark_inherited_fds_cloexec`), so this
3419/// carries the same cross-platform `dead_code` exception the other Linux-consumed items in this
3420/// module carry.
3421#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
3422pub(crate) const FD_HYGIENE_FIRST_FD: u32 = 3;
3423
3424/// Installs kernel-level exec/network/filesystem enforcement into `command` so it applies to
3425/// the spawned process and everything it forks/execs. The Landlock/seccomp half is a no-op when
3426/// `enforcement.tier == EnvironmentOnly` — it does not even attempt those calls in that case —
3427/// but the rlimit half below is not: `setrlimit` is POSIX and applies on every platform this
3428/// runtime targets, so a macOS capsule still gets per-process ceilings even though this tier has
3429/// no kernel sandbox at all.
3430#[cfg(not(target_os = "linux"))]
3431pub(crate) fn prepare_enforcement(
3432    command: &mut std::process::Command,
3433    enforcement: &ShellEnforcement,
3434    _accessible_workdir: &Path,
3435) -> Result<SupervisorHandle, String> {
3436    #[cfg(test)]
3437    forced_prepare_failure()?;
3438
3439    debug_assert!(
3440        matches!(
3441            enforcement.tier,
3442            EnforcementTier::EnvironmentOnly | EnforcementTier::Unconfined
3443        ),
3444        "non-Linux targets must always resolve to EnforcementTier::EnvironmentOnly or Unconfined"
3445    );
3446    attach_process_limits(command, enforcement);
3447    Ok(SupervisorHandle::Noop)
3448}
3449
3450/// Linux implementation of `prepare_enforcement`. See the module-level docs and the `linux_enforce`
3451/// submodule for the mechanics: the session's network namespace is established in the parent,
3452/// and the `pre_exec` closure sets `RLIMIT_NPROC` or joins the cgroup scope, then joins the
3453/// namespace, then maps `/tmp` or installs the composed root, then seccomp and Landlock, and
3454/// applies the per-process rlimits last.
3455#[cfg(target_os = "linux")]
3456#[allow(unsafe_code)]
3457pub(crate) fn prepare_enforcement(
3458    command: &mut std::process::Command,
3459    enforcement: &ShellEnforcement,
3460    accessible_workdir: &Path,
3461) -> Result<SupervisorHandle, String> {
3462    use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
3463    use std::os::unix::process::CommandExt;
3464
3465    #[cfg(test)]
3466    forced_prepare_failure()?;
3467
3468    if matches!(
3469        enforcement.tier,
3470        EnforcementTier::EnvironmentOnly | EnforcementTier::Unconfined
3471    ) {
3472        // No kernel sandbox to install. `EnvironmentOnly` keeps the POSIX rlimit ceilings — it is
3473        // only reachable on Linux from tests, and even there it must not read as "unbounded" —
3474        // and `Unconfined` keeps the declared limits and the fd-hygiene step.
3475        attach_process_limits(command, enforcement);
3476        return Ok(SupervisorHandle::Noop);
3477    }
3478
3479    // The session's temporary folder, reopened from its root's held descriptor: its Landlock
3480    // rule's fd on both Landlock tiers, and the identity the sealed bind and the mount-namespace
3481    // bind both verify, so a link swapped onto its path redirects neither.
3482    let temp_dir_fd = enforcement
3483        .runtime_dir
3484        .as_ref()
3485        .and_then(|roots| roots.temp.as_ref())
3486        .map(|temp| temp.path_fd().map(Arc::new))
3487        .transpose()
3488        .map_err(|error| {
3489            format!("sandbox: failed to open the session's temporary folder: {error}")
3490        })?;
3491    // The session's home folder, on exactly the temporary folder's terms: its Landlock rule's fd
3492    // on both Landlock tiers and the identity its sealed bind verifies.
3493    let home_dir_fd = enforcement
3494        .home_root()
3495        .map(|home| home.path_fd().map(Arc::new))
3496        .transpose()
3497        .map_err(|error| format!("sandbox: failed to open the session's home folder: {error}"))?;
3498    // A native tool's store, on the home folder's terms on both Landlock tiers and opened on no
3499    // other, where nothing is scoped. Fail-closed: a tool that declared a store is never spawned
3500    // without it.
3501    let state_dir_fd = enforcement
3502        .native_state_dir
3503        .as_deref()
3504        .filter(|_| {
3505            matches!(
3506                enforcement.tier,
3507                EnforcementTier::KernelFull | EnforcementTier::KernelSealed
3508            )
3509        })
3510        .map(|path| linux_enforce::open_state_dir(path).map(|fd| (Arc::new(fd), path)))
3511        .transpose()?;
3512    // Every host path, on the store's terms: opened on both Landlock tiers and on no other, and
3513    // fail-closed. A configured host path that can no longer be opened fails this spawn naming
3514    // it; it is never dropped.
3515    let host_path_fds: Vec<HostPathFd> = if matches!(
3516        enforcement.tier,
3517        EnforcementTier::KernelFull | EnforcementTier::KernelSealed
3518    ) {
3519        enforcement
3520            .host_paths
3521            .iter()
3522            .map(|host_path| {
3523                linux_enforce::open_host_path(&host_path.path).map(|fd| HostPathFd {
3524                    fd: Arc::new(fd),
3525                    path: host_path.path.clone(),
3526                    read_write: host_path.access == crate::host_paths::HostPathAccess::ReadWrite,
3527                })
3528            })
3529            .collect::<Result<_, _>>()?
3530    } else {
3531        Vec::new()
3532    };
3533
3534    // The descriptors a rule and a bind share on `KernelSealed`: the temporary folder and the
3535    // runtime and records directories, each taken once, with no link followed, from a held
3536    // descriptor. The Landlock rule is taken on the descriptor, and the bind is refused unless
3537    // the directory the child reaches is the one it names, so a link swapped in afterwards
3538    // changes neither.
3539    let sealed_sources = if enforcement.tier == EnforcementTier::KernelSealed {
3540        Some(SealedSourceFds::open(
3541            enforcement,
3542            temp_dir_fd.as_ref(),
3543            home_dir_fd.as_ref(),
3544            state_dir_fd
3545                .as_ref()
3546                .map(|(fd, path)| (Arc::clone(fd), path.to_path_buf())),
3547        )?)
3548    } else {
3549        None
3550    };
3551    // On `KernelFull` the runtime directory needs the Landlock rule alone.
3552    let runtime_dir_fd = match &sealed_sources {
3553        Some(sources) => Some(Arc::clone(&sources.runtime_dir)),
3554        None if enforcement.tier == EnforcementTier::KernelFull => enforcement
3555            .runtime_dir
3556            .as_ref()
3557            .map(|roots| roots.runtime.path_fd().map(Arc::new))
3558            .transpose()
3559            .map_err(|error| {
3560                format!(
3561                    "sandbox: failed to open the runtime directory for Landlock scoping: {error}"
3562                )
3563            })?,
3564        None => None,
3565    };
3566    // The records directory takes the runtime directory's rule on both Landlock tiers, from the
3567    // same descriptor its sealed bind verifies.
3568    let records_dir_fd = match &sealed_sources {
3569        Some(sources) => sources.records_dir.as_ref().map(|(fd, _)| Arc::clone(fd)),
3570        None if enforcement.tier == EnforcementTier::KernelFull => enforcement
3571            .runtime_dir
3572            .as_ref()
3573            .and_then(|roots| roots.records.as_ref())
3574            .map(|records| records.path_fd().map(Arc::new))
3575            .transpose()
3576            .map_err(|error| {
3577                format!(
3578                    "sandbox: failed to open the records directory for Landlock scoping: {error}"
3579                )
3580            })?,
3581        None => None,
3582    };
3583
3584    // The private `/tmp` of a `MountNamespace` spawn, every path and buffer of it built here.
3585    let tmp_map_spec = match (&temp_dir_fd, enforcement.installed_temp_mapping()) {
3586        (Some(folder_fd), crate::temp_dir::TempMapping::MountNamespace) => Some(
3587            build_tmp_map_spec(enforcement, folder_fd, accessible_workdir)?,
3588        ),
3589        _ => None,
3590    };
3591
3592    // Resolve/open every Landlock path in the PARENT, before fork(). Failing to open the workdir
3593    // is a normal synchronous error here — it names the path and returns before any subprocess is
3594    // spawned — rather than an `open()` that, buried inside `pre_exec`, would collapse to a bare
3595    // EINVAL at the `.spawn()` call site. Grant paths that fail to open are silently dropped
3596    // (shrink-not-fail), exactly as before; only the *where* of the open moved. Landlock rules
3597    // only apply on `KernelFull`, so `KernelSeccompOnly` opens nothing.
3598    let landlock_fds = if matches!(
3599        enforcement.tier,
3600        EnforcementTier::KernelFull | EnforcementTier::KernelSealed
3601    ) {
3602        Some(linux_enforce::open_landlock_fds(
3603            linux_enforce::LandlockFdRequest {
3604                accessible_workdir,
3605                landlock_grants: &enforcement.landlock_grants,
3606                device_grants: &landlock_device_grants(enforcement.tier),
3607                workdir_exec: enforcement.workdir_exec,
3608                abi: enforcement.landlock_abi,
3609                sealed: sealed_sources.as_ref(),
3610                temp_dir_fd,
3611                home_dir_fd,
3612                state_dir_fd: state_dir_fd.map(|(fd, _)| fd),
3613                runtime_dir_fd,
3614                records_dir_fd,
3615                host_path_fds: host_path_fds
3616                    .iter()
3617                    .map(|host_path| (Arc::clone(&host_path.fd), host_path.read_write))
3618                    .collect(),
3619            },
3620        )?)
3621    } else {
3622        None
3623    };
3624
3625    // Resolve the whole composed root in the PARENT for the same reason the Landlock fds are
3626    // opened here: every path lookup, every `PathBuf::join` and every allocation the child would
3627    // otherwise perform inside `pre_exec` — the window where the allocator lock may be held by a
3628    // thread that no longer exists — happens now instead, synchronously, where a failure names
3629    // itself. See `crate::sealed` for the plan/execute split.
3630    let sealed_spec = match &sealed_sources {
3631        Some(sources) => Some(build_sealed_root(
3632            accessible_workdir,
3633            sources,
3634            &enforcement.sealed_bind_dirs,
3635            &enforcement.staged_runtime_dirs,
3636            &host_path_fds,
3637            &enforcement.host_path_read_only_binds,
3638        )?),
3639        None => None,
3640    };
3641
3642    // Dedicated CLOEXEC pipe carrying failure detail out of the child. The write end is moved
3643    // into the `pre_exec` closure; because it is CLOEXEC, a successful `execve` closes it with
3644    // nothing written (the parent's read then returns immediate EOF — zero cost on the success
3645    // path), while any `pre_exec` setup failure writes its real message here before the closure
3646    // returns `Err`.
3647    let mut diag_fds = [0i32; 2];
3648    // SAFETY: `diag_fds` is a 2-element stack array, exactly the size `pipe2` writes into; no
3649    // other precondition applies.
3650    let rc = unsafe { libc::pipe2(diag_fds.as_mut_ptr(), libc::O_CLOEXEC) };
3651    if rc != 0 {
3652        return Err(format!(
3653            "sandbox: pipe2() for enforcement diagnostics failed: {}",
3654            std::io::Error::last_os_error()
3655        ));
3656    }
3657    // SAFETY: both fds were just returned by the successful pipe2() call above; nothing else has
3658    // taken ownership of them yet, so wrapping them in OwnedFd is exclusive/sound.
3659    let (diag_read, diag_write) = unsafe {
3660        (
3661            OwnedFd::from_raw_fd(diag_fds[0]),
3662            OwnedFd::from_raw_fd(diag_fds[1]),
3663        )
3664    };
3665
3666    // The session's network namespace, created by the first command that needs it and joined by
3667    // every one after. Established here, in the parent and before the fork, so the egress proxy
3668    // is already serving the namespace's listeners when the command's first connection arrives,
3669    // and so a namespace that cannot be created fails this spawn with a legible error instead of
3670    // running the command anywhere else. The join handle moves into the closure, which keeps both
3671    // descriptors open until the `Command` is dropped.
3672    let netns = enforcement.session_netns.establish(
3673        &enforcement.egress_tcp_ports,
3674        crate::egress_proxy::EgressPolicy::new(
3675            enforcement.network_allow_rules.clone(),
3676            enforcement.network_allow_ips.clone(),
3677        ),
3678    )?;
3679
3680    let tier = enforcement.tier;
3681    // Copied out before the `move` closure below takes ownership of everything it touches, the
3682    // same clone-before-move shape `tier` and the Landlock fds already use.
3683    let unix_sockets_allowed = enforcement.unix_sockets_allowed;
3684    // Same clone-before-move shape. `HostResourceLimits` and `NprocBound` are `Copy`, and the
3685    // `RLIMIT_NPROC` decision is made here because its baseline is a `/proc` walk; the cgroup scope
3686    // is an `Arc` whose `cgroup.procs` descriptor was opened in the parent at scope-creation time,
3687    // so the child performs no path lookup to join it.
3688    let resource_limits = enforcement.resource_limits;
3689    let nproc = enforcement.nproc_bound();
3690    let cgroup_scope = enforcement.cgroup_scope.clone();
3691
3692    // SAFETY: this closure runs in the forked child, after fork() but before execve() — the
3693    // narrow pre_exec window where only async-signal-safe operations are permitted. In order, it
3694    // performs: without a cgroup scope, the `RLIMIT_NPROC` `getrlimit`/`setrlimit` pair of
3695    // `resources::apply_nproc_bound`; with one, one `write` to the `cgroup.procs` descriptor the
3696    // parent opened; two `setns` calls into the session's namespaces; on a `MountNamespace` spawn
3697    // the `unshare`/`mount`/`open`/`mkdirat`/`chdir` sequence of `temp_dir::map_private_tmp`, and
3698    // on `KernelSealed` the `unshare`/`open`/`write`/`mount`/`pivot_root` sequence of
3699    // `sealed::construct_composed_root`, both over paths and buffers built here; the
3700    // `close_range` fd-hygiene call; the capability-dropping `prctl`/`capset` sequence
3701    // (bounding-set drop, capset clear, and the explicit `no_new_privs` `prctl`) plus one
3702    // allocation-free `open`/`read`/`close` of `/proc/sys/kernel/cap_last_cap`; libseccomp filter
3703    // construction/load (kernel syscalls); on `KernelFull`/`KernelSealed` Landlock ruleset
3704    // construction/`restrict_self` against already-open fds (also kernel syscalls); and last, the
3705    // `getrlimit`/`setrlimit` pairs of `resources::apply_per_process_rlimits`. No `canonicalize()`, no
3706    // path lookup beyond those prepared paths and `cap_last_cap`, no locks beyond what those
3707    // syscalls need. On any failure it writes the real error message to the CLOEXEC diagnostic
3708    // pipe (best-effort, bounded, raw `write` loop) before returning `Err`. `diag_write`, the
3709    // namespace join handle and the Landlock fds are moved in, and are `CLOEXEC`, so none of them
3710    // reaches the exec'd program.
3711    unsafe {
3712        command.pre_exec(move || {
3713            // `RLIMIT_NPROC` first: the composed root's `unshare(CLONE_NEWUSER)` below fixes the
3714            // session namespace's task bound at this child's limit as of that call, and a later
3715            // `setrlimit` would bound only the new namespace's own tasks. It is set only without
3716            // a cgroup scope (`NprocBound::Headroom`) and creates no descriptor.
3717            if let Err(error) = crate::resources::apply_nproc_bound(&resource_limits, nproc) {
3718                linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
3719                return Err(error);
3720            }
3721            if let Some(scope) = cgroup_scope.as_ref() {
3722                if let Err(error) = scope.join_current_process() {
3723                    linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
3724                    return Err(error);
3725                }
3726            }
3727
3728            let (user_ns_fd, net_ns_fd) = netns.raw_fds();
3729            if let Err(error) = linux_enforce::child_install_enforcement(
3730                tier,
3731                user_ns_fd,
3732                net_ns_fd,
3733                tmp_map_spec.as_ref(),
3734                sealed_spec.as_ref(),
3735                landlock_fds.as_ref(),
3736                unix_sockets_allowed,
3737            ) {
3738                linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
3739                return Err(error);
3740            }
3741
3742            // The per-process rlimits come last, as the final call before `execve`. They cannot
3743            // come first: every descriptor the steps above create (the `/tmp` mapping's `O_PATH`
3744            // fds, the composed root's `/proc/self/*_map` writes and `MkFile` steps,
3745            // `cap_last_cap`, the Landlock ruleset) must be numbered below `RLIMIT_NOFILE`, and
3746            // this child holds every descriptor `mur` had open at the fork, so the lowest free
3747            // number can be above a modest `max_open_files`. Last still bounds the exec'd program
3748            // from its first instruction: a step above that fails returns `Err`, which aborts the
3749            // exec, so no capsule code runs in this child; a failing `setrlimit` aborts it the
3750            // same way; and `prlimit64`/`getrlimit`/`setrlimit` are in
3751            // `SECCOMP_SYSCALL_ALLOWLIST`, and lowering a limit needs no capability, so neither
3752            // the filter nor the capability drop stops them.
3753            if let Err(error) = crate::resources::apply_per_process_rlimits(&resource_limits) {
3754                linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
3755                return Err(error);
3756            }
3757            Ok(())
3758        });
3759    }
3760
3761    Ok(SupervisorHandle::Linux { diag_read })
3762}
3763
3764/// Which fixed device set Landlock grants, keyed on the tier.
3765///
3766/// Landlock keeps mediating inside a composed root, so the two device *lists* have to agree: a
3767/// device present in the sealed `/dev` but absent from the Landlock rules would exist and be
3768/// unopenable, which reads as a runtime bug rather than as policy. `KernelFull` keeps
3769/// [`CAPSULE_DEVICE_GRANTS`], which services `scoped`, while `KernelSealed` derives its own from
3770/// [`crate::sealed::SEALED_DEVICE_NODES`], the same list the private `/dev` tmpfs is built from.
3771///
3772/// The parent opens these paths on the *host*, before the namespace exists; because the composed
3773/// root bind-mounts the host's own nodes rather than creating new ones, the inodes the rules name
3774/// are the inodes the capsule reaches.
3775#[cfg(target_os = "linux")]
3776fn landlock_device_grants(tier: EnforcementTier) -> Vec<CapsuleDeviceGrant> {
3777    match tier {
3778        EnforcementTier::KernelSealed => crate::sealed::SEALED_DEVICE_NODES
3779            .iter()
3780            .map(|device| CapsuleDeviceGrant {
3781                path: device.path,
3782                writable: device.writable,
3783            })
3784            .collect(),
3785        _ => CAPSULE_DEVICE_GRANTS.to_vec(),
3786    }
3787}
3788
3789/// The `/tmp` mapping of one `MountNamespace` spawn: the folder held by `folder_fd` bound over
3790/// canonical `/tmp`, and every path beneath the host's `/tmp` the command still needs re-exposed
3791/// at its own path.
3792///
3793/// Those paths are the accessible workdir, the session's home folder and a native tool's store,
3794/// bound read-write, and the runtime and
3795/// records directories and every Landlock grant path, which already carries the `shell.allow`
3796/// closure, the interpreter and staged runtimes and a native tool's entrypoint closure, bound
3797/// read-only. Landlock adds the folder's rights to everything bound beneath it, which are the
3798/// workdir's own; a read-only bind is what keeps them from writing the rest. See
3799/// [`crate::temp_dir::reexposed_paths`].
3800#[cfg(target_os = "linux")]
3801fn build_tmp_map_spec(
3802    enforcement: &ShellEnforcement,
3803    folder_fd: &std::os::fd::OwnedFd,
3804    accessible_workdir: &Path,
3805) -> Result<crate::temp_dir::TmpMapSpec, String> {
3806    let folder = enforcement.temp_dir_path().ok_or_else(|| {
3807        format!(
3808            "{} this enforcement holds none",
3809            crate::temp_dir::TMP_MAP_FAILURE_PREFIX
3810        )
3811    })?;
3812    let identity = crate::workdir_fs::fd_identity(folder_fd).map_err(|errno| {
3813        format!(
3814            "{} fstat of the temporary folder failed: {errno}",
3815            crate::temp_dir::TMP_MAP_FAILURE_PREFIX
3816        )
3817    })?;
3818    let canonical_tmp = crate::temp_dir::canonical_tmp();
3819    let read_only = [
3820        enforcement.runtime_dir_path(),
3821        enforcement.records_dir_path(),
3822    ]
3823    .into_iter()
3824    .flatten()
3825    .chain(
3826        enforcement
3827            .landlock_grants
3828            .iter()
3829            .map(|grant| grant.path.as_path()),
3830    );
3831    let reexposed = crate::temp_dir::reexposed_paths(
3832        &canonical_tmp,
3833        std::iter::once(accessible_workdir)
3834            .chain(enforcement.home_dir_path())
3835            .chain(enforcement.native_state_dir.as_deref()),
3836        read_only,
3837    );
3838    crate::temp_dir::TmpMapSpec::new(
3839        folder,
3840        identity,
3841        &canonical_tmp,
3842        &reexposed,
3843        accessible_workdir,
3844    )
3845}
3846
3847/// Writes (idempotently) the synthetic `/etc/passwd`/`/etc/group` the composed root binds in place
3848/// of the host's into the session's `sealed-etc/` directory, returning their host paths in
3849/// [`crate::sealed::SEALED_ETC_PATHS`] order.
3850///
3851/// `sealed-etc/` sits beside the runtime directory, outside every workdir, and no capsule command
3852/// is granted it. The account the files name is the home folder's owner, the identity the
3853/// capsule's subprocesses run as, with the session's home folder as its home directory.
3854///
3855/// Runs in the parent, never in `pre_exec`: composing a root is the child's job, but *producing
3856/// content* is work the parent can do, and this module's rule is that the `pre_exec` window does
3857/// nothing the parent could have done for it.
3858///
3859/// The rewrite is in place ([`crate::workdir_fs::WorkdirRoot::overwrite_in_place`] truncates; it
3860/// does not replace): Landlock grants the file by inode and the composed root binds it by path, so
3861/// replacing the file would leave `/etc/passwd` bound but denied (`EACCES`).
3862#[cfg(target_os = "linux")]
3863fn ensure_sealed_identity_files(
3864    sealed_etc_root: &crate::workdir_fs::WorkdirRoot,
3865    home_root: &crate::workdir_fs::WorkdirRoot,
3866) -> Result<Vec<PathBuf>, String> {
3867    // The home folder was created by this process, so its owner is the identity the capsule's
3868    // subprocesses run as — no `getuid(2)` FFI needed under `#![deny(unsafe_code)]`.
3869    let (uid, gid) = home_root.owner().map_err(|error| {
3870        format!(
3871            "sealed: failed to stat the session's home folder for the capsule's uid/gid: {error}"
3872        )
3873    })?;
3874    // The same string `build_shell_env` puts in `$HOME`: the home folder's own absolute path,
3875    // which the composed root binds at that same path.
3876    let home = home_root.path().to_string_lossy().into_owned();
3877    let identity = crate::sealed::SealedAccountIdentity {
3878        uid,
3879        gid,
3880        home: &home,
3881    };
3882
3883    let mut written = Vec::new();
3884    for entry in crate::sealed::SEALED_ETC_PATHS {
3885        let Some(file) = entry.synthetic else {
3886            continue;
3887        };
3888        sealed_etc_root
3889            .overwrite_in_place(
3890                file.file_name(),
3891                file.render(&identity)?.as_bytes(),
3892                crate::workdir_fs::DEFAULT_FILE_MODE,
3893            )
3894            .map_err(|error| {
3895                format!(
3896                    "sealed: failed to write the synthetic {}: {error}",
3897                    entry.path
3898                )
3899            })?;
3900        written.push(crate::sealed::synthetic_etc_source(
3901            sealed_etc_root.path(),
3902            file,
3903        ));
3904    }
3905    Ok(written)
3906}
3907
3908/// One host path's descriptor, opened in the parent at each spawn: its Landlock rule's fd on both
3909/// Landlock tiers and, on `KernelSealed`, the identity its bind verifies.
3910#[cfg(target_os = "linux")]
3911pub(crate) struct HostPathFd {
3912    fd: Arc<std::os::fd::OwnedFd>,
3913    path: PathBuf,
3914    read_write: bool,
3915}
3916
3917/// The descriptors a composed root's runtime-provided binds are verified against and its Landlock
3918/// rules are taken on, opened in the parent before the fork.
3919#[cfg(target_os = "linux")]
3920pub(crate) struct SealedSourceFds {
3921    /// The session's temporary folder, reopened from its root's held descriptor: the `/tmp`
3922    /// rule's fd.
3923    tmp_store: Arc<std::os::fd::OwnedFd>,
3924    /// The folder's host path, which the child opens and verifies against [`Self::tmp_store`].
3925    tmp_store_path: PathBuf,
3926    /// The session's home folder, reopened from its root's held descriptor, and its absolute
3927    /// path: its rule's fd and its read-write bind at that same path.
3928    home_dir: (Arc<std::os::fd::OwnedFd>, PathBuf),
3929    /// A native tool's store, the descriptor `prepare_enforcement` took on
3930    /// [`ShellEnforcement::native_state_dir`], and its absolute path: its rule's fd and its
3931    /// read-write bind at that same path. `None` for every other spawn.
3932    state_dir: Option<(Arc<std::os::fd::OwnedFd>, PathBuf)>,
3933    /// The runtime directory, reopened from its root's held descriptor: its rule's fd.
3934    runtime_dir: Arc<std::os::fd::OwnedFd>,
3935    /// The runtime directory's absolute path, which the bind reproduces inside the root and the
3936    /// child verifies against [`Self::runtime_dir`].
3937    runtime_dir_path: PathBuf,
3938    /// The records directory, reopened from its root's held descriptor, and its absolute path:
3939    /// its rule's fd and its read-only bind, on the runtime directory's terms. `None` for an
3940    /// enforcement held outside a session.
3941    records_dir: Option<(Arc<std::os::fd::OwnedFd>, PathBuf)>,
3942    /// `sealed-etc/`'s host path.
3943    etc_dir: PathBuf,
3944    /// The synthetic `/etc` files, in [`crate::sealed::SEALED_ETC_PATHS`] order.
3945    identity_files: Vec<PathBuf>,
3946}
3947
3948#[cfg(target_os = "linux")]
3949impl SealedSourceFds {
3950    /// Writes the synthetic `/etc` files and takes every descriptor, with `temp_dir_fd` and
3951    /// `home_dir_fd` the descriptors `prepare_enforcement` took on the session's temporary and
3952    /// home folders, and `state_dir` the one it took on a native tool's store. A sealed root
3953    /// cannot be composed for an enforcement that holds no runtime directory, no temporary folder
3954    /// or no home folder.
3955    fn open(
3956        enforcement: &ShellEnforcement,
3957        temp_dir_fd: Option<&Arc<std::os::fd::OwnedFd>>,
3958        home_dir_fd: Option<&Arc<std::os::fd::OwnedFd>>,
3959        state_dir: Option<(Arc<std::os::fd::OwnedFd>, PathBuf)>,
3960    ) -> Result<Self, String> {
3961        let roots = enforcement.runtime_dir.as_ref().ok_or_else(|| {
3962            "sealed: a composed root needs the session's runtime directory, and this \
3963             enforcement holds none"
3964                .to_string()
3965        })?;
3966        let (Some(temp), Some(tmp_store)) = (roots.temp.as_ref(), temp_dir_fd) else {
3967            return Err(
3968                "sealed: a composed root binds the session's temporary folder at /tmp, \
3969                        and this enforcement holds none"
3970                    .to_string(),
3971            );
3972        };
3973        let tmp_store = Arc::clone(tmp_store);
3974        let (Some(home), Some(home_fd)) = (roots.home.as_ref(), home_dir_fd) else {
3975            return Err(crate::shell::NO_HOME_FOLDER_REFUSAL.to_string());
3976        };
3977        let home_dir = (Arc::clone(home_fd), home.path().to_path_buf());
3978        let identity_files = ensure_sealed_identity_files(&roots.sealed_etc, home)?;
3979        let runtime_dir = Arc::new(roots.runtime.path_fd().map_err(|error| {
3980            format!("sealed: failed to open the runtime directory for its bind: {error}")
3981        })?);
3982        let records_dir = roots
3983            .records
3984            .as_ref()
3985            .map(|records| {
3986                records
3987                    .path_fd()
3988                    .map(|fd| (Arc::new(fd), records.path().to_path_buf()))
3989                    .map_err(|error| {
3990                        format!(
3991                            "sealed: failed to open the records directory for its bind: {error}"
3992                        )
3993                    })
3994            })
3995            .transpose()?;
3996        Ok(Self {
3997            tmp_store,
3998            tmp_store_path: temp.path().to_path_buf(),
3999            home_dir,
4000            state_dir,
4001            runtime_dir,
4002            runtime_dir_path: roots.runtime.path().to_path_buf(),
4003            records_dir,
4004            etc_dir: roots.sealed_etc.path().to_path_buf(),
4005            identity_files,
4006        })
4007    }
4008
4009    /// `(st_dev, st_ino)` of `fd`, which a bind's source must still have in the child.
4010    fn identity_of(fd: &std::os::fd::OwnedFd) -> Result<(u64, u64), String> {
4011        crate::workdir_fs::fd_identity(fd)
4012            .map_err(|errno| format!("sealed: fstat of a bind source failed: {errno}"))
4013    }
4014}
4015
4016/// Parent-side half of the sealed mechanism: pick a base, plan the composed root against the real
4017/// host layout, and lower the plan into the C-string form the forked child executes.
4018///
4019/// Every failure here is synchronous and named, before `.spawn()` is called at all — the same
4020/// reason `open_landlock_fds` moved its `open()` calls out of `pre_exec`. A capsule that reaches
4021/// this point has already cleared `check_containment_floor`, so a failure here is a genuine
4022/// host-state surprise (an unwritable workdir, a host with none of the base candidates), not a
4023/// declared-vs-achieved mismatch.
4024///
4025/// `staged_runtime_read_only` is passed straight through to the planner rather than validated
4026/// here. A missing `source_path` is *not* diagnosed in this function on purpose: a parent-side
4027/// error would return `Err(String)`, which converts to the ordinary, retryable
4028/// `ShellExecError::Failed`. Letting the required bind fail at `mount(2)` in the child instead
4029/// routes it to the session-fatal `SealedRootConstructionFailed` (`E-RUN-014`) — the correct
4030/// classification for a capsule that did not get a runtime tree it declared.
4031#[cfg(target_os = "linux")]
4032fn build_sealed_root(
4033    accessible_workdir: &Path,
4034    sources: &SealedSourceFds,
4035    extra_read_only: &[PathBuf],
4036    staged_runtime_read_only: &[PathBuf],
4037    host_paths: &[HostPathFd],
4038    host_path_read_only: &[PathBuf],
4039) -> Result<crate::sealed::SealedRootSpec, String> {
4040    use crate::sealed;
4041
4042    // The workdir must be absolute for its path to mean the same thing inside the composed root,
4043    // and `launch_session` always creates it as one — but the composed root's whole design rests
4044    // on it, so it is checked rather than assumed.
4045    if !accessible_workdir.is_absolute() {
4046        return Err(format!(
4047            "sealed: capsule workdir {} is not absolute; a composed root reproduces the workdir at \
4048             its own absolute path",
4049            accessible_workdir.display()
4050        ));
4051    }
4052
4053    let host_path_binds = host_paths
4054        .iter()
4055        .map(|host_path| {
4056            SealedSourceFds::identity_of(&host_path.fd).map(|identity| sealed::HostPathBind {
4057                path: host_path.path.clone(),
4058                identity,
4059                read_only: !host_path.read_write,
4060            })
4061        })
4062        .collect::<Result<Vec<_>, _>>()?;
4063    let host_path_dirs: Vec<PathBuf> = host_paths
4064        .iter()
4065        .map(|host_path| host_path.path.clone())
4066        .collect();
4067    let base = sealed::choose_root_base(
4068        accessible_workdir,
4069        &host_path_dirs,
4070        sealed::SEALED_ROOT_BASE_CANDIDATES,
4071        |path| path.is_dir(),
4072    )
4073    .ok_or_else(|| {
4074        format!(
4075            "sealed: no usable base directory for the composed root; none of {:?} exists outside \
4076             the session workdir's own path and every host path's",
4077            sealed::SEALED_ROOT_BASE_CANDIDATES
4078        )
4079    })?;
4080
4081    let plan = sealed::plan_composed_root(
4082        accessible_workdir,
4083        &sealed::SealedBindSources {
4084            tmp_store: (
4085                &sources.tmp_store_path,
4086                SealedSourceFds::identity_of(&sources.tmp_store)?,
4087            ),
4088            etc_dir: &sources.etc_dir,
4089            runtime_dir: Some((
4090                &sources.runtime_dir_path,
4091                SealedSourceFds::identity_of(&sources.runtime_dir)?,
4092            )),
4093            records_dir: sources
4094                .records_dir
4095                .as_ref()
4096                .map(|(fd, path)| {
4097                    SealedSourceFds::identity_of(fd).map(|identity| (path.as_path(), identity))
4098                })
4099                .transpose()?,
4100            home_dir: Some((
4101                &sources.home_dir.1,
4102                SealedSourceFds::identity_of(&sources.home_dir.0)?,
4103            )),
4104            state_dir: sources
4105                .state_dir
4106                .as_ref()
4107                .map(|(fd, path)| {
4108                    SealedSourceFds::identity_of(fd).map(|identity| (path.as_path(), identity))
4109                })
4110                .transpose()?,
4111            host_paths: &host_path_binds,
4112            host_path_read_only,
4113        },
4114        &base,
4115        extra_read_only,
4116        staged_runtime_read_only,
4117        &sealed::RealHostLayout,
4118    );
4119    sealed::build_sealed_root_spec(&plan)
4120}
4121
4122/// Linux-only mechanics: the `pre_exec` enforcement sequence, seccomp filter construction and
4123/// Landlock application.
4124///
4125/// Nothing here uses `libseccomp`'s notify facility. `connect`/`sendto` are enforced by the
4126/// network namespace and `execve`/`execveat` by Landlock `Execute` rights, both of which decide
4127/// from kernel-held state. A `Notify` rule would reintroduce a `/proc/<pid>/mem` read on the hot
4128/// path, where the value read can change between the decision and the kernel's own dereference.
4129#[cfg_attr(target_os = "linux", allow(unsafe_code))]
4130#[cfg(target_os = "linux")]
4131mod linux_enforce {
4132    use std::io;
4133    use std::os::fd::{AsFd, OwnedFd, RawFd};
4134    use std::path::Path;
4135
4136    use landlock::{make_bitflags, AccessFs, BitFlags};
4137
4138    use super::{EnforcementTier, LandlockGrant};
4139
4140    /// Longest diagnostic message written to (or read from) the child-failure pipe. A message
4141    /// naming the failed step is far shorter than this; the bound just keeps the best-effort
4142    /// `write`/`read` loops trivially terminating.
4143    const MAX_DIAG_LEN: usize = 1024;
4144
4145    /// One Landlock grant path, opened (`O_PATH | O_CLOEXEC`) in the PARENT before fork(), paired
4146    /// with the two bits that decide its rule's access set: `list_dir` (`ReadDir`) and
4147    /// `executable` (`Execute`). Only the already-open fd crosses into the child's `pre_exec` —
4148    /// never a path to re-open there.
4149    pub(super) struct OpenLandlockGrant {
4150        fd: OwnedFd,
4151        list_dir: bool,
4152        executable: bool,
4153    }
4154
4155    /// One entry of [`CAPSULE_DEVICE_GRANTS`], opened (`O_PATH | O_CLOEXEC`) in the PARENT before
4156    /// fork(), paired with the `writable` bit that decides whether its rule carries `WriteFile` on
4157    /// top of `ReadFile`. Structurally a sibling of [`OpenLandlockGrant`] rather than the same
4158    /// type, mirroring the split between [`CapsuleDeviceGrant`](super::CapsuleDeviceGrant) and
4159    /// [`LandlockGrant`]: read+execute-a-binary and read/write-a-device are different intents and
4160    /// their bits must not be confusable.
4161    pub(super) struct OpenDeviceGrant {
4162        fd: OwnedFd,
4163        writable: bool,
4164    }
4165
4166    /// All Landlock file descriptors resolved in the parent for one shell subprocess: the
4167    /// workdir's fd and the session's temporary folder's (both scoped by
4168    /// [`workdir_access_rights`]), each successfully-opened grant fd, and each
4169    /// successfully-opened fixed-device fd.
4170    /// Handed by reference into the child's `pre_exec`, where `apply_landlock_scope` builds rules
4171    /// against these fds without performing a single `open()`.
4172    pub(super) struct LandlockChildFds {
4173        workdir_fd: OwnedFd,
4174        /// The capsule's `capabilities.filesystem.workdir_exec`, carried alongside the fd it
4175        /// applies to so `apply_landlock_scope` — which runs in `pre_exec` and must read nothing
4176        /// but its argument — has the whole workdir rule decided for it before fork.
4177        workdir_exec: bool,
4178        /// The Landlock ABI the ruleset is built at, decided in the parent from the session's
4179        /// [`super::HostProbe`] (see [`super::ShellEnforcement::landlock_abi`]) so the child reads
4180        /// no host state of its own. `None` builds at ABI v1, the narrowest set.
4181        abi: Option<u8>,
4182        /// The session's temporary folder, `tmp/` in its records directory, on both Landlock
4183        /// tiers whenever the enforcement holds one, mapped onto `/tmp` or not. `None` outside a
4184        /// session. On `KernelSealed` the same descriptor is the composed root's `/tmp` bind
4185        /// source.
4186        ///
4187        /// It needs a rule of its own on every mapped tier: Landlock resolves a path by walking
4188        /// dentries up to each mount root, so an access to `/tmp/x` is matched against the folder
4189        /// (the bind's root) and then against the mountpoint `/tmp` — never against the records
4190        /// directory or the workdir. It carries the workdir's own right-set, `Execute` axis
4191        /// included, so `workdir_exec: false` forbids running anything written there.
4192        temp_dir_fd: Option<std::sync::Arc<OwnedFd>>,
4193        /// The session's home folder, `home/` in its records directory, on exactly
4194        /// [`Self::temp_dir_fd`]'s terms: the workdir's rights, on both Landlock tiers, whenever the
4195        /// enforcement holds one. On `KernelSealed` the same descriptor is the read-write bind's
4196        /// source.
4197        home_dir_fd: Option<std::sync::Arc<OwnedFd>>,
4198        /// A native tool's store ([`super::ShellEnforcement::native_state_dir`]), on
4199        /// [`Self::home_dir_fd`]'s terms but never with `Execute`: the store holds data, never
4200        /// code. `None` for every spawn but that tool's. On `KernelSealed` the same descriptor is
4201        /// the read-write bind's source.
4202        state_dir_fd: Option<std::sync::Arc<OwnedFd>>,
4203        /// The synthetic `/etc/passwd`/`/etc/group` in the session's `sealed-etc/`, opened on
4204        /// `KernelSealed` and empty on every other tier.
4205        ///
4206        /// Exactly the [`Self::temp_dir_fd`] problem, one directory over. The grant list already
4207        /// carries `/etc/passwd`, but that rule is taken on the fd opened at the *host's*
4208        /// `/etc/passwd` — a different inode from the file the composed root actually binds there.
4209        /// Landlock matches inodes, not path strings, and the walk from a bind-mounted file starts
4210        /// at the bound file's own inode, so without these fds both account databases would be
4211        /// mounted and `EACCES`: `getpwuid(3)` fails, and a shell cannot expand `~`.
4212        ///
4213        /// Read-only, like every other `SEALED_ETC_PATHS` rule — the mount is `MS_RDONLY` too, and
4214        /// nothing inside a capsule has any business editing an account database.
4215        sealed_identity_fds: Vec<OwnedFd>,
4216        /// The session's runtime directory, reopened from its root's held descriptor: `ReadFile`
4217        /// and `ReadDir` and nothing else, so every command reads `MURMUR.md`, `task.md`, plans
4218        /// and installed tools and changes none of them. `None` outside a session. On
4219        /// `KernelSealed` the same descriptor is the read-only bind's source.
4220        runtime_dir_fd: Option<std::sync::Arc<OwnedFd>>,
4221        /// The session's records directory, reopened from its root's held descriptor, granted on
4222        /// [`Self::runtime_dir_fd`]'s terms: every command reads the trace and logs and changes
4223        /// none of them. `None` outside a session. On `KernelSealed` the same descriptor is the
4224        /// read-only bind's source.
4225        records_dir_fd: Option<std::sync::Arc<OwnedFd>>,
4226        /// Every host path's descriptor and whether it is `read-write`, in config order: one rule
4227        /// each, with [`host_path_access_rights`]. Opened fail-closed by `prepare_enforcement`.
4228        host_path_fds: Vec<(std::sync::Arc<OwnedFd>, bool)>,
4229        grants: Vec<OpenLandlockGrant>,
4230        devices: Vec<OpenDeviceGrant>,
4231    }
4232
4233    impl LandlockChildFds {
4234        /// The temporary folder's fd, for the tier-gate test. Keeps the struct's fields private
4235        /// like every other one: nothing outside `apply_landlock_scope` needs the fd itself.
4236        #[cfg(test)]
4237        pub(super) fn temp_dir_fd(&self) -> Option<&OwnedFd> {
4238            self.temp_dir_fd.as_deref()
4239        }
4240
4241        /// The home folder's fd, for the tier-gate test, on the same terms.
4242        #[cfg(test)]
4243        pub(super) fn home_dir_fd(&self) -> Option<&OwnedFd> {
4244            self.home_dir_fd.as_deref()
4245        }
4246
4247        /// A native tool's store fd, for the tier-gate test, on the same terms.
4248        #[cfg(test)]
4249        pub(super) fn state_dir_fd(&self) -> Option<&OwnedFd> {
4250            self.state_dir_fd.as_deref()
4251        }
4252
4253        /// The runtime directory's fd, for the tier-gate test, on the same terms.
4254        #[cfg(test)]
4255        pub(super) fn runtime_dir_fd(&self) -> Option<&OwnedFd> {
4256            self.runtime_dir_fd.as_deref()
4257        }
4258
4259        /// The records directory's fd, for the tier-gate test, on the same terms.
4260        #[cfg(test)]
4261        pub(super) fn records_dir_fd(&self) -> Option<&OwnedFd> {
4262            self.records_dir_fd.as_deref()
4263        }
4264
4265        /// How many synthetic-`/etc` fds were opened, for the tier-gate test. A count, not the fds:
4266        /// the same "nothing outside `apply_landlock_scope` needs the fd itself" rule as above.
4267        #[cfg(test)]
4268        pub(super) fn sealed_identity_fd_count(&self) -> usize {
4269            self.sealed_identity_fds.len()
4270        }
4271
4272        /// How many grant fds were opened, for the pinned-grant test. A count, for the same reason.
4273        #[cfg(test)]
4274        pub(super) fn grant_count(&self) -> usize {
4275            self.grants.len()
4276        }
4277    }
4278
4279    /// The Landlock rights granted on the capsule **workdir's own** `PathBeneath` rule,
4280    /// *excluding* `Execute` — see [`workdir_access_rights`], which adds it back for a capsule that
4281    /// declared `capabilities.filesystem.workdir_exec: true`.
4282    ///
4283    /// Spelled out variant by variant rather than `AccessFs::from_all` for two reasons: `from_all`
4284    /// hides what is actually handed to the capsule, and four rights are withheld from this
4285    /// constant. It is the set for [`super::LANDLOCK_TARGET_ABI`]; `apply_landlock_scope` masks it
4286    /// to the ABI the ruleset is built at, so on an older kernel the rights that ABI does not know
4287    /// simply drop out.
4288    ///
4289    /// Two rights are granted from an ABI later than v1:
4290    ///
4291    ///   - `Refer` (ABI v2, Linux 5.19) — moving and hard-linking a file between two folders.
4292    ///     Landlock refuses every cross-folder `rename(2)`/`link(2)` with `EXDEV` unless the ruleset
4293    ///     handles `Refer` and both folders grant it, so without it every tool that renames a
4294    ///     finished file into place from a sibling folder fails in the workdir. It reaches nothing
4295    ///     outside: a move or link needs `Refer` on both the source and the destination folder, no
4296    ///     rule outside the workdir and the session's temporary folder carries it, and Landlock
4297    ///     also refuses any move that would leave the file with more rights at its destination
4298    ///     than its source.
4299    ///   - `Truncate` (ABI v3, Linux 6.2) — `truncate(2)`, `ftruncate(2)` and `open(O_TRUNC)`.
4300    ///     Below v3 Landlock does not mediate truncation at all, so a capsule could zero any host
4301    ///     file its uid can write. Handling it denies that everywhere except the rules that grant
4302    ///     it, which are exactly the ones already carrying `WriteFile`: this one and the writable
4303    ///     devices.
4304    ///
4305    ///   - `Execute` — the one this constant withholds *conditionally*. Withholding it is what
4306    ///     makes `capabilities.shell.allow` a complete, sound statement: Landlock refuses the exec
4307    ///     on the path the kernel itself resolved, so a binary the capsule wrote into its workdir
4308    ///     cannot run under any name — including an allowlisted basename, the exact bypass a prior
4309    ///     release shipped. The cost: a binary the capsule legitimately compiled in its workdir
4310    ///     cannot run either, which is what `workdir_exec: true` buys back.
4311    ///   - `MakeChar` / `MakeBlock` — creating a device node inside the workdir escapes this
4312    ///     whole scope. A capsule running as root could `mknod` a node for the host's own disk
4313    ///     (e.g. major/minor `8:0` = `sda`) *inside* the granted directory — which Landlock
4314    ///     permits, because the new inode lives beneath a granted path — then `open()` it and
4315    ///     read the raw host filesystem underneath every other restriction. No known workload
4316    ///     creates device nodes in its working tree.
4317    ///   - `MakeSock` — binding an `AF_UNIX` socket file. Withheld by the same rule (no
4318    ///     filesystem object with a kernel-side identity beyond a regular file), but unlike the
4319    ///     two device rights this one is a genuine open question: some build tooling and some
4320    ///     language-toolchain daemons `bind()` a unix socket in their working tree, and a fix
4321    ///     that breaks those is not a fix. The manual acceptance procedure in
4322    ///     `docs/content/reference/workdir-device-node-manual-verification.md` carries an
4323    ///     explicit unix-socket scenario for exactly this; if the team's
4324    ///     real-hardware run finds a workload that needs it, add `MakeSock` back to this list.
4325    ///
4326    /// `MakeFifo` stays granted: real build tooling does create named pipes in its working tree,
4327    /// and a FIFO carries none of the raw-device risk above.
4328    ///
4329    /// Because `apply_landlock_scope`'s `handle_access` declares the *full* `from_all` set of the
4330    /// ABI it builds at, a right this rule withholds is not merely "not extra-granted" — it
4331    /// is **denied on every path this rule covers**, workdir included, once `restrict_self()` takes
4332    /// effect — so withholding `Execute` here is enforcement, not a mere omission. The three
4333    /// `Make*` rights are denied domain-wide because no other rule grants them either; `Execute`
4334    /// is denied *on the workdir specifically*, while the narrow read+execute grants below still
4335    /// carry it for the allowlisted binaries outside it: exec where the operator named a binary,
4336    /// nowhere the capsule writes.
4337    pub(super) const WORKDIR_ACCESS_RIGHTS_NO_EXEC: BitFlags<AccessFs> = make_bitflags!(AccessFs::{
4338        WriteFile
4339            | ReadFile
4340            | ReadDir
4341            | RemoveDir
4342            | RemoveFile
4343            | MakeDir
4344            | MakeReg
4345            | MakeFifo
4346            | MakeSym
4347            | Refer
4348            | Truncate
4349    });
4350
4351    /// The workdir's `PathBeneath` rights for one capsule: [`WORKDIR_ACCESS_RIGHTS_NO_EXEC`], plus
4352    /// `Execute` only when the manifest declared `capabilities.filesystem.workdir_exec: true`.
4353    ///
4354    /// A function rather than a second constant so the two cases cannot drift: adding a right to
4355    /// the workdir grant is one edit, and the `workdir_exec` axis stays exactly one bit wide.
4356    /// Note what this does *not* consult — `shell.allow`, the exec allowlist, any resolved path.
4357    /// The whole point of the default is that the workdir grant is no longer name-based at all.
4358    pub(super) fn workdir_access_rights(workdir_exec: bool) -> BitFlags<AccessFs> {
4359        if workdir_exec {
4360            WORKDIR_ACCESS_RIGHTS_NO_EXEC | AccessFs::Execute
4361        } else {
4362            WORKDIR_ACCESS_RIGHTS_NO_EXEC
4363        }
4364    }
4365
4366    /// A host path's `PathBeneath` rights: `ReadFile | ReadDir` for `read`, and
4367    /// [`workdir_access_rights`]`(false)` for `read-write` — the native tool store's right-set.
4368    /// Never `Execute`, whatever `workdir_exec` grants the workdir: a host path makes files
4369    /// reachable, never runnable, so `shell.allow` stays the whole list of what can run.
4370    pub(super) fn host_path_access_rights(read_write: bool) -> BitFlags<AccessFs> {
4371        if read_write {
4372            workdir_access_rights(false)
4373        } else {
4374            AccessFs::ReadFile | AccessFs::ReadDir
4375        }
4376    }
4377
4378    /// The kernel's Landlock ABI version, or `None` when the kernel has no Landlock.
4379    ///
4380    /// `landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION)` returns the version and
4381    /// creates nothing — no ruleset fd and no domain — so unlike
4382    /// [`probe_landlock_full_access`] it is safe to call in-process. A kernel built without
4383    /// Landlock, or with it disabled at boot, fails it with `ENOSYS`/`EOPNOTSUPP`.
4384    pub(super) fn kernel_landlock_abi() -> Option<u8> {
4385        const LANDLOCK_CREATE_RULESET_VERSION: u32 = 1;
4386        // SAFETY: with a null attribute pointer, a zero size and only the VERSION flag set, the
4387        // kernel reads no memory and allocates no fd; the call has no side effect to undo.
4388        let version = unsafe {
4389            libc::syscall(
4390                libc::SYS_landlock_create_ruleset,
4391                std::ptr::null::<libc::c_void>(),
4392                0usize,
4393                LANDLOCK_CREATE_RULESET_VERSION,
4394            )
4395        };
4396        if version <= 0 {
4397            return None;
4398        }
4399        Some(u8::try_from(version).unwrap_or(u8::MAX))
4400    }
4401
4402    /// Probes real Landlock support by building a ruleset granting full access to `/` and
4403    /// calling `restrict_self()` **in a forked child**, so the host process is never placed
4404    /// into a Landlock domain by a mere capability check.
4405    ///
4406    /// Forking is required, not defensive. The rights the ruleset grants are beside the point:
4407    /// entering *any* Landlock domain — even one permitting everything — permanently forbids the
4408    /// mount family to the task and every descendant it will ever have, which is how Landlock
4409    /// stops a sandbox being escaped through a nested namespace. [`HostProbe::probe`] runs this
4410    /// before the sealed probe, and a session's probe is taken before any fork that would
4411    /// build a composed root, so restricting in-process poisons `sealed` on every host and every
4412    /// invocation: the namespace is created and then `mount` is refused, with nothing in the error
4413    /// to suggest the runtime did it to itself.
4414    ///
4415    /// The ruleset is built in the parent because creating one and adding rules restricts
4416    /// nothing; only `restrict_self` applies it. That keeps everything the child does after
4417    /// the fork down to a `prctl` and one syscall.
4418    pub(super) fn probe_landlock_full_access() -> Option<bool> {
4419        use landlock::{
4420            Access, AccessFs, CompatLevel, Compatible, PathBeneath, PathFd, Ruleset, RulesetAttr,
4421            RulesetCreatedAttr, RulesetStatus, ABI,
4422        };
4423
4424        // The child reports which of the three outcomes it reached through its exit status;
4425        // nothing else crosses the fork.
4426        const PROBE_FULLY_ENFORCED: i32 = 0;
4427        const PROBE_PARTIAL: i32 = 1;
4428        const PROBE_FAILED: i32 = 2;
4429
4430        // ABI v1 on purpose: this decides whether a Landlock tier is available at all, which v1
4431        // answers on every Landlock kernel. The ABI a capsule's ruleset is built at is read
4432        // separately, by `kernel_landlock_abi`.
4433        let abi = ABI::V1;
4434        let access_all = AccessFs::from_all(abi);
4435
4436        let root_fd = PathFd::new("/").ok()?;
4437
4438        let ruleset = Ruleset::default()
4439            .set_compatibility(CompatLevel::BestEffort)
4440            .handle_access(access_all)
4441            .ok()?
4442            .create()
4443            .ok()?
4444            .add_rule(PathBeneath::new(root_fd, access_all))
4445            .ok()?;
4446
4447        // SAFETY: `fork()` from a possibly-multithreaded process is sound as long as the child
4448        // confines itself to async-signal-safe work. Everything this child does is the
4449        // `restrict_self` syscall pair and `_exit`; the allocating part of building the
4450        // ruleset already happened above, in the parent.
4451        let pid = unsafe { libc::fork() };
4452        if pid < 0 {
4453            return None;
4454        }
4455        if pid == 0 {
4456            let code = match ruleset.restrict_self() {
4457                Ok(status) => {
4458                    if matches!(status.ruleset, RulesetStatus::FullyEnforced) {
4459                        PROBE_FULLY_ENFORCED
4460                    } else {
4461                        PROBE_PARTIAL
4462                    }
4463                }
4464                Err(_) => PROBE_FAILED,
4465            };
4466            // SAFETY: forked-child context; `_exit` skips every destructor and atexit hook,
4467            // which is what keeps the parent's state untouched.
4468            unsafe { libc::_exit(code) }
4469        }
4470
4471        let mut wait_status: libc::c_int = 0;
4472        // SAFETY: `pid` is the child just forked; `wait_status` is a live local.
4473        let waited = unsafe { libc::waitpid(pid, &mut wait_status, 0) };
4474        if waited < 0 || !libc::WIFEXITED(wait_status) {
4475            return None;
4476        }
4477        match libc::WEXITSTATUS(wait_status) {
4478            PROBE_FULLY_ENFORCED => Some(true),
4479            PROBE_PARTIAL => Some(false),
4480            // A `restrict_self` that errored tells us nothing either way, which is the same
4481            // answer the pre-fork version gave by propagating `None` out of `.ok()?`.
4482            _ => None,
4483        }
4484    }
4485
4486    /// Runs inside the forked child, pre-exec: joins the session's network namespace through
4487    /// `user_ns_fd`/`net_ns_fd`, maps `/tmp` onto the session's temporary folder when
4488    /// `tmp_map_spec` is given, strips the child's Linux capabilities, installs the seccomp filter
4489    /// (the `socket()` domain denials keyed on `unix_sockets_allowed`, over a default-deny syscall
4490    /// allowlist), and (on `KernelFull`/`KernelSealed`) applies the Landlock filesystem scope —
4491    /// which is also what enforces `capabilities.shell.allow`. Returning `Err` here aborts the
4492    /// exec (std's `Command` machinery propagates it back to the parent's `.spawn()` call as an
4493    /// `io::Error`) — the fail-closed path for setup failures that happen after fork.
4494    pub(super) fn child_install_enforcement(
4495        tier: EnforcementTier,
4496        user_ns_fd: RawFd,
4497        net_ns_fd: RawFd,
4498        tmp_map_spec: Option<&crate::temp_dir::TmpMapSpec>,
4499        sealed_spec: Option<&crate::sealed::SealedRootSpec>,
4500        landlock_fds: Option<&LandlockChildFds>,
4501        unix_sockets_allowed: bool,
4502    ) -> io::Result<()> {
4503        // FIRST, on every kernel tier: join the session's network namespace. It has to precede
4504        // everything below it for three separate reasons:
4505        //
4506        //   * `SECCOMP_MUST_STAY_DENIED` denies `setns`, so the filter installed further down
4507        //     closes the only window in which the join can happen — the same argument the
4508        //     composed root makes for `unshare`.
4509        //   * joining the user namespace hands this child every capability *inside* it, and
4510        //     `drop_all_capabilities` (below) strips them again before `execve`. Joining after
4511        //     that drop would fail: entering the network namespace needs `CAP_SYS_ADMIN` there.
4512        //   * on `KernelSealed`, the composed root's own `unshare(CLONE_NEWUSER)` nests its user
4513        //     namespace inside the session's. A task in a descendant user namespace holds no
4514        //     capability in the ancestor, so the capsule cannot reconfigure the network namespace
4515        //     it is confined to — which only holds if the join comes first.
4516        //
4517        // There is no path that skips it and falls back: a host that cannot create the namespace
4518        // was refused at launch (`RuntimeError::EgressNamespaceUnavailable`, `E-CAP-005`), and a
4519        // spawn whose namespace could not be established failed before the fork.
4520        //
4521        // SAFETY: post-`fork()`, pre-exec child context — exactly the window this function
4522        // documents itself as running in, and the one `join_session_netns` requires. Both
4523        // descriptors are held open by the join handle the `pre_exec` closure owns.
4524        unsafe { crate::network_namespace::join_session_netns(user_ns_fd, net_ns_fd) }.map_err(
4525            |error| {
4526                io::Error::other(format!(
4527                    "sandbox: joining this session's network namespace failed: {error}"
4528                ))
4529            },
4530        )?;
4531
4532        // NEXT, and only on a `MountNamespace` spawn: a private mount namespace, owned by the
4533        // session's user namespace, in which the session's temporary folder is bound over `/tmp`.
4534        // Here for the composed root's reason: the capabilities the join above handed out are
4535        // still held, and the seccomp filter below denies `unshare` and `mount`. A mapping that
4536        // cannot be built fails the spawn; nothing runs with an unmapped `/tmp` instead.
4537        if let Some(spec) = tmp_map_spec {
4538            #[cfg(test)]
4539            if super::FORCE_TMP_MAP_FAILURE.with(|flag| flag.get()) {
4540                return Err(io::Error::other(format!(
4541                    "{} (forced by test seam)",
4542                    spec.describe(crate::temp_dir::TmpMapFailure::forced(libc::EPERM))
4543                )));
4544            }
4545            // SAFETY: post-`fork()`, pre-exec child context, after `join_session_netns` handed
4546            // this child every capability in the session's user namespace and before the seccomp
4547            // filter — the window `map_private_tmp` requires. Every path and buffer it reads was
4548            // built in the parent and is borrowed from the `pre_exec` closure.
4549            unsafe { crate::temp_dir::map_private_tmp(spec) }
4550                .map_err(|failure| io::Error::other(spec.describe(failure)))?;
4551        }
4552
4553        // NEXT, and only on `KernelSealed`: build the private mount namespace and pivot onto the
4554        // composed root. This has to precede every *remaining* step in this closure, because
4555        // `SECCOMP_MUST_STAY_DENIED` denies `unshare`, `mount` and `pivot_root` to every process
4556        // the filter below covers, permanently and deliberately.
4557        // The composed root is therefore built by this process while it still has its pre-filter
4558        // credentials, never by a syscall the sandboxed subprocess is later permitted to make.
4559        // The three steps that follow then run *inside* the new root: `drop_all_capabilities`
4560        // strips the (namespace-local) capabilities `unshare` handed out, seccomp installs, and
4561        // Landlock's rules — built from fds the parent opened, so unaffected by the pivot —
4562        // re-scope the same inodes under their new pathnames. Defence in depth, in that order.
4563        if tier == EnforcementTier::KernelSealed {
4564            if let Some(spec) = sealed_spec {
4565                #[cfg(test)]
4566                if super::FORCE_SEALED_ROOT_FAILURE.with(|flag| flag.get()) {
4567                    return Err(io::Error::other(format!(
4568                        "{} unshare(CLONE_NEWUSER|CLONE_NEWNS) failed (forced by test seam)",
4569                        crate::sealed::SEALED_ROOT_FAILURE_PREFIX
4570                    )));
4571                }
4572                crate::sealed::construct_composed_root(spec)
4573                    .map_err(|failure| io::Error::other(spec.describe(failure)))?;
4574            }
4575        }
4576
4577        // Before anything else in this window, on every kernel tier: shut the fd-inheritance
4578        // door. Landlock (KernelFull only) mediates *new* filesystem operations against the
4579        // ruleset installed further down this function — it can do nothing about a descriptor
4580        // the child already holds open at that point, because that fd was opened before the
4581        // ruleset existed and there is no operation left for the ruleset to intercept. Until
4582        // this call existed, the only thing stopping a spawned shell from inheriting an
4583        // arbitrary open fd was the empirical accident that nothing else happened to be open in
4584        // the runtime process at spawn time; one `open()` added anywhere before a spawn — a
4585        // config read, a lock file, a log handle — would have leaked silently into every
4586        // subsequent subprocess, on every platform and every tier, with no test to catch it.
4587        //
4588        // Running it here is also what makes it cover this function's *own* descriptors: the
4589        // diagnostic pipe and the Landlock grant fds are used below but must never survive the
4590        // `execve`, and neither may the session's namespace handles, already used above.
4591        mark_inherited_fds_cloexec()?;
4592
4593        // Next, and on every kernel tier (not just `KernelFull`): Landlock and the Linux
4594        // capability model are independent, both-must-allow gates, so narrowing the Landlock
4595        // workdir grant does not by itself take `CAP_MKNOD` away from a root-uid capsule. This
4596        // must also run *before* `install_seccomp_filter`, because it is what sets
4597        // `no_new_privs` — which `seccomp(2)` requires once `CAP_SYS_ADMIN` is gone. Setting it
4598        // here (rather than depending on libseccomp's `SCMP_FLTATR_CTL_NNP` default) also means a
4599        // failure is its own distinct, fail-closed error path before any filter is installed.
4600        drop_all_capabilities()?;
4601
4602        // The child stays *non-dumpable*, inherited from the runtime process
4603        // (`security::harden_process_dumpable`). Nothing reads the child's memory, so nothing
4604        // needs it dumpable. Marking it dumpable opens same-uid `ptrace`, `environ` and core-dump
4605        // access for the whole life of every shell subprocess: do not do so without a reader that
4606        // requires it.
4607        install_seccomp_filter(unix_sockets_allowed)?;
4608
4609        if matches!(
4610            tier,
4611            EnforcementTier::KernelFull | EnforcementTier::KernelSealed
4612        ) {
4613            if let Some(fds) = landlock_fds {
4614                apply_landlock_scope(fds).map_err(io::Error::other)?;
4615            }
4616        }
4617
4618        Ok(())
4619    }
4620
4621    /// Marks every file descriptor at or above [`super::FD_HYGIENE_FIRST_FD`] close-on-exec, so
4622    /// the only descriptors that survive into the exec'd program are the ones the runtime
4623    /// deliberately set up (stdio) rather than whatever happened to be open.
4624    ///
4625    /// `CLOSE_RANGE_CLOEXEC` sets the flag rather than closing the descriptors outright. That is
4626    /// the required semantics here, not a softer variant of it: several fds in this range are
4627    /// still needed *inside* this `pre_exec` window — the diagnostic pipe a failure further down
4628    /// writes to, and the Landlock grant fds `apply_landlock_scope` builds rules from. Closing
4629    /// them here would break the very enforcement setup that follows; flagging them means they
4630    /// keep working until `execve`, which then closes them for us.
4631    ///
4632    /// The range starts at 3, never 0 — see [`super::FD_HYGIENE_FIRST_FD`] for the recorded
4633    /// stdio decision and its reasoning.
4634    ///
4635    /// **Kernel range narrowing:** `close_range(2)` landed in Linux 5.9 and
4636    /// `CLOSE_RANGE_CLOEXEC` in Linux 5.11. On an older kernel this call fails (`ENOSYS`, or
4637    /// `EINVAL` for the flag on 5.9/5.10), the error propagates, and `Command::spawn()` fails —
4638    /// so every spawn on a `KernelSeccompOnly` *or* `KernelFull` host with such a kernel
4639    /// now fails rather than silently running without fd hygiene. That is deliberate and it is
4640    /// this module's established fail-closed discipline (`drop_all_capabilities`,
4641    /// `install_seccomp_filter` and `apply_landlock_scope` all abort the spawn on an unexpected
4642    /// error rather than degrade), but it is a real, user-visible narrowing of the supported
4643    /// kernel range for kernel-enforcement tiers.
4644    pub(super) fn mark_inherited_fds_cloexec() -> io::Result<()> {
4645        // SAFETY: this issues the `close_range` syscall directly through `libc::syscall`. The
4646        // three variadic arguments are all `c_uint` scalars and the kernel dereferences none of
4647        // them. It is a single syscall, so it is safe to make in the post-fork/pre-exec window.
4648        // `c_uint::MAX` as the `last` argument is the documented "to the end of the table"
4649        // spelling from `close_range(2)`.
4650        let rc = unsafe {
4651            libc::syscall(
4652                libc::SYS_close_range,
4653                super::FD_HYGIENE_FIRST_FD,
4654                libc::c_uint::MAX,
4655                libc::CLOSE_RANGE_CLOEXEC,
4656            )
4657        };
4658        if rc != 0 {
4659            return Err(io::Error::last_os_error());
4660        }
4661        Ok(())
4662    }
4663
4664    /// Capability-version word for the 64-bit, two-`__u32`-word capability ABI
4665    /// (`_LINUX_CAPABILITY_VERSION_3`, Linux 2.6.26+). Passing it tells the kernel the
4666    /// `cap_user_data` argument is a **two element** array. The older version-1 word would cover
4667    /// only capabilities 0–31 and make the kernel log a deprecation warning.
4668    const LINUX_CAPABILITY_VERSION_3: u32 = 0x2008_0522;
4669
4670    /// Upper bound for the bounding-set loop when `/proc/sys/kernel/cap_last_cap` cannot be read.
4671    /// 63 is the architectural ceiling — `capset`'s two 32-bit words address exactly 64
4672    /// capabilities — so this can never under-cover. Numbers the running kernel does not define
4673    /// return `EINVAL`, which the loop skips.
4674    const CAP_LAST_CAP_FALLBACK: u32 = 63;
4675
4676    /// `cap_user_header_t` from `<linux/capability.h>`. `libc` 0.2 exposes `SYS_capset` but
4677    /// neither this struct, its data counterpart, nor a safe `capset()` wrapper — so both are
4678    /// hand-rolled here, for the same reason `send_fd_over_socket` hand-rolls its
4679    /// `msghdr`/`cmsghdr` handling.
4680    ///
4681    /// `dead_code` is allowed because the kernel reads these fields through the raw pointer
4682    /// handed to `capset(2)`; Rust itself only ever writes them.
4683    #[repr(C)]
4684    #[allow(dead_code)]
4685    struct CapUserHeader {
4686        version: u32,
4687        pid: libc::c_int,
4688    }
4689
4690    /// One word of `cap_user_data_t` from `<linux/capability.h>`. Under
4691    /// [`LINUX_CAPABILITY_VERSION_3`] the kernel expects an array of exactly two of these: word 0
4692    /// carries capabilities 0–31, word 1 carries 32–63. See [`CapUserHeader`] for the `dead_code`
4693    /// note.
4694    #[repr(C)]
4695    #[derive(Clone, Copy)]
4696    #[allow(dead_code)]
4697    struct CapUserData {
4698        effective: u32,
4699        permitted: u32,
4700        inheritable: u32,
4701    }
4702
4703    /// Strips every Linux capability from the forked child before `execve()`, in three steps
4704    /// whose **order matters**:
4705    ///
4706    ///   1. drop the whole capability **bounding set** (`PR_CAPBSET_DROP`) — while this process
4707    ///      still holds `CAP_SETPCAP` in its *effective* set, because that is exactly what
4708    ///      `PR_CAPBSET_DROP` checks (`capabilities(7)`, `prctl(2)`).
4709    ///   2. clear this process's own permitted/effective/inheritable sets via `capset(2)`.
4710    ///      Shrinking your own permitted set to any subset — including empty — never requires
4711    ///      `CAP_SETPCAP`, so running it after step 1 costs nothing. Clearing permitted and
4712    ///      inheritable also empties the *ambient* set, which the kernel maintains as a subset of
4713    ///      both — that is what covers a non-root capsule handed an unexpected ambient
4714    ///      `CAP_MKNOD` (e.g. a systemd unit with `AmbientCapabilities=`).
4715    ///   3. set `no_new_privs`, so `execve()` cannot regain privilege through a set-user-ID or
4716    ///      file-capability binary, and so the seccomp filter installed next can load without
4717    ///      `CAP_SYS_ADMIN` (which step 2 just removed). Setting it here explicitly means this no
4718    ///      longer depends on libseccomp's default `SCMP_FLTATR_CTL_NNP` attribute having set it
4719    ///      as a side effect.
4720    ///
4721    /// **Why steps 1 and 2 are in this order** (it is the reverse of the obvious "clear my sets,
4722    /// then clear the bounding set"): `PR_CAPBSET_DROP` requires `CAP_SETPCAP` in the *effective*
4723    /// set of the caller. Clearing effective first would strip `CAP_SETPCAP`, after which every
4724    /// `PR_CAPBSET_DROP` returns `EPERM` — and because `EPERM` is (correctly) non-fatal for a
4725    /// genuinely unprivileged caller, the bounding set would silently survive while this function
4726    /// still reported success.
4727    ///
4728    /// **Why the bounding set is the load-bearing step for a root-uid capsule:** `execve(2)`'s
4729    /// capability transition treats a file's permitted set as all-ones when the process's real
4730    /// uid is 0, so the new program's permitted set comes out as `P(bounding)`. Step 2 alone
4731    /// would be undone by the very next `execve`; emptying the bounding set is what makes the
4732    /// post-exec permitted (and hence effective) set empty, which is what actually takes
4733    /// `CAP_MKNOD` away from the shell.
4734    ///
4735    /// Note this also removes `CAP_DAC_OVERRIDE` from a root-run capsule's shell subprocess, so
4736    /// it no longer bypasses ordinary file-permission checks. That is intended — least privilege
4737    /// — and strictly narrows what the subprocess can reach.
4738    fn drop_all_capabilities() -> io::Result<()> {
4739        drop_capability_bounding_set()?;
4740        clear_capability_sets()?;
4741        set_no_new_privs()
4742    }
4743
4744    /// Step 1 of [`drop_all_capabilities`]. Two errnos are expected and skipped:
4745    ///
4746    ///   - `EINVAL` — `cap` is not a capability this kernel defines, so there is nothing to drop.
4747    ///   - `EPERM` — the caller holds no `CAP_SETPCAP`, i.e. it is a genuinely unprivileged
4748    ///     process that never held the capability being dropped either. Hard-failing here would
4749    ///     break every non-root `mur run`.
4750    ///
4751    /// Any other errno is unexpected and propagates, aborting the exec — this module's
4752    /// fail-closed invariant: no silently-unenforced spawn.
4753    #[allow(unsafe_code)]
4754    fn drop_capability_bounding_set() -> io::Result<()> {
4755        let zero: libc::c_ulong = 0;
4756
4757        for cap in 0..=kernel_last_cap() {
4758            let cap_arg = libc::c_ulong::from(cap);
4759            // SAFETY: `prctl(PR_CAPBSET_DROP, ...)` takes an integer capability number and three
4760            // ignored arguments — no pointers and no borrowed memory are involved.
4761            let rc = unsafe { libc::prctl(libc::PR_CAPBSET_DROP, cap_arg, zero, zero, zero) };
4762            if rc == 0 {
4763                continue;
4764            }
4765
4766            let error = io::Error::last_os_error();
4767            match error.raw_os_error() {
4768                Some(libc::EINVAL) | Some(libc::EPERM) => continue,
4769                _ => return Err(error),
4770            }
4771        }
4772
4773        Ok(())
4774    }
4775
4776    /// Step 2 of [`drop_all_capabilities`]. Always expected to succeed: the kernel's `capset`
4777    /// checks are all "is the new set a subset of the old one", and the empty set is a subset of
4778    /// everything. Any error is therefore unexpected and fails closed.
4779    #[allow(unsafe_code)]
4780    fn clear_capability_sets() -> io::Result<()> {
4781        let header = CapUserHeader {
4782            version: LINUX_CAPABILITY_VERSION_3,
4783            // 0 means "the calling thread" — the only pid `capset` accepts for a capability
4784            // change since Linux 2.6.25.
4785            pid: 0,
4786        };
4787        let data = [CapUserData {
4788            effective: 0,
4789            permitted: 0,
4790            inheritable: 0,
4791        }; 2];
4792
4793        // SAFETY: both pointers refer to stack locals that outlive this single `capset` call, and
4794        // `data` is exactly the two-element array `LINUX_CAPABILITY_VERSION_3` makes the kernel
4795        // expect. The kernel only reads through them.
4796        let rc = unsafe {
4797            libc::syscall(
4798                libc::SYS_capset,
4799                &header as *const CapUserHeader,
4800                data.as_ptr(),
4801            )
4802        };
4803        if rc != 0 {
4804            return Err(io::Error::last_os_error());
4805        }
4806
4807        Ok(())
4808    }
4809
4810    /// Step 3 of [`drop_all_capabilities`]. Setting `no_new_privs` can only ever *reduce* a
4811    /// process's privilege and is never refused for a well-formed call, so any error is
4812    /// unexpected and fails closed. Same call convention and fail-closed style as
4813    /// `security::harden_process_dumpable`'s `PR_SET_DUMPABLE` `prctl`, but a different call site
4814    /// and lifetime: this is per-shell-subprocess, inside `pre_exec`, not the once-at-`main()`
4815    /// whole-process hardening.
4816    #[allow(unsafe_code)]
4817    fn set_no_new_privs() -> io::Result<()> {
4818        #[cfg(test)]
4819        if super::FORCE_NO_NEW_PRIVS_FAILURE.with(|flag| flag.get()) {
4820            return Err(io::Error::other(
4821                "sandbox: no_new_privs (prctl PR_SET_NO_NEW_PRIVS, 1) failed (forced by test seam)",
4822            ));
4823        }
4824        // The kernel rejects `PR_SET_NO_NEW_PRIVS` unless arg2 is exactly 1 and arg3/4/5 are
4825        // exactly 0, so these are typed as `c_ulong` rather than left as untyped literals whose
4826        // upper 32 bits would be unspecified in a variadic call on aarch64.
4827        let enable: libc::c_ulong = 1;
4828        let zero: libc::c_ulong = 0;
4829
4830        // SAFETY: `prctl(PR_SET_NO_NEW_PRIVS, ...)` takes four integer arguments — no pointers
4831        // and no borrowed memory are involved.
4832        let rc = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, enable, zero, zero, zero) };
4833        if rc != 0 {
4834            return Err(io::Error::last_os_error());
4835        }
4836
4837        Ok(())
4838    }
4839
4840    /// Highest capability number this kernel defines, read from `/proc/sys/kernel/cap_last_cap`.
4841    /// Uses raw `open`/`read` into a stack buffer so it stays allocation-free in the post-`fork()`
4842    /// window. Any failure — file absent on a pre-2.6.25 kernel, unreadable, contents that do not
4843    /// parse — falls back to [`CAP_LAST_CAP_FALLBACK`], which over-covers rather than
4844    /// under-covers, since undefined capability numbers just return the `EINVAL` the caller skips.
4845    #[allow(unsafe_code)]
4846    fn kernel_last_cap() -> u32 {
4847        const PATH: &[u8] = b"/proc/sys/kernel/cap_last_cap\0";
4848
4849        // SAFETY: `PATH` is a NUL-terminated byte literal with `'static` lifetime, and `open`
4850        // only reads through the pointer.
4851        let fd = unsafe {
4852            libc::open(
4853                PATH.as_ptr() as *const libc::c_char,
4854                libc::O_RDONLY | libc::O_CLOEXEC,
4855            )
4856        };
4857        if fd < 0 {
4858            return CAP_LAST_CAP_FALLBACK;
4859        }
4860
4861        let mut buf = [0u8; 16];
4862        // SAFETY: `fd` was just opened successfully; `buf` is a stack array and its exact length
4863        // is passed, so `read` cannot write past it.
4864        let read = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
4865        // SAFETY: `fd` is open and exclusively owned here; nothing else holds or will reuse it.
4866        unsafe { libc::close(fd) };
4867
4868        if read <= 0 {
4869            return CAP_LAST_CAP_FALLBACK;
4870        }
4871
4872        let parsed = std::str::from_utf8(&buf[..read as usize])
4873            .ok()
4874            .and_then(|text| text.trim().parse::<u32>().ok());
4875
4876        match parsed {
4877            Some(last) if last <= CAP_LAST_CAP_FALLBACK => last,
4878            _ => CAP_LAST_CAP_FALLBACK,
4879        }
4880    }
4881
4882    /// The Landlock syscalls the child still has to make after its seccomp filter is loaded, each
4883    /// with the syscall number to fall back on when this host's libseccomp is too old to resolve
4884    /// the name (they were added in libseccomp 2.5.4; Ubuntu 22.04 ships 2.5.3 on a kernel that
4885    /// does support Landlock, so this is a real combination, not a hypothetical one).
4886    ///
4887    /// Linux assigned these numbers uniformly across architectures in 5.13 — 444/445/446 on
4888    /// x86_64, aarch64 and every other arch with Landlock — so a single number per name is
4889    /// correct for all of them, unlike the legacy syscalls where numbers diverge per arch.
4890    const LANDLOCK_SYSCALLS: [(&str, i32); 3] = [
4891        ("landlock_create_ruleset", 444),
4892        ("landlock_add_rule", 445),
4893        ("landlock_restrict_self", 446),
4894    ];
4895
4896    /// Builds the child's seccomp filter and returns it unloaded, so that the same construction
4897    /// the spawn path uses can also be measured. [`install_seccomp_filter`] is the only caller
4898    /// that loads it; the measurement of what it compiles to, the kernel ceilings that number is
4899    /// held against and the headroom between them are recorded in
4900    /// `docs/content/reference/seccomp-filter-instruction-budget.md`.
4901    ///
4902    /// **This filter raises no notifications and needs no supervisor.** Network enforcement lives
4903    /// in [`crate::network_namespace`] plus [`crate::egress_proxy`], and exec enforcement in the
4904    /// Landlock domain `apply_landlock_scope` installs — see this module's own header for why no
4905    /// `Notify` rule may come back.
4906    ///
4907    /// One mechanism plus a default:
4908    ///
4909    ///   - `socket` gets classic, register-value `Errno` rules, one per denied domain. `domain` is
4910    ///     a plain integer argument, so the comparison compiles straight into the loaded BPF
4911    ///     program and is evaluated in-kernel at syscall time: no notification is raised and no
4912    ///     memory of another task is read. That is what makes it structurally immune to the
4913    ///     pointer-read TOCTOU class of problem in the first place.
4914    ///   - the filter's **default action**, which is a deny (`Errno(EPERM)`): a syscall named by
4915    ///     neither [`super::SECCOMP_SYSCALL_ALLOWLIST`] nor a rule here is refused, without any
4916    ///     argument being inspected. That is what makes `io_uring_setup`, `bpf`, `userfaultfd`,
4917    ///     `perf_event_open`, `ptrace` and the rest of [`super::SECCOMP_MUST_STAY_DENIED`]
4918    ///     unreachable — they are simply absent, not argument-matched.
4919    fn build_seccomp_filter(
4920        unix_sockets_allowed: bool,
4921    ) -> io::Result<libseccomp::ScmpFilterContext> {
4922        // Default-deny. `EPERM` matches what the OCI/Docker default profile returns for a syscall
4923        // outside its allowlist, and is deliberately a *different* errno from the `EACCES` that the
4924        // `socket()` domain rules (and Landlock) return: `EACCES` means "the sandbox looked at this
4925        // call's arguments and refused it", `EPERM` means "this syscall is not part of the
4926        // capsule's syscall surface at all". Keeping them distinct is what lets an operator tell an
4927        // unmediated-surface problem from a policy decision.
4928        let mut filter =
4929            libseccomp::ScmpFilterContext::new(libseccomp::ScmpAction::Errno(libc::EPERM))
4930                .map_err(to_io_err)?;
4931
4932        // Ask the kernel to record every action this filter takes that is not `Allow` in the
4933        // audit trail, so a denial is attributable after the fact to a syscall number, pid and
4934        // comm. The denied process itself still observes nothing but `EPERM` — `SECCOMP_RET_ERRNO`
4935        // has no channel for anything richer — so this is the only way an operator debugging a
4936        // workload that dies on an unexpected denial gets a syscall name to look at.
4937        //
4938        // Best-effort on purpose, and the one place in this function where an error is swallowed:
4939        // `SCMP_FLTATR_CTL_LOG` needs libseccomp API level 3 (libseccomp 2.4.0+), and the crate
4940        // reports an older runtime library as an error here. That is a *diagnosability* shortfall,
4941        // not an enforcement one — the default-deny action and every rule below are unaffected —
4942        // and turning it into a hard failure would take a host with an old libseccomp from
4943        // "denials are logged less legibly" to "no capsule can spawn a shell at all".
4944        //
4945        // Setting the attribute is necessary but not sufficient: the kernel only logs an action
4946        // whose type also appears in `/proc/sys/kernel/seccomp/actions_logged`, which is host
4947        // configuration this process does not control.
4948        // `ESCAPE_CONFORMANCE_HARNESS.md` at the repository root carries the hand-run procedure
4949        // these denials are verified by.
4950        let _ = filter.set_ctl_log(true);
4951
4952        // Deny the dangerous `socket(2)` domains at creation time. `EACCES` (not `EPERM`) is the
4953        // errno this sandbox uses for "the sandbox looked at this call's arguments and refused it",
4954        // as opposed to the default action's `EPERM` for "this syscall is not part of the capsule's
4955        // surface at all". It also matches what Landlock returns for a denied exec, so a capsule
4956        // sees one consistent errno across both argument-level mechanisms.
4957        //
4958        // Untouched by the network-namespace work, and deliberately so: a network namespace does
4959        // not mediate `AF_UNIX` at all — pathname or abstract — so this register-level rule is
4960        // still the only thing standing between a capsule and `/var/run/docker.sock`.
4961        //
4962        // `add_rule_conditional` (not `..._exact`) on purpose: the non-exact form lets libseccomp
4963        // adapt the rule to the architectures in the filter — on an arch where `socket` is
4964        // reached through `socketcall(2)` rather than as its own syscall, the exact form would
4965        // fail the whole install instead. Fail-closed is preserved either way, since any error
4966        // here propagates out of `pre_exec` and aborts the spawn.
4967        //
4968        // One rule per domain rather than one rule with three comparators: comparators within a
4969        // single rule are AND-ed, and no `socket()` call has a `domain` that is simultaneously
4970        // `AF_UNIX` and `AF_NETLINK`, so a combined rule would match nothing.
4971        let socket_syscall = libseccomp::ScmpSyscall::from_name("socket").map_err(to_io_err)?;
4972        for domain in super::denied_socket_domains(unix_sockets_allowed) {
4973            filter
4974                .add_rule_conditional(
4975                    libseccomp::ScmpAction::Errno(libc::EACCES),
4976                    socket_syscall,
4977                    &[libseccomp::ScmpArgCompare::new(
4978                        0,
4979                        libseccomp::ScmpCompareOp::Equal,
4980                        domain as u64,
4981                    )],
4982                )
4983                .map_err(to_io_err)?;
4984        }
4985
4986        // The permitted domains need positive rules of their own now that the filter's default is
4987        // a deny — without them `socket()` would be refused for every family, IP included.
4988        //
4989        // Equality rules per allowed domain, and NOT one unconditional `Allow` rule for `socket`:
4990        // libseccomp resolves an unconditional rule for a syscall by *discarding* every
4991        // argument-conditional chain already recorded for it (`db.c`: "syscall exists with chains
4992        // but the new filter has no chains so the existing chains must be cleared"), so a broad
4993        // `Allow` here would silently delete the AF_UNIX/AF_NETLINK/AF_PACKET denials directly
4994        // above and reopen the `/var/run/docker.sock` path they exist to close. containerd's
4995        // default profile carries the same warning against combining a broad `socket` allow with
4996        // explicit errno rules, and works around it the same way — with argument-matched rules
4997        // only. The two domain sets are disjoint by construction (a test pins that), so no
4998        // `socket()` call can match both an allow and a deny rule.
4999        for domain in super::allowed_socket_domains(unix_sockets_allowed) {
5000            filter
5001                .add_rule_conditional(
5002                    libseccomp::ScmpAction::Allow,
5003                    socket_syscall,
5004                    &[libseccomp::ScmpArgCompare::new(
5005                        0,
5006                        libseccomp::ScmpCompareOp::Equal,
5007                        domain as u64,
5008                    )],
5009                )
5010                .map_err(to_io_err)?;
5011        }
5012
5013        // The allowlist proper. Two failure modes are both handled by skipping the name:
5014        //
5015        //   - `from_name` fails when this host's libseccomp does not know the syscall (a name
5016        //     newer than the installed library);
5017        //   - `add_rule` fails with `EDOM` when the name resolves to a pseudo-syscall that does
5018        //     not exist on this architecture — every legacy x86_64 spelling in the array
5019        //     (`open`, `dup2`, `poll`, `stat`, ...) hits this on aarch64.
5020        //
5021        // Skipping is fail-closed: a name that gets no rule stays denied by the default action,
5022        // so the filter can only ever come out *narrower* than intended, never wider. Propagating
5023        // instead would abort every single spawn on the first arch-specific name, which is why
5024        // this is the deliberate exception to the module's otherwise-strict error handling.
5025        for name in super::SECCOMP_SYSCALL_ALLOWLIST {
5026            if let Ok(syscall) = libseccomp::ScmpSyscall::from_name(name) {
5027                let _ = filter.add_rule(libseccomp::ScmpAction::Allow, syscall);
5028            }
5029        }
5030
5031        // Landlock's three syscalls, which `child_install_enforcement` calls *after* this filter
5032        // is loaded (`apply_landlock_scope`, on `KernelFull`). Without these rules the default
5033        // deny would refuse the child's own filesystem scoping and abort every spawn on that
5034        // tier. Allowing them costs nothing: a Landlock domain can only ever narrow the process
5035        // that installs it, never widen it, and Docker's default profile allows them too.
5036        //
5037        // Resolved by name where possible, with the syscall number as a fallback, because these
5038        // names only became known to libseccomp in 2.5.4 — the "skip what does not resolve"
5039        // policy above would silently turn an older libseccomp into "no capsule can run a shell
5040        // on a Landlock-capable host". The numbers are the same on every architecture Linux has
5041        // added Landlock to (they were assigned after the syscall-number unification), so the
5042        // fallback is not architecture-specific.
5043        for (name, fallback_nr) in LANDLOCK_SYSCALLS {
5044            let syscall = libseccomp::ScmpSyscall::from_name(name)
5045                .unwrap_or_else(|_| libseccomp::ScmpSyscall::from(fallback_nr));
5046            filter
5047                .add_rule(libseccomp::ScmpAction::Allow, syscall)
5048                .map_err(to_io_err)?;
5049        }
5050
5051        Ok(filter)
5052    }
5053
5054    /// Loads the filter [`build_seccomp_filter`] produces into the calling task. Irreversible:
5055    /// the filter stays in force for the rest of this task's life and is inherited across the
5056    /// `execve` that follows, which is why nothing outside a forked child ever calls this.
5057    fn install_seccomp_filter(unix_sockets_allowed: bool) -> io::Result<()> {
5058        // Dropping the context after `load()` is safe: libseccomp's `seccomp_release()` (invoked
5059        // on Drop) only frees the userspace filter-building context, while the filter itself is
5060        // already installed in the kernel and is inherited across the `execve` that follows.
5061        build_seccomp_filter(unix_sockets_allowed)?
5062            .load()
5063            .map_err(to_io_err)
5064    }
5065
5066    fn to_io_err<E: std::fmt::Display>(error: E) -> io::Error {
5067        io::Error::other(error.to_string())
5068    }
5069
5070    /// Size of one `struct sock_filter`, the record `seccomp_export_bpf` writes per BPF
5071    /// instruction. Dividing an export's length by it is what turns bytes into an instruction
5072    /// count.
5073    #[cfg(test)]
5074    const SOCK_FILTER_BYTES: usize = 8;
5075
5076    /// Writes `export` — one of libseccomp's two exporters — into an anonymous in-memory file and
5077    /// returns what it wrote.
5078    ///
5079    /// Both exporters take a descriptor and nothing else, so the buffer has to come from
5080    /// somewhere. A `memfd` rather than a temporary file because it touches no filesystem the
5081    /// sandbox might already have scoped away, and because `tempfile` is a dev-dependency of this
5082    /// crate and so is unavailable to anything outside a test build.
5083    #[cfg(test)]
5084    fn export_to_memfd<F>(export: F) -> io::Result<Vec<u8>>
5085    where
5086        F: FnOnce(&OwnedFd) -> Result<(), libseccomp::error::SeccompError>,
5087    {
5088        use std::io::{Read, Seek, SeekFrom};
5089        use std::os::fd::FromRawFd;
5090
5091        // SAFETY: `memfd_create` reads a NUL-terminated name (a `c"..."` literal, static
5092        // lifetime) and returns a fresh descriptor or -1. No flags, so no cloexec and no sealing:
5093        // the descriptor lives and dies inside this function.
5094        let raw = unsafe { libc::memfd_create(c"murmur-seccomp-export".as_ptr(), 0) };
5095        if raw < 0 {
5096            return Err(io::Error::last_os_error());
5097        }
5098        // SAFETY: `raw` is a descriptor `memfd_create` just returned and nothing else owns, which
5099        // is exactly the precondition for handing it to `OwnedFd`.
5100        let memfd = unsafe { OwnedFd::from_raw_fd(raw) };
5101
5102        export(&memfd).map_err(to_io_err)?;
5103
5104        let mut file = std::fs::File::from(memfd);
5105        file.seek(SeekFrom::Start(0))?;
5106        let mut exported = Vec::new();
5107        file.read_to_end(&mut exported)?;
5108        Ok(exported)
5109    }
5110
5111    /// The number of BPF instructions the filter [`build_seccomp_filter`] produces compiles to on
5112    /// this host, for the given `unix_sockets_allowed`.
5113    ///
5114    /// Measured, never assumed: `seccomp_export_bpf` writes the generated program as a sequence of
5115    /// 8-byte `struct sock_filter` records, so the instruction count is the exported length
5116    /// divided by that record size. The number depends on the host — how many
5117    /// [`super::SECCOMP_SYSCALL_ALLOWLIST`] names this architecture and this libseccomp resolve —
5118    /// which is why it is measured here rather than written down.
5119    ///
5120    /// `export_bpf` and not `export_bpf_mem`: the latter binds `seccomp_export_bpf_mem`, which
5121    /// first appeared in libseccomp 2.6.0, and a host on 2.5.x would fail to resolve the symbol.
5122    #[cfg(test)]
5123    fn seccomp_filter_instruction_count(unix_sockets_allowed: bool) -> io::Result<usize> {
5124        let filter = build_seccomp_filter(unix_sockets_allowed)?;
5125        let exported = export_to_memfd(|fd| filter.export_bpf(fd))?;
5126        Ok(exported.len() / SOCK_FILTER_BYTES)
5127    }
5128
5129    /// The filter [`build_seccomp_filter`] produces, rendered as libseccomp's pseudo filter code:
5130    /// one block per syscall carrying a rule, each block naming the argument comparisons and the
5131    /// action. It is the only view of a built context that shows the rule *shape* rather than the
5132    /// compiled program's size, which is what a test pinning the rule sequence needs.
5133    #[cfg(test)]
5134    fn seccomp_filter_pfc(unix_sockets_allowed: bool) -> io::Result<String> {
5135        let filter = build_seccomp_filter(unix_sockets_allowed)?;
5136        let exported = export_to_memfd(|fd| filter.export_pfc(fd))?;
5137        String::from_utf8(exported).map_err(to_io_err)
5138    }
5139
5140    /// Scopes the shell subprocess tree's filesystem access with Landlock. Four kinds of rule:
5141    ///
5142    ///   - the workdir gets [`workdir_access_rights`] — read/write plus directory, regular-file,
5143    ///     FIFO and symlink creation, but **not** `MakeChar`/`MakeBlock`/`MakeSock`, and **not**
5144    ///     `Execute` unless the capsule declared `capabilities.filesystem.workdir_exec: true` (see
5145    ///     [`WORKDIR_ACCESS_RIGHTS_NO_EXEC`] for why each right is withheld). Withholding `Execute`
5146    ///     is what enforces `capabilities.shell.allow` completely: the kernel refuses the exec on
5147    ///     the path it resolved itself, so nothing the capsule writes into its workdir can run;
5148    ///   - the session's temporary folder gets that **same** right-set from the same local
5149    ///     binding, whenever the enforcement holds one. The composed root and the mount-namespace
5150    ///     mapping both bind it at `/tmp` read-write, but Landlock matches an access to `/tmp/x`
5151    ///     against the bind's own root inode rather than the workdir's, so without this rule the
5152    ///     mount is writable and every access to it is nevertheless `EACCES` — bash heredocs,
5153    ///     `mktemp`, a compiler's intermediate objects and a package manager's build scratch all
5154    ///     fail. Identical rights are the honest ones: it is counted by the same
5155    ///     `workdir_max_bytes` guard, so it must be neither more nor less runnable than the
5156    ///     workdir itself;
5157    ///   - each [`LandlockGrant`] (the `shell.allow` binaries, their ELF interpreter, their
5158    ///     shared-library closure, any `interpreter_runtime` or `staged_runtime` directory, and on
5159    ///     `KernelSealed` the fixed [`crate::sealed::SEALED_RUNTIME_PATHS`] and
5160    ///     [`crate::sealed::SEALED_ETC_PATHS`], all *outside* the workdir) gets a **narrow read**
5161    ///     grant — never write — with two bits added on top.
5162    ///     Whether it carries `ReadDir` (i.e. the directory's own entries are enumerable) is
5163    ///     exactly the grant's `list_dir`: the derived closure files are all `false` (a regular
5164    ///     file has no meaningful `ReadDir`), while an `interpreter_runtime` directory carries
5165    ///     whatever its author wrote. `ReadDir` is granted only on the specific inode a rule names
5166    ///     — never on an ancestor or sibling — so naming one subdirectory never makes `/usr/lib`
5167    ///     (or any parent) enumerable. Whether it carries `Execute` is the grant's `executable`,
5168    ///     `true` everywhere except the two tier-issued sealed grants, which are deliberately
5169    ///     readable (and, on a directory, enumerable) but not runnable;
5170    ///   - each entry of the fixed [`CAPSULE_DEVICE_GRANTS`] set gets `ReadFile`, plus `WriteFile`
5171    ///     and `Truncate` if its `writable` bit is set. That bit is set for exactly one path —
5172    ///     `/dev/null` — which is therefore the *only* writable path outside the workdir in the
5173    ///     whole sandbox. This list is a compile-time constant, not manifest-derived: no capability
5174    ///     declaration widens or narrows it. Every device path it does not name stays denied by the
5175    ///     ordinary no-matching-rule path below.
5176    ///
5177    /// Without the second kind of rule, `restrict_self()` denies `Execute`/`ReadFile` on every
5178    /// path outside the workdir, so every allowlisted binary's `execve` fails with EACCES before
5179    /// it runs (and even a binary placed *inside* the workdir can't be loaded, because its dynamic
5180    /// loader must still read `ld-linux`/libc outside it). The narrow grants fix that while keeping
5181    /// the security story honest: apart from `/dev/null`, nothing outside the workdir is writable,
5182    /// so a write to any other outside path — including one of these read+execute grant paths — is
5183    /// still denied.
5184    ///
5185    /// The ruleset is built at `fds.abi` — the kernel's ABI capped at
5186    /// [`super::LANDLOCK_TARGET_ABI`], decided in the parent — or at ABI v1 when that is `None`.
5187    /// `handle_access` declares the **full** `from_all` set of that ABI, under `CompatLevel::HardRequirement`, and every rule's
5188    /// rights are masked to it first: a right the ABI does not know would otherwise fail
5189    /// `add_rule`, and masking it out is what a kernel of that ABI would enforce anyway. Built at
5190    /// v1, the ruleset is bit for bit the one a 5.13–5.18 kernel gets.
5191    ///
5192    /// `handle_access` must declare the union of every access bit any rule uses, and it is the
5193    /// full set deliberately: a bit declared there but granted by no rule is denied for
5194    /// the whole domain, which is exactly how the rights missing from
5195    /// [`WORKDIR_ACCESS_RIGHTS_NO_EXEC`] become denied rather than merely un-granted, and it is also what
5196    /// makes every device outside [`CAPSULE_DEVICE_GRANTS`] denied. A grant path — or a device
5197    /// path, on a host where one of the three is missing — that
5198    /// fails to *open* is skipped
5199    /// (shrink-not-fail, matching `resolve_exec_allowlist`) rather than failing the whole scope —
5200    /// it only narrows what is allowed. A genuine ruleset-construction failure still returns `Err`,
5201    /// preserving the module's fail-closed invariant.
5202    fn apply_landlock_scope(fds: &LandlockChildFds) -> Result<(), String> {
5203        #[cfg(test)]
5204        if super::FORCE_LANDLOCK_FAILURE.with(|flag| flag.get()) {
5205            return Err("landlock: ruleset construction failed (forced by test seam)".to_string());
5206        }
5207
5208        use landlock::{
5209            Access, AccessFs, CompatLevel, Compatible, PathBeneath, Ruleset, RulesetAttr,
5210            RulesetCreatedAttr, ABI,
5211        };
5212
5213        let abi = fds.abi.map_or(ABI::V1, |abi| ABI::from(i32::from(abi)));
5214        let handled = AccessFs::from_all(abi);
5215        // Enough for the loader to open, read, and map-execute a binary or shared library, and to
5216        // open a file inside a granted directory by exact name — but no write access, and (without
5217        // `ReadDir`) no enumeration of the directory's own entries.
5218        let read_execute = AccessFs::Execute | AccessFs::ReadFile;
5219        // Adds enumerability (`getdents64`) — only for a grant carrying `list_dir: true`.
5220        let read_execute_list = read_execute | AccessFs::ReadDir;
5221        // `ReadFile + ReadDir`, no `Execute` — readable and enumerable but not runnable. See
5222        // `resolve_sealed_runtime_landlock_grants` and `resolve_sealed_etc_landlock_grants` for why
5223        // `Execute` is withheld from a tier-issued grant over trees the manifest never named.
5224        let read_list = AccessFs::ReadFile | AccessFs::ReadDir;
5225        // `ReadFile` alone: the same tier-issued grant on an entry that is a *file* rather than a
5226        // directory (most of `SEALED_ETC_PATHS`). Not cosmetic — `landlock_append_fs_rule` rejects
5227        // `ReadDir` on a non-directory with `EINVAL`, and a rejected rule fails the launch below.
5228        let read_only: BitFlags<AccessFs> = AccessFs::ReadFile.into();
5229        // Device rights. No `Execute` (a character device is never exec'd) and no `ReadDir` (it is
5230        // not a directory) — only the data bits, with `WriteFile` reserved for the writable
5231        // devices. `Truncate` rides with `WriteFile` because a shell's `>/dev/null` opens with
5232        // `O_TRUNC`, and handling `Truncate` from ABI v3 would otherwise refuse it.
5233        let device_read: BitFlags<AccessFs> = AccessFs::ReadFile.into();
5234        let device_read_write = device_read | AccessFs::WriteFile | AccessFs::Truncate;
5235
5236        // The one writable right-set in this function, resolved once and used by both rules that
5237        // carry it. The session's temporary folder is counted by the workdir's size guard and is
5238        // the capsule's other scratch space, so it gets the same rights, `Execute` axis included:
5239        // `workdir_exec: false` must no more permit running a binary from `/tmp` than from the
5240        // workdir. Sharing the binding is what keeps that true; two separate calls could drift
5241        // apart.
5242        let writable_rights = workdir_access_rights(fds.workdir_exec) & handled;
5243
5244        // Every fd here was already opened in the parent (before fork). This function performs no
5245        // `open()`/`canonicalize()` — only the Landlock ruleset syscalls against already-open fds,
5246        // so a failure here can only be the kernel call itself, not an unrelated path resolution.
5247        let mut ruleset = Ruleset::default()
5248            .set_compatibility(CompatLevel::HardRequirement)
5249            .handle_access(handled)
5250            .map_err(|error| format!("landlock: handle_access failed: {error}"))?
5251            .create()
5252            .map_err(|error| format!("landlock: ruleset create failed: {error}"))?
5253            .add_rule(PathBeneath::new(fds.workdir_fd.as_fd(), writable_rights))
5254            .map_err(|error| format!("landlock: add_rule failed: {error}"))?;
5255
5256        // The session's temporary folder, whenever the enforcement holds one. A rule on the
5257        // workdir does not reach a bind mounted over `/tmp`; see [`LandlockChildFds::temp_dir_fd`].
5258        if let Some(temp_dir_fd) = &fds.temp_dir_fd {
5259            ruleset = ruleset
5260                .add_rule(PathBeneath::new(temp_dir_fd.as_fd(), writable_rights))
5261                .map_err(|error| {
5262                    format!("landlock: add_rule for the session's temporary folder failed: {error}")
5263                })?;
5264        }
5265
5266        // The session's home folder, on exactly the temporary folder's terms: the workdir's
5267        // rights beneath the records directory's read-only rule.
5268        if let Some(home_dir_fd) = &fds.home_dir_fd {
5269            ruleset = ruleset
5270                .add_rule(PathBeneath::new(home_dir_fd.as_fd(), writable_rights))
5271                .map_err(|error| {
5272                    format!("landlock: add_rule for the session's home folder failed: {error}")
5273                })?;
5274        }
5275
5276        // A native tool's store: written and read by that tool, never executed, whatever
5277        // `workdir_exec` grants the workdir.
5278        if let Some(state_dir_fd) = &fds.state_dir_fd {
5279            ruleset = ruleset
5280                .add_rule(PathBeneath::new(
5281                    state_dir_fd.as_fd(),
5282                    workdir_access_rights(false) & handled,
5283                ))
5284                .map_err(|error| {
5285                    format!("landlock: add_rule for the native tool's state store failed: {error}")
5286                })?;
5287        }
5288
5289        // Every host path the operator's global config lends this session. A runtime folder
5290        // beneath one keeps its own rule below; on `KernelSealed` its bind is what keeps it
5291        // read-only, since Landlock rights accumulate down the tree.
5292        for (host_path_fd, read_write) in &fds.host_path_fds {
5293            ruleset = ruleset
5294                .add_rule(PathBeneath::new(
5295                    host_path_fd.as_fd(),
5296                    host_path_access_rights(*read_write) & handled,
5297                ))
5298                .map_err(|error| format!("landlock: add_rule for a host path failed: {error}"))?;
5299        }
5300
5301        // The runtime directory: readable and listable, never writable or executable. A native
5302        // tool's entrypoint inside it runs through its own pinned `Execute` grant.
5303        if let Some(runtime_dir_fd) = &fds.runtime_dir_fd {
5304            ruleset = ruleset
5305                .add_rule(PathBeneath::new(
5306                    runtime_dir_fd.as_fd(),
5307                    read_list & handled,
5308                ))
5309                .map_err(|error| {
5310                    format!("landlock: add_rule for the runtime directory failed: {error}")
5311                })?;
5312        }
5313
5314        // The records directory, on the same terms: the runtime appends to the trace from
5315        // outside the domain, and no command inside it can.
5316        if let Some(records_dir_fd) = &fds.records_dir_fd {
5317            ruleset = ruleset
5318                .add_rule(PathBeneath::new(
5319                    records_dir_fd.as_fd(),
5320                    read_list & handled,
5321                ))
5322                .map_err(|error| {
5323                    format!("landlock: add_rule for the records directory failed: {error}")
5324                })?;
5325        }
5326
5327        // `KernelSealed` only — empty on every other tier. `ReadFile` alone: these are the two
5328        // synthetic account databases the root binds read-only at `/etc/passwd`/`/etc/group`, and
5329        // the grant loop's rules for those two paths are taken on the host's files, not on these.
5330        for identity_fd in &fds.sealed_identity_fds {
5331            ruleset = ruleset
5332                .add_rule(PathBeneath::new(identity_fd.as_fd(), read_only & handled))
5333                .map_err(|error| {
5334                    format!("landlock: add_rule for a synthetic /etc file failed: {error}")
5335                })?;
5336        }
5337
5338        for grant in &fds.grants {
5339            // Grant paths that failed to open in the parent were already dropped (shrink-not-fail),
5340            // so every fd reaching here is valid. A rule that still fails to add is a genuine
5341            // ruleset-construction failure and propagates as `Err` (fail-closed).
5342            let access = match (grant.executable, grant.list_dir) {
5343                (true, true) => read_execute_list,
5344                (true, false) => read_execute,
5345                (false, true) => read_list,
5346                (false, false) => read_only,
5347            };
5348            ruleset = ruleset
5349                .add_rule(PathBeneath::new(grant.fd.as_fd(), access & handled))
5350                .map_err(|error| format!("landlock: add_rule for grant failed: {error}"))?;
5351        }
5352
5353        for device in &fds.devices {
5354            // Same convention as the grant loop above: a device that failed to open in the parent
5355            // was already dropped, so a failure here is a genuine ruleset-construction failure and
5356            // propagates as `Err` (fail-closed).
5357            let access = if device.writable {
5358                device_read_write
5359            } else {
5360                device_read
5361            };
5362            ruleset = ruleset
5363                .add_rule(PathBeneath::new(device.fd.as_fd(), access & handled))
5364                .map_err(|error| format!("landlock: add_rule for device failed: {error}"))?;
5365        }
5366
5367        ruleset
5368            .restrict_self()
5369            .map_err(|error| format!("landlock: restrict_self failed: {error}"))?;
5370
5371        Ok(())
5372    }
5373
5374    /// What [`open_landlock_fds`] opens, or carries through, for one launch.
5375    pub(super) struct LandlockFdRequest<'a> {
5376        /// The workdir the capsule's commands run in. Fail-closed: a workdir that cannot be
5377        /// opened is an `Err` naming the path.
5378        pub(super) accessible_workdir: &'a Path,
5379        /// The manifest-derived grants. A grant path that fails to open is dropped
5380        /// (shrink-not-fail).
5381        pub(super) landlock_grants: &'a [LandlockGrant],
5382        /// The tier's fixed device set, [`super::CAPSULE_DEVICE_GRANTS`] on `KernelFull` and the
5383        /// composed root's own OCI default set on `KernelSealed`, chosen by
5384        /// `super::landlock_device_grants`. Opened by the grants' rules: the list is a
5385        /// compile-time constant rather than manifest-derived, and a host where one entry cannot
5386        /// be opened — unusual, but a broken or minimal `/dev` is possible — loses that one device
5387        /// rather than failing the launch. A lost device, `/dev/null` included, narrows the
5388        /// capsule's scope and never widens it.
5389        pub(super) device_grants: &'a [super::CapsuleDeviceGrant],
5390        /// Carried into [`LandlockChildFds::workdir_exec`].
5391        pub(super) workdir_exec: bool,
5392        /// Carried into [`LandlockChildFds::abi`].
5393        pub(super) abi: Option<u8>,
5394        /// What only `KernelSealed` opens: the synthetic `/etc` files. Fail-closed.
5395        pub(super) sealed: Option<&'a super::SealedSourceFds>,
5396        /// The session's temporary folder's descriptor on both Landlock tiers, which
5397        /// `prepare_enforcement` already took and shares with the sealed binds. Fail-closed — an
5398        /// `Err` naming the path, exactly like the workdir's own open, never the grant loops'
5399        /// shrink-not-fail `continue`.
5400        pub(super) temp_dir_fd: Option<std::sync::Arc<OwnedFd>>,
5401        /// The session's home folder's descriptor, on the same terms as [`Self::temp_dir_fd`].
5402        pub(super) home_dir_fd: Option<std::sync::Arc<OwnedFd>>,
5403        /// A native tool's store's descriptor, from [`open_state_dir`], on the same terms.
5404        pub(super) state_dir_fd: Option<std::sync::Arc<OwnedFd>>,
5405        /// The runtime directory's descriptor, on the same terms as [`Self::temp_dir_fd`].
5406        pub(super) runtime_dir_fd: Option<std::sync::Arc<OwnedFd>>,
5407        /// The records directory's descriptor, on the same terms as [`Self::temp_dir_fd`].
5408        pub(super) records_dir_fd: Option<std::sync::Arc<OwnedFd>>,
5409        /// Every host path's descriptor, from [`open_host_path`], and whether it is
5410        /// `read-write`. Carried into [`LandlockChildFds::host_path_fds`].
5411        pub(super) host_path_fds: Vec<(std::sync::Arc<OwnedFd>, bool)>,
5412    }
5413
5414    impl<'a> LandlockFdRequest<'a> {
5415        /// `accessible_workdir` and nothing else: no grants, no devices, no sealed sources and
5416        /// no directory fds.
5417        #[cfg(test)]
5418        pub(super) fn workdir_only(accessible_workdir: &'a Path) -> Self {
5419            Self {
5420                accessible_workdir,
5421                landlock_grants: &[],
5422                device_grants: &[],
5423                workdir_exec: false,
5424                abi: None,
5425                sealed: None,
5426                temp_dir_fd: None,
5427                home_dir_fd: None,
5428                state_dir_fd: None,
5429                runtime_dir_fd: None,
5430                records_dir_fd: None,
5431                host_path_fds: Vec::new(),
5432            }
5433        }
5434    }
5435
5436    /// Opens (in the PARENT, before fork) the workdir fd and every grant fd Landlock needs, using
5437    /// `O_PATH | O_CLOEXEC` — the exact flags `landlock::PathFd` uses. Moving these `open()` calls
5438    /// out of the child's `pre_exec` window is the whole point: a workdir that cannot be resolved
5439    /// now fails here, synchronously, with a message naming the path — before any subprocess is
5440    /// spawned — instead of collapsing to a bare EINVAL inside `pre_exec`. A grant path that fails
5441    /// to open is dropped (shrink-not-fail), matching the previous per-grant `continue`.
5442    ///
5443    /// It is also where each grant's `list_dir` is reconciled against the fd that was actually
5444    /// opened — see the comment on the grant loop. Same discipline as the drop above: narrowing
5445    /// only, never widening.
5446    ///
5447    /// The request's devices are opened by the same rules as its grants, and its directory fds
5448    /// move into the returned [`LandlockChildFds`]. See [`LandlockFdRequest`] for each field.
5449    pub(super) fn open_landlock_fds(
5450        request: LandlockFdRequest<'_>,
5451    ) -> Result<LandlockChildFds, String> {
5452        let LandlockFdRequest {
5453            accessible_workdir,
5454            landlock_grants,
5455            device_grants,
5456            workdir_exec,
5457            abi,
5458            sealed,
5459            temp_dir_fd,
5460            home_dir_fd,
5461            state_dir_fd,
5462            runtime_dir_fd,
5463            records_dir_fd,
5464            host_path_fds,
5465        } = request;
5466        let workdir_fd = open_o_path(accessible_workdir)
5467            .map(OwnedFd::from)
5468            .map_err(|error| {
5469                format!(
5470                    "sandbox: failed to open workdir {} for Landlock scoping: {error}",
5471                    accessible_workdir.display()
5472                )
5473            })?;
5474
5475        // Same tier gate, same fail-closed handling: the composed root binds these two files at
5476        // `/etc/passwd`/`/etc/group`, and the grant loop below opens the *host's* files of those
5477        // names, whose inodes the capsule never sees. See `LandlockChildFds`. `sealed-etc/` is
5478        // written by the runtime alone.
5479        let mut sealed_identity_fds = Vec::new();
5480        for path in sealed
5481            .map(|sources| sources.identity_files.as_slice())
5482            .unwrap_or_default()
5483        {
5484            let file = open_o_path(path).map_err(|error| {
5485                format!(
5486                    "sandbox: failed to open the synthetic /etc file {} for Landlock scoping: \
5487                     {error}",
5488                    path.display()
5489                )
5490            })?;
5491            sealed_identity_fds.push(OwnedFd::from(file));
5492        }
5493
5494        let mut grants = Vec::with_capacity(landlock_grants.len());
5495        for grant in landlock_grants {
5496            match open_o_path(&grant.path) {
5497                Ok(file)
5498                    if grant.pinned.is_some_and(|pinned| {
5499                        file.metadata()
5500                            .map(|meta| super::FileIdentity::of(&meta) != pinned)
5501                            .unwrap_or(true)
5502                    }) =>
5503                {
5504                    continue
5505                }
5506                Ok(file) => grants.push(OpenLandlockGrant {
5507                    // `ReadDir` is meaningful only on a directory, and the kernel does not merely
5508                    // ignore it elsewhere: `landlock_append_fs_rule` returns `EINVAL` for a
5509                    // directory-only right on a non-directory, and the rule loop in
5510                    // `apply_landlock_scope` is fail-closed, so one such rule would refuse the
5511                    // whole launch. Every `list_dir: true` reaching here is a *claim* — an author's
5512                    // `interpreter_runtime` entry, or the static `directory` bit on a
5513                    // `SEALED_ETC_PATHS` entry — so it is checked against the fd actually opened
5514                    // and narrowed rather than trusted. Narrowing only: this can drop `ReadDir`,
5515                    // never add it.
5516                    list_dir: grant.list_dir
5517                        && file.metadata().map(|meta| meta.is_dir()).unwrap_or(false),
5518                    fd: OwnedFd::from(file),
5519                    executable: grant.executable,
5520                }),
5521                Err(_) => continue,
5522            }
5523        }
5524
5525        let mut devices = Vec::with_capacity(device_grants.len());
5526        for device in device_grants {
5527            match open_o_path(Path::new(device.path)) {
5528                Ok(file) => devices.push(OpenDeviceGrant {
5529                    fd: OwnedFd::from(file),
5530                    writable: device.writable,
5531                }),
5532                Err(_) => continue,
5533            }
5534        }
5535
5536        Ok(LandlockChildFds {
5537            workdir_fd,
5538            workdir_exec,
5539            abi,
5540            temp_dir_fd,
5541            home_dir_fd,
5542            state_dir_fd,
5543            sealed_identity_fds,
5544            runtime_dir_fd,
5545            records_dir_fd,
5546            host_path_fds,
5547            grants,
5548            devices,
5549        })
5550    }
5551
5552    /// Opens `path` with `O_PATH | O_CLOEXEC` (identity/lifetime handle only, never a data fd),
5553    /// suitable for `landlock::PathBeneath`. Same open the vendored `landlock::PathFd::new`
5554    /// performs, done in the parent so the resulting fd can be moved into the child's `pre_exec`
5555    /// closure.
5556    ///
5557    /// Returns the `File` rather than a bare `OwnedFd` so the caller can `fstat` it — see the
5558    /// `list_dir` narrowing in [`open_landlock_fds`], which needs the kind of *this* fd and must
5559    /// not perform a second path lookup to get it.
5560    fn open_o_path(path: &Path) -> io::Result<std::fs::File> {
5561        use std::os::unix::fs::OpenOptionsExt;
5562        std::fs::OpenOptions::new()
5563            .read(true)
5564            .custom_flags(libc::O_PATH | libc::O_CLOEXEC)
5565            .open(path)
5566    }
5567
5568    /// An `O_PATH` handle on a native tool's store, its Landlock rule's fd and its sealed bind's
5569    /// identity. `O_NOFOLLOW | O_DIRECTORY`: a link planted at the store's own name, or anything
5570    /// but a directory, is refused rather than granted. The store is
5571    /// `~/.murmur/state/<store>/`, created `0700` by the runtime outside every workdir.
5572    pub(super) fn open_state_dir(path: &Path) -> Result<OwnedFd, String> {
5573        use std::os::unix::fs::OpenOptionsExt;
5574        std::fs::OpenOptions::new()
5575            .read(true)
5576            .custom_flags(libc::O_PATH | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_DIRECTORY)
5577            .open(path)
5578            .map(OwnedFd::from)
5579            .map_err(|error| {
5580                format!(
5581                    "sandbox: failed to open the native tool's state store {}: {error}",
5582                    path.display()
5583                )
5584            })
5585    }
5586
5587    /// An `O_PATH` handle on a host path, its Landlock rule's fd and its sealed bind's identity.
5588    /// `O_NOFOLLOW | O_DIRECTORY` on the directory the entry resolved to at launch: a link planted
5589    /// at its name since, or anything but a directory, fails the spawn naming the path.
5590    pub(super) fn open_host_path(path: &Path) -> Result<OwnedFd, String> {
5591        use std::os::unix::fs::OpenOptionsExt;
5592        std::fs::OpenOptions::new()
5593            .read(true)
5594            .custom_flags(libc::O_PATH | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_DIRECTORY)
5595            .open(path)
5596            .map(OwnedFd::from)
5597            .map_err(|error| {
5598                format!(
5599                    "sandbox: failed to open host path {} (host_paths in ~/.murmur/config.yaml): \
5600                     {error}",
5601                    path.display()
5602                )
5603            })
5604    }
5605
5606    /// Best-effort write of a bounded failure message to the child-diagnostic pipe from inside
5607    /// `pre_exec`, using a raw `write` loop (allocation for the message itself already happened
5608    /// upstream — `child_install_enforcement`'s error paths use `format!`). Never fails the caller:
5609    /// a lost diagnostic only costs legibility, never the fail-closed guarantee (the closure still
5610    /// returns `Err`, so `execve` is still aborted).
5611    #[allow(unsafe_code)]
5612    pub(super) fn write_diagnostic(fd: RawFd, message: &str) {
5613        let bytes = message.as_bytes();
5614        let len = bytes.len().min(MAX_DIAG_LEN);
5615        let mut written = 0;
5616        while written < len {
5617            // SAFETY: `fd` is the valid, open write end of the diagnostic pipe; the pointer/len
5618            // name a live sub-slice of `bytes` for the duration of this single `write` call.
5619            let n = unsafe {
5620                libc::write(
5621                    fd,
5622                    bytes[written..len].as_ptr() as *const libc::c_void,
5623                    len - written,
5624                )
5625            };
5626            if n < 0 {
5627                if io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) {
5628                    continue;
5629                }
5630                break;
5631            }
5632            if n == 0 {
5633                break;
5634            }
5635            written += n as usize;
5636        }
5637    }
5638
5639    /// Parent-side read of whatever the child wrote to the diagnostic pipe before its `pre_exec`
5640    /// closure returned `Err`. Blocks until EOF, which arrives once every write end is closed —
5641    /// the child's (on `_exit`) and the parent's captured copy (which `execute_shell` drops before
5642    /// calling this). Returns `None` when nothing was written (the success-path EOF, or a `spawn`
5643    /// failure that never ran `pre_exec`).
5644    #[allow(unsafe_code)]
5645    pub(super) fn read_diagnostic_pipe(fd: RawFd) -> Option<String> {
5646        let mut buf = Vec::new();
5647        let mut chunk = [0u8; 256];
5648        loop {
5649            // SAFETY: `fd` is the valid, open read end of the diagnostic pipe; `chunk` is a live
5650            // stack buffer of exactly the length passed.
5651            let n = unsafe { libc::read(fd, chunk.as_mut_ptr() as *mut libc::c_void, chunk.len()) };
5652            if n < 0 {
5653                if io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) {
5654                    continue;
5655                }
5656                break;
5657            }
5658            if n == 0 {
5659                break;
5660            }
5661            buf.extend_from_slice(&chunk[..n as usize]);
5662            if buf.len() >= MAX_DIAG_LEN {
5663                break;
5664            }
5665        }
5666        if buf.is_empty() {
5667            None
5668        } else {
5669            Some(String::from_utf8_lossy(&buf).into_owned())
5670        }
5671    }
5672
5673    /// What the child's seccomp filter costs, and the two kernel ceilings that cost is held
5674    /// against — all four numbers produced by running code on the host under test, never quoted.
5675    ///
5676    /// The written record these tests feed is
5677    /// `docs/content/reference/seccomp-filter-instruction-budget.md`.
5678    ///
5679    /// Nothing here ever loads a filter into the test process. A loaded seccomp filter is
5680    /// irreversible for the life of the task, so an in-process load would silently apply to the
5681    /// rest of the test binary and to everything it spawns. Every load happens in a forked child
5682    /// that does nothing else and then `_exit`s.
5683    #[cfg(test)]
5684    #[allow(clippy::print_stdout, clippy::print_stderr)]
5685    mod seccomp_budget {
5686        use std::io;
5687        use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
5688
5689        // `seccomp_filter_instruction_count` is absent from this list because the test that
5690        // reports it carries the same name; it is called as `super::` throughout.
5691        use super::{
5692            build_seccomp_filter, export_to_memfd, seccomp_filter_pfc, to_io_err,
5693            LANDLOCK_SYSCALLS, SOCK_FILTER_BYTES,
5694        };
5695        use crate::sandbox::{
5696            allowed_socket_domains, denied_socket_domains, detect_enforcement_tier,
5697            EnforcementTier, BPF_MAXINSNS_CEILING, SECCOMP_FILTER_INSTRUCTION_BUDGET,
5698            SECCOMP_MAX_INSNS_PER_PATH, SECCOMP_SYSCALL_ALLOWLIST,
5699        };
5700
5701        /// The kernel charges each filter already attached to a task four instructions on top of
5702        /// its own length when it decides whether the next attach fits under
5703        /// [`SECCOMP_MAX_INSNS_PER_PATH`].
5704        ///
5705        /// It is only part of the arithmetic. The length the kernel adds up is that of the
5706        /// *converted* program — `seccomp_prepare_filter` translates the classic BPF that
5707        /// `export_bpf` writes into eBPF before the chain is measured, and the translation comes
5708        /// out slightly longer. So a load count predicted from the exported classic length is an
5709        /// upper bound on the load count the kernel actually allows, never an equality, which is
5710        /// why the cumulative probe brackets it rather than pinning it.
5711        const KERNEL_PER_FILTER_OVERHEAD_INSNS: usize = 4;
5712
5713        /// The exit code a load probe's child reports on a refusal libseccomp gave no errno for.
5714        /// Every real errno is a small positive number, and 0 is reserved for a load that
5715        /// succeeded, so this cannot collide with either.
5716        const PROBE_ERRNO_UNKNOWN: i32 = 254;
5717
5718        /// A filter built only to be measured or refused: default `Allow`, and `rules`
5719        /// argument-conditional `Errno` rules on a syscall no probe child ever makes.
5720        ///
5721        /// Default `Allow` rather than the real filter's default deny, for two reasons that both
5722        /// matter. A child has to survive its own load long enough to `_exit` and, in the
5723        /// cumulative probe, to call `seccomp(2)` again — the real filter denies `seccomp` on
5724        /// purpose, so it cannot be stacked on itself. And libseccomp drops a rule whose action
5725        /// equals the default action, so the rules have to carry an action the default does not:
5726        /// `Errno` against a default of `Allow` is the inverse of the shipped filter and keeps
5727        /// every rule in the program.
5728        ///
5729        /// `set_api_sysrawrc` is what makes the errno observable at all: without it libseccomp
5730        /// collapses every `seccomp(2)` attach failure to `ECANCELED`, which would hide the very
5731        /// `EINVAL`/`ENOMEM` distinction these probes exist to establish.
5732        fn probe_filter(rules: usize) -> io::Result<(libseccomp::ScmpFilterContext, usize)> {
5733            let mut filter = libseccomp::ScmpFilterContext::new(libseccomp::ScmpAction::Allow)
5734                .map_err(to_io_err)?;
5735            filter.set_api_sysrawrc(true).map_err(to_io_err)?;
5736
5737            let syscall = libseccomp::ScmpSyscall::from_name("ptrace").map_err(to_io_err)?;
5738            for value in 0..rules {
5739                filter
5740                    .add_rule_conditional(
5741                        libseccomp::ScmpAction::Errno(libc::EPERM),
5742                        syscall,
5743                        &[libseccomp::ScmpArgCompare::new(
5744                            0,
5745                            libseccomp::ScmpCompareOp::Equal,
5746                            value as u64,
5747                        )],
5748                    )
5749                    .map_err(to_io_err)?;
5750            }
5751
5752            let exported = export_to_memfd(|fd| filter.export_bpf(fd))?;
5753            Ok((filter, exported.len() / SOCK_FILTER_BYTES))
5754        }
5755
5756        /// The smallest probe-filter rule count whose compiled program is longer than
5757        /// `instructions`. Doubling to bracket, then bisecting: the instruction count grows
5758        /// monotonically with the rule count, so both halves are sound and the whole search
5759        /// costs a couple of dozen exports rather than thousands.
5760        fn first_rule_count_above(instructions: usize) -> io::Result<usize> {
5761            let mut under = 1;
5762            let mut over = 8;
5763            while probe_filter(over)?.1 <= instructions {
5764                under = over;
5765                over *= 2;
5766                assert!(
5767                    over <= 1 << 20,
5768                    "no probe filter of up to {over} rules exceeded {instructions} instructions — \
5769                     the probe is not growing the program the way this search assumes"
5770                );
5771            }
5772            while over - under > 1 {
5773                let mid = under + (over - under) / 2;
5774                if probe_filter(mid)?.1 <= instructions {
5775                    under = mid;
5776                } else {
5777                    over = mid;
5778                }
5779            }
5780            Ok(over)
5781        }
5782
5783        /// The raw errno behind a libseccomp failure, for a context built by [`probe_filter`] and
5784        /// therefore carrying `SCMP_FLTATR_API_SYSRAWRC`. Known errnos come back as
5785        /// `ErrorKind::Errno`, anything else as the raw code; both are mapped here so the caller
5786        /// only ever sees a plain `libc` value.
5787        fn refusal_errno(error: &libseccomp::error::SeccompError) -> i32 {
5788            use libseccomp::error::SeccompErrno;
5789
5790            if let Some(raw) = error.sysrawrc() {
5791                return -raw;
5792            }
5793            match error.errno() {
5794                Some(SeccompErrno::EACCES) => libc::EACCES,
5795                Some(SeccompErrno::ECANCELED) => libc::ECANCELED,
5796                Some(SeccompErrno::EINVAL) => libc::EINVAL,
5797                Some(SeccompErrno::ENOMEM) => libc::ENOMEM,
5798                _ => PROBE_ERRNO_UNKNOWN,
5799            }
5800        }
5801
5802        /// Loads `filter` in a forked child and reports what the kernel said: `Ok(())` for a load
5803        /// that took, `Err(errno)` for one the kernel refused.
5804        ///
5805        /// The filter is built by the caller, before the fork, so the child's whole post-fork
5806        /// window is `load()` and `_exit`.
5807        fn load_in_child(filter: &libseccomp::ScmpFilterContext) -> Result<(), i32> {
5808            // SAFETY: `fork()` from a possibly-multithreaded process is sound as long as the
5809            // child confines itself to work that does not depend on another thread's state. This
5810            // child calls `load()` and then `_exit`. `load()` allocates — libseccomp generates
5811            // the BPF program on every load and does not cache the one `export_bpf` produced —
5812            // so this probe is not async-signal-safe in the strict sense; it is bounded to a
5813            // child that touches nothing else and dies immediately.
5814            let pid = unsafe { libc::fork() };
5815            assert!(pid >= 0, "fork failed: {}", io::Error::last_os_error());
5816
5817            if pid == 0 {
5818                let code = match filter.load() {
5819                    Ok(()) => 0,
5820                    Err(error) => refusal_errno(&error),
5821                };
5822                // SAFETY: forked-child context; `_exit` skips every destructor and atexit hook,
5823                // which is what keeps libtest's state in the parent untouched.
5824                unsafe { libc::_exit(code) }
5825            }
5826
5827            let mut status: libc::c_int = 0;
5828            // SAFETY: `pid` is the child just forked; `status` is a live local.
5829            let waited = unsafe { libc::waitpid(pid, &mut status, 0) };
5830            assert!(waited == pid, "waitpid: {}", io::Error::last_os_error());
5831            assert!(
5832                libc::WIFEXITED(status),
5833                "the load probe child did not exit normally (status {status})"
5834            );
5835            match libc::WEXITSTATUS(status) {
5836                0 => Ok(()),
5837                errno => Err(errno),
5838            }
5839        }
5840
5841        /// The number of distinct syscalls the allowlist actually contributes a rule for on this
5842        /// host, counted the way `build_seccomp_filter` contributes them — resolve the name, try
5843        /// the rule, keep what libseccomp accepted — but against a scratch context, so the count
5844        /// is an independent measurement rather than a restatement of the filter under test.
5845        fn resolvable_allowlist_syscalls() -> io::Result<usize> {
5846            use std::collections::HashSet;
5847
5848            let mut scratch =
5849                libseccomp::ScmpFilterContext::new(libseccomp::ScmpAction::Errno(libc::EPERM))
5850                    .map_err(to_io_err)?;
5851            let mut accepted = HashSet::new();
5852            for name in SECCOMP_SYSCALL_ALLOWLIST {
5853                if let Ok(syscall) = libseccomp::ScmpSyscall::from_name(name) {
5854                    if scratch
5855                        .add_rule(libseccomp::ScmpAction::Allow, syscall)
5856                        .is_ok()
5857                    {
5858                        accepted.insert(i32::from(syscall));
5859                    }
5860                }
5861            }
5862            Ok(accepted.len())
5863        }
5864
5865        /// The line both ceiling probes print instead of running, on a host where no seccomp
5866        /// filter can be loaded at all.
5867        fn skip_without_seccomp(what: &str) -> bool {
5868            let tier = detect_enforcement_tier();
5869            if tier == EnforcementTier::EnvironmentOnly {
5870                eprintln!(
5871                    "SKIP — PROVES NOTHING ON THIS HOST: measuring {what} requires a host that \
5872                     can load a seccomp filter at all, which is every tier above \
5873                     EnforcementTier::EnvironmentOnly; detected {tier:?}"
5874                );
5875                return true;
5876            }
5877            false
5878        }
5879
5880        /// What the real filter compiles to, against the budget this project holds itself to and
5881        /// the ceiling the kernel holds it to.
5882        ///
5883        /// Both `unix_sockets_allowed` settings, because they build different rule sets — one
5884        /// moves `AF_UNIX` from the denied domains to the allowed ones — and the budget has to
5885        /// cover the larger of the two.
5886        #[test]
5887        fn seccomp_filter_instruction_count() {
5888            for unix_sockets_allowed in [false, true] {
5889                let count = super::seccomp_filter_instruction_count(unix_sockets_allowed)
5890                    .expect("export the built seccomp filter");
5891
5892                eprintln!(
5893                    "seccomp filter instruction count: unix_sockets_allowed={unix_sockets_allowed} \
5894                     -> {count} instructions; budget {SECCOMP_FILTER_INSTRUCTION_BUDGET}; kernel \
5895                     per-filter ceiling {BPF_MAXINSNS_CEILING} ({:.1}x headroom); kernel \
5896                     cumulative ceiling {SECCOMP_MAX_INSNS_PER_PATH} ({:.1}x headroom)",
5897                    BPF_MAXINSNS_CEILING as f64 / count as f64,
5898                    SECCOMP_MAX_INSNS_PER_PATH as f64 / count as f64,
5899                );
5900
5901                assert!(
5902                    count > 0,
5903                    "the exported filter is empty, so nothing was measured — \
5904                     `export_bpf` wrote no `struct sock_filter` records at all"
5905                );
5906                assert!(
5907                    count <= SECCOMP_FILTER_INSTRUCTION_BUDGET,
5908                    "the child's seccomp filter compiles to {count} instructions with \
5909                     unix_sockets_allowed={unix_sockets_allowed}, over this project's budget of \
5910                     {SECCOMP_FILTER_INSTRUCTION_BUDGET} (the kernel refuses a single filter over \
5911                     {BPF_MAXINSNS_CEILING} with EINVAL). Re-measure, decide whether the rule set \
5912                     still earns its size, and raise SECCOMP_FILTER_INSTRUCTION_BUDGET \
5913                     deliberately — updating \
5914                     docs/content/reference/seccomp-filter-instruction-budget.md with the new \
5915                     numbers — rather than letting the filter grow into a spawn failure"
5916                );
5917            }
5918        }
5919
5920        /// The rule sequence `build_seccomp_filter` lays down, pinned against the filter's own
5921        /// pseudo-filter-code export so a change to the shape is a test failure rather than a
5922        /// silent change of policy.
5923        ///
5924        /// The `EACCES` count is the load-bearing one. An unconditional `Allow` on `socket`
5925        /// anywhere in this function would make libseccomp discard every argument-conditional
5926        /// chain already recorded for that syscall — reopening `AF_UNIX`, `AF_NETLINK` and
5927        /// `AF_PACKET` — and it would show up here as zero `EACCES` actions.
5928        #[test]
5929        fn seccomp_filter_rule_shape() {
5930            let allowlist_syscalls =
5931                resolvable_allowlist_syscalls().expect("count the resolvable allowlist syscalls");
5932            assert!(
5933                allowlist_syscalls > 0,
5934                "no SECCOMP_SYSCALL_ALLOWLIST name resolved on this host, so this test would pass \
5935                 for the wrong reason"
5936            );
5937
5938            for unix_sockets_allowed in [false, true] {
5939                let filter =
5940                    build_seccomp_filter(unix_sockets_allowed).expect("build the seccomp filter");
5941                assert_eq!(
5942                    filter.get_act_default().expect("read the default action"),
5943                    libseccomp::ScmpAction::Errno(libc::EPERM),
5944                    "the filter's default action is no longer a deny with EPERM, which is what \
5945                     makes every syscall outside the allowlist unreachable"
5946                );
5947
5948                let pfc =
5949                    seccomp_filter_pfc(unix_sockets_allowed).expect("export the filter's pfc");
5950                let denied = denied_socket_domains(unix_sockets_allowed);
5951                let allowed = allowed_socket_domains(unix_sockets_allowed);
5952
5953                // One block per syscall carrying at least one rule: `socket`, every allowlist
5954                // name libseccomp accepted, and the three Landlock syscalls.
5955                let syscall_blocks = pfc.matches("# filter for syscall ").count();
5956                assert_eq!(
5957                    syscall_blocks,
5958                    1 + allowlist_syscalls + LANDLOCK_SYSCALLS.len(),
5959                    "the built filter carries rules for {syscall_blocks} syscalls, not the \
5960                     socket rule plus {allowlist_syscalls} allowlist syscalls plus {} Landlock \
5961                     syscalls this code adds",
5962                    LANDLOCK_SYSCALLS.len()
5963                );
5964
5965                let deny_actions = pfc
5966                    .matches(&format!("action ERRNO({});", libc::EACCES))
5967                    .count();
5968                assert_eq!(
5969                    deny_actions,
5970                    denied.len(),
5971                    "the built filter carries {deny_actions} EACCES rules, not one per denied \
5972                     socket domain ({denied:?}) — an unconditional Allow on `socket` would look \
5973                     exactly like this, and would have discarded every domain denial with it"
5974                );
5975
5976                // Every non-`socket` block contributes exactly one `Allow`; the `socket` block
5977                // contributes one per allowed domain.
5978                let allow_actions = pfc.matches("action ALLOW;").count();
5979                assert_eq!(
5980                    allow_actions,
5981                    allowed.len() + allowlist_syscalls + LANDLOCK_SYSCALLS.len(),
5982                    "the built filter carries {allow_actions} Allow rules, not one per allowed \
5983                     socket domain ({allowed:?}) plus one per allowlist syscall plus one per \
5984                     Landlock syscall"
5985                );
5986
5987                for (name, _) in LANDLOCK_SYSCALLS {
5988                    assert!(
5989                        pfc.contains(&format!("\"{name}\"")),
5990                        "{name} has no rule in the built filter, so `apply_landlock_scope` would \
5991                         be refused by the filter installed just before it"
5992                    );
5993                }
5994
5995                eprintln!(
5996                    "seccomp filter rule shape: unix_sockets_allowed={unix_sockets_allowed} -> \
5997                     default ERRNO(EPERM); {} socket EACCES rules; {} socket ALLOW rules; \
5998                     {allowlist_syscalls} allowlist syscalls; {} Landlock syscalls",
5999                    denied.len(),
6000                    allowed.len(),
6001                    LANDLOCK_SYSCALLS.len(),
6002                );
6003            }
6004        }
6005
6006        /// Brackets `BPF_MAXINSNS`, the kernel's limit on a *single* filter's length, by loading
6007        /// two filters that straddle it and observing that the kernel takes one and refuses the
6008        /// other with `EINVAL`.
6009        ///
6010        /// `EINVAL`, not `ENOMEM`: an `ENOMEM` out of a seccomp attach never came from this
6011        /// ceiling, whatever the filter's length.
6012        #[test]
6013        fn seccomp_per_filter_ceiling() {
6014            if skip_without_seccomp("the kernel's per-filter instruction ceiling") {
6015                return;
6016            }
6017
6018            let over_rules = first_rule_count_above(BPF_MAXINSNS_CEILING)
6019                .expect("bracket the per-filter ceiling");
6020            let (under_filter, under_count) =
6021                probe_filter(over_rules - 1).expect("build the under-ceiling probe filter");
6022            let (over_filter, over_count) =
6023                probe_filter(over_rules).expect("build the over-ceiling probe filter");
6024
6025            assert!(
6026                under_count <= BPF_MAXINSNS_CEILING && over_count > BPF_MAXINSNS_CEILING,
6027                "the two probe filters ({under_count} and {over_count} instructions) do not \
6028                 straddle BPF_MAXINSNS_CEILING ({BPF_MAXINSNS_CEILING}), so this test would prove \
6029                 nothing about where the ceiling is"
6030            );
6031
6032            let under_outcome = load_in_child(&under_filter);
6033            let over_outcome = load_in_child(&over_filter);
6034
6035            eprintln!(
6036                "seccomp per-filter ceiling: largest filter that loaded = {under_count} \
6037                 instructions; smallest that did not = {over_count} instructions, refused with \
6038                 errno {} (EINVAL is {}); recorded BPF_MAXINSNS_CEILING = {BPF_MAXINSNS_CEILING}",
6039                over_outcome.err().unwrap_or(0),
6040                libc::EINVAL,
6041            );
6042
6043            assert_eq!(
6044                under_outcome,
6045                Ok(()),
6046                "a {under_count}-instruction filter was refused, below the \
6047                 {BPF_MAXINSNS_CEILING}-instruction ceiling this test expects the kernel to hold"
6048            );
6049            assert_eq!(
6050                over_outcome,
6051                Err(libc::EINVAL),
6052                "a {over_count}-instruction filter was not refused with EINVAL, so \
6053                 BPF_MAXINSNS_CEILING ({BPF_MAXINSNS_CEILING}) is not where this kernel puts its \
6054                 per-filter limit"
6055            );
6056        }
6057
6058        /// Measures `MAX_INSNS_PER_PATH`, the kernel's limit on the *whole chain* of filters one
6059        /// task carries, by stacking filters in a forked child until the kernel refuses.
6060        ///
6061        /// `ENOMEM` is what this ceiling returns, and it is the only seccomp attach path in the
6062        /// kernel that returns it — so an `ENOMEM` from a runtime that installs exactly one
6063        /// filter of a few hundred instructions, and that had not yet reached `load()` when it
6064        /// failed, did not come from the kernel's seccomp code.
6065        #[test]
6066        fn seccomp_cumulative_ceiling() {
6067            if skip_without_seccomp("the kernel's cumulative filter-chain ceiling") {
6068                return;
6069            }
6070
6071            // A probe of roughly the shipped filter's size, so the number of stacked loads reads
6072            // directly as "how many filters like ours one task could carry".
6073            let (filter, count) = probe_filter(120).expect("build the cumulative probe filter");
6074            let per_filter_cost = count + KERNEL_PER_FILTER_OVERHEAD_INSNS;
6075            // Enough headroom past the ceiling that a kernel which never refuses is caught by the
6076            // cap rather than by the test hanging.
6077            let max_loads = SECCOMP_MAX_INSNS_PER_PATH / per_filter_cost + 16;
6078
6079            let mut fds = [0 as libc::c_int; 2];
6080            // SAFETY: `fds` is a live two-element array, which is what `pipe2` writes into.
6081            let rc = unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC) };
6082            assert!(rc == 0, "pipe2: {}", io::Error::last_os_error());
6083            // SAFETY: both descriptors are fresh out of `pipe2` and owned by nothing else.
6084            let (read_fd, write_fd) =
6085                unsafe { (OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])) };
6086
6087            // SAFETY: `fork()` from a possibly-multithreaded process is sound as long as the
6088            // child confines itself to work that does not depend on another thread's state. This
6089            // child calls `load()` in a loop, writes eight bytes and `_exit`s.
6090            let pid = unsafe { libc::fork() };
6091            assert!(pid >= 0, "fork failed: {}", io::Error::last_os_error());
6092
6093            if pid == 0 {
6094                let mut loads: u32 = 0;
6095                let mut refusal: i32 = 0;
6096                while loads < max_loads as u32 {
6097                    match filter.load() {
6098                        Ok(()) => loads += 1,
6099                        Err(error) => {
6100                            refusal = refusal_errno(&error);
6101                            break;
6102                        }
6103                    }
6104                }
6105
6106                let mut report = [0u8; 8];
6107                report[..4].copy_from_slice(&loads.to_ne_bytes());
6108                report[4..].copy_from_slice(&refusal.to_ne_bytes());
6109                // SAFETY: forked-child context; `write` is async-signal-safe, `report` is a live
6110                // local of exactly the length passed, and the descriptor is the pipe's write end.
6111                unsafe { libc::write(write_fd.as_raw_fd(), report.as_ptr().cast(), report.len()) };
6112                // SAFETY: forked-child context; `_exit` skips every destructor and atexit hook.
6113                unsafe { libc::_exit(0) }
6114            }
6115
6116            drop(write_fd);
6117            let mut report = [0u8; 8];
6118            std::io::Read::read_exact(&mut std::fs::File::from(read_fd), &mut report)
6119                .expect("read the cumulative probe child's report");
6120            let loads = u32::from_ne_bytes(report[..4].try_into().unwrap()) as usize;
6121            let refusal = i32::from_ne_bytes(report[4..].try_into().unwrap());
6122
6123            let mut status: libc::c_int = 0;
6124            // SAFETY: `pid` is the child just forked; `status` is a live local.
6125            let waited = unsafe { libc::waitpid(pid, &mut status, 0) };
6126            assert!(waited == pid, "waitpid: {}", io::Error::last_os_error());
6127
6128            let stacked = loads * count;
6129            let cumulative_at_refusal = count + loads * per_filter_cost;
6130            let loads_if_counted_classic = SECCOMP_MAX_INSNS_PER_PATH / per_filter_cost;
6131            let shipped = super::seccomp_filter_instruction_count(false)
6132                .expect("measure the shipped filter for comparison");
6133
6134            eprintln!(
6135                "seccomp cumulative ceiling: {loads} loads of a {count}-instruction filter \
6136                 succeeded ({stacked} instructions stacked, {cumulative_at_refusal} counted the \
6137                 way the kernel counts once the {KERNEL_PER_FILTER_OVERHEAD_INSNS}-instruction \
6138                 per-filter overhead is added); load {} was refused with errno {refusal} (ENOMEM \
6139                 is {}), against the recorded SECCOMP_MAX_INSNS_PER_PATH of \
6140                 {SECCOMP_MAX_INSNS_PER_PATH} — which the exported classic length puts at at most \
6141                 {loads_if_counted_classic} loads. The runtime installs ONE filter of {shipped} \
6142                 instructions, so reaching this ceiling would take {} of them in a single task.",
6143                loads + 1,
6144                libc::ENOMEM,
6145                SECCOMP_MAX_INSNS_PER_PATH / shipped,
6146            );
6147
6148            assert!(
6149                loads >= 2,
6150                "the kernel refused the filter chain after only {loads} load(s), so this is not \
6151                 a cumulative limit and the ENOMEM it returns is not the one being ruled out"
6152            );
6153            assert_eq!(
6154                refusal,
6155                libc::ENOMEM,
6156                "stacking filters past SECCOMP_MAX_INSNS_PER_PATH was refused with errno \
6157                 {refusal}, not ENOMEM ({}) — the rule-out in \
6158                 docs/content/reference/seccomp-filter-instruction-budget.md rests on ENOMEM \
6159                 being what this ceiling returns",
6160                libc::ENOMEM
6161            );
6162            assert!(
6163                loads <= loads_if_counted_classic,
6164                "the kernel took {loads} loads of a {count}-instruction filter — more than the \
6165                 {loads_if_counted_classic} that fit under SECCOMP_MAX_INSNS_PER_PATH \
6166                 ({SECCOMP_MAX_INSNS_PER_PATH}) even before the conversion to eBPF lengthens \
6167                 each one, so this kernel's ceiling is higher than the recorded value"
6168            );
6169            // Loose on purpose: the exported classic length under-counts what the kernel adds
6170            // up, so the bound below tolerates any plausible conversion expansion while still
6171            // refusing a ceiling of a different order of magnitude — a kernel that stopped at,
6172            // say, BPF_MAXINSNS would fail here.
6173            assert!(
6174                loads * 2 > loads_if_counted_classic,
6175                "the kernel refused after {loads} loads of a {count}-instruction filter, far \
6176                 short of the {loads_if_counted_classic} that SECCOMP_MAX_INSNS_PER_PATH \
6177                 ({SECCOMP_MAX_INSNS_PER_PATH}) implies — the ceiling being measured is not the \
6178                 one recorded"
6179            );
6180            assert!(
6181                shipped * 2 < SECCOMP_MAX_INSNS_PER_PATH,
6182                "the shipped filter ({shipped} instructions) is within a factor of two of the \
6183                 cumulative ceiling, which would make the rule-out this test supports untrue"
6184            );
6185        }
6186    }
6187}
6188
6189/// Builds a minimal but well-formed ELF64 image exercising exactly the fields
6190/// `parse_elf_dependencies` reads: a single identity-mapped `PT_LOAD` (so virtual address ==
6191/// file offset), an optional `PT_INTERP`, and — when any dynamic metadata is requested — a
6192/// `PT_DYNAMIC` with `DT_NEEDED`/`DT_RUNPATH`/`DT_STRTAB`/`DT_STRSZ`/`DT_NULL` entries.
6193#[cfg(test)]
6194pub(crate) fn build_elf64(interp: Option<&str>, needed: &[&str], runpath: Option<&str>) -> Vec<u8> {
6195    let has_dynamic = !needed.is_empty() || runpath.is_some();
6196
6197    let mut phnum: u16 = 1; // PT_LOAD
6198    if interp.is_some() {
6199        phnum += 1;
6200    }
6201    if has_dynamic {
6202        phnum += 1;
6203    }
6204
6205    let ehsize = 64usize;
6206    let phentsize = 56usize;
6207    let phoff = ehsize;
6208    let body_start = phoff + phentsize * phnum as usize;
6209
6210    // Dynamic string table: index 0 is a NUL, then each soname and the runpath.
6211    let mut strtab: Vec<u8> = vec![0];
6212    let mut needed_offsets = Vec::new();
6213    for name in needed {
6214        needed_offsets.push(strtab.len() as u64);
6215        strtab.extend_from_slice(name.as_bytes());
6216        strtab.push(0);
6217    }
6218    let runpath_offset = runpath.map(|r| {
6219        let off = strtab.len() as u64;
6220        strtab.extend_from_slice(r.as_bytes());
6221        strtab.push(0);
6222        off
6223    });
6224
6225    let mut body: Vec<u8> = Vec::new();
6226
6227    let interp_offset = body_start + body.len();
6228    if let Some(i) = interp {
6229        body.extend_from_slice(i.as_bytes());
6230        body.push(0);
6231    }
6232
6233    // Align the dynamic array to 8 bytes.
6234    while !(body_start + body.len()).is_multiple_of(8) {
6235        body.push(0);
6236    }
6237    let dyn_offset = body_start + body.len();
6238    let num_dyn = needed.len() + runpath.map_or(0, |_| 1) + 3; // + DT_STRTAB, DT_STRSZ, DT_NULL
6239    let dyn_size = num_dyn * 16;
6240    let strtab_offset = dyn_offset + dyn_size;
6241
6242    if has_dynamic {
6243        let mut push_dyn = |tag: u64, val: u64| {
6244            body.extend_from_slice(&tag.to_le_bytes());
6245            body.extend_from_slice(&val.to_le_bytes());
6246        };
6247        for &o in &needed_offsets {
6248            push_dyn(1, o); // DT_NEEDED
6249        }
6250        if let Some(ro) = runpath_offset {
6251            push_dyn(29, ro); // DT_RUNPATH
6252        }
6253        push_dyn(5, strtab_offset as u64); // DT_STRTAB (vaddr == offset, identity PT_LOAD)
6254        push_dyn(10, strtab.len() as u64); // DT_STRSZ
6255        push_dyn(0, 0); // DT_NULL
6256        assert_eq!(body_start + body.len(), strtab_offset);
6257        body.extend_from_slice(&strtab);
6258    }
6259
6260    let total_len = body_start + body.len();
6261    let mut out = vec![0u8; total_len];
6262    out[0..4].copy_from_slice(b"\x7fELF");
6263    out[4] = 2; // ELFCLASS64
6264    out[5] = 1; // little-endian
6265    out[6] = 1; // EI_VERSION
6266    out[16..18].copy_from_slice(&3u16.to_le_bytes()); // e_type = ET_DYN
6267    out[18..20].copy_from_slice(&0x3eu16.to_le_bytes()); // e_machine = x86_64 (irrelevant)
6268    out[20..24].copy_from_slice(&1u32.to_le_bytes()); // e_version
6269    out[32..40].copy_from_slice(&(phoff as u64).to_le_bytes()); // e_phoff
6270    out[54..56].copy_from_slice(&(phentsize as u16).to_le_bytes()); // e_phentsize
6271    out[56..58].copy_from_slice(&phnum.to_le_bytes()); // e_phnum
6272
6273    let mut phdrs: Vec<(u32, u64, u64, u64)> = Vec::new(); // (p_type, p_offset, p_vaddr, p_filesz)
6274    phdrs.push((1, 0, 0, total_len as u64)); // PT_LOAD identity-maps the whole file
6275    if let Some(i) = interp {
6276        phdrs.push((
6277            3,
6278            interp_offset as u64,
6279            interp_offset as u64,
6280            i.len() as u64 + 1,
6281        ));
6282    }
6283    if has_dynamic {
6284        phdrs.push((2, dyn_offset as u64, dyn_offset as u64, dyn_size as u64));
6285    }
6286    for (idx, (p_type, p_offset, p_vaddr, p_filesz)) in phdrs.iter().enumerate() {
6287        let base = phoff + idx * phentsize;
6288        out[base..base + 4].copy_from_slice(&p_type.to_le_bytes());
6289        out[base + 8..base + 16].copy_from_slice(&p_offset.to_le_bytes());
6290        out[base + 16..base + 24].copy_from_slice(&p_vaddr.to_le_bytes());
6291        out[base + 32..base + 40].copy_from_slice(&p_filesz.to_le_bytes());
6292    }
6293
6294    out[body_start..total_len].copy_from_slice(&body);
6295    out
6296}
6297
6298#[cfg(test)]
6299mod tests {
6300    use super::*;
6301    use murmur_artifact::security_warnings::W_SEC_003;
6302
6303    /// Content check only: this asserts the *constant*, not the security property. Spawning a
6304    /// subprocess and asserting it cannot see an inherited fd passes vacuously without kernel
6305    /// enforcement, so it would read as evidence while proving nothing.
6306    ///
6307    /// It does catch the one silent, catastrophic edit: a `0` here marks stdio close-on-exec and
6308    /// breaks every subprocess invocation.
6309    #[test]
6310    fn fd_hygiene_range_starts_above_stdio() {
6311        assert_eq!(
6312            FD_HYGIENE_FIRST_FD, 3,
6313            "fd hygiene must start at 3 — 0/1/2 are the stdio pipes every spawn needs to \
6314             survive execve"
6315        );
6316    }
6317
6318    /// `mark_inherited_fds_cloexec` reaches `close_range` through `libc::syscall`, so its failure
6319    /// convention is the raw one: `-1` with `errno` set. That convention is what turns a kernel
6320    /// that rejects the call into a failed `Command::spawn()` rather than a spawn that silently
6321    /// runs without fd hygiene.
6322    ///
6323    /// The rejection is provoked with an all-bits-set flag word, which the kernel refuses during
6324    /// argument validation before it touches any descriptor — so this exercises the errno path
6325    /// without closing or flagging a single fd of the test process. A kernel older than 5.11
6326    /// rejects the production call with a different errno and the identical convention — see the
6327    /// **Kernel range narrowing** paragraph on `mark_inherited_fds_cloexec`.
6328    #[cfg(target_os = "linux")]
6329    #[allow(unsafe_code)]
6330    #[test]
6331    fn close_range_syscall_reports_failure_as_minus_one_and_errno() {
6332        // SAFETY: `close_range` takes three scalar arguments and dereferences nothing. Every flag
6333        // bit is set here, so the kernel rejects the call on argument validation and no descriptor
6334        // of this process is closed or marked close-on-exec.
6335        let rc = unsafe {
6336            libc::syscall(
6337                libc::SYS_close_range,
6338                FD_HYGIENE_FIRST_FD,
6339                libc::c_uint::MAX,
6340                libc::c_uint::MAX,
6341            )
6342        };
6343
6344        assert_eq!(rc, -1, "an invalid flag word must not be accepted");
6345        assert_eq!(
6346            std::io::Error::last_os_error().raw_os_error(),
6347            Some(libc::EINVAL),
6348            "the two statements the production path runs on failure must carry the kernel's errno"
6349        );
6350    }
6351
6352    /// A host whose probe created all three namespaces — read under the AppArmor restriction with
6353    /// `mur` unconfined, because the readings never decide anything.
6354    fn sealed_capable() -> crate::namespace_probe::NamespaceReading {
6355        crate::namespace_probe::NamespaceReading {
6356            network: Ok(()),
6357            mount: crate::namespace_probe::MountAttempt::Created,
6358            sealed_root: crate::namespace_probe::MountAttempt::Created,
6359            userns_grant: crate::sealed::UsernsGrant::Withheld,
6360            attachment: crate::sealed::ProfileAttachment::Unconfined,
6361        }
6362    }
6363
6364    /// A host whose probe could not run at all, which claims nothing.
6365    fn sealed_incapable() -> crate::namespace_probe::NamespaceReading {
6366        crate::namespace_probe::NamespaceReading {
6367            network: Err(crate::network_namespace::NamespaceAttemptError::Creator(
6368                "the namespace creator did not run".to_string(),
6369            )),
6370            mount: crate::namespace_probe::MountAttempt::NotAttempted,
6371            sealed_root: crate::namespace_probe::MountAttempt::NotAttempted,
6372            userns_grant: crate::sealed::UsernsGrant::Withheld,
6373            attachment: crate::sealed::ProfileAttachment::NotReported,
6374        }
6375    }
6376
6377    /// `mur doctor`'s native-tool mechanism is read from the decision on the reading it is handed:
6378    /// on a sealed-capable reading a capsule declaring `scoped` runs its native tools on
6379    /// `landlock+seccomp` and one declaring `sealed` on the composed root.
6380    #[test]
6381    fn doctor_reads_the_decision_from_the_probe_it_is_handed() {
6382        if !cfg!(target_os = "linux") {
6383            return;
6384        }
6385        let sealed_host = HostProbe::from_parts(Some(true), sealed_capable());
6386        assert_eq!(sealed_host.tier(), EnforcementTier::KernelSealed);
6387        assert_eq!(
6388            crate::containment::applied_mechanism(
6389                &sealed_host,
6390                murmur_artifact::ContainmentClass::Scoped
6391            ),
6392            "landlock+seccomp"
6393        );
6394        assert_eq!(
6395            crate::containment::applied_mechanism(
6396                &sealed_host,
6397                murmur_artifact::ContainmentClass::Sealed
6398            ),
6399            "mountns+pivot_root+landlock+seccomp"
6400        );
6401    }
6402
6403    /// A probe that learned nothing: no Landlock answer and a namespace probe that could not run.
6404    /// The reading a host gives when neither half of the probe could run.
6405    fn unprobed() -> HostProbe {
6406        HostProbe::from_parts(None, sealed_incapable())
6407    }
6408
6409    /// A probe that could not run must land on the weakest Linux tier, not on either kernel tier
6410    /// it failed to demonstrate. This is the fail-closed direction, asserted at the one place the
6411    /// tier is decided from a reading.
6412    #[test]
6413    fn an_unprobed_host_resolves_to_seccomp_only() {
6414        let tier = unprobed().tier();
6415        if cfg!(target_os = "linux") {
6416            assert_eq!(tier, EnforcementTier::KernelSeccompOnly);
6417        } else {
6418            assert_eq!(tier, EnforcementTier::EnvironmentOnly);
6419        }
6420        assert_ne!(tier, EnforcementTier::KernelSealed);
6421        assert_ne!(tier, EnforcementTier::KernelFull);
6422    }
6423
6424    /// A reading that demonstrated nothing must still name what is missing: a refusal with no
6425    /// blocker is a refusal an operator cannot act on.
6426    #[test]
6427    fn an_unprobed_host_names_a_blocker() {
6428        assert!(unprobed().sealed_shortfall().is_some());
6429    }
6430
6431    /// The achieved class the trace records for an unprobed host stays strictly below `sealed`,
6432    /// so nothing downstream can read a failed probe as a met floor.
6433    #[test]
6434    fn an_unprobed_host_achieves_less_than_sealed() {
6435        let achieved = crate::containment::achieved_class_for_tier(unprobed().tier());
6436        assert!(achieved < murmur_artifact::ContainmentClass::Sealed);
6437    }
6438
6439    /// The whole point of the direction: a capsule declaring `sealed` against a reading that
6440    /// demonstrated nothing is refused, with the shortfall naming the mechanism.
6441    #[test]
6442    fn an_unprobed_host_refuses_a_declared_sealed_floor() {
6443        let probe = unprobed();
6444        let achieved = crate::containment::achieved_class_for_tier(probe.tier());
6445        let error = crate::containment::check_containment_floor(
6446            murmur_artifact::ContainmentClass::Sealed,
6447            achieved,
6448            probe.sealed_shortfall().as_ref(),
6449            false,
6450            None,
6451        )
6452        .expect_err("an unprobed host cannot back a declared sealed floor");
6453        assert!(matches!(
6454            error,
6455            crate::errors::RuntimeError::ContainmentFloorUnmet { .. }
6456        ));
6457    }
6458
6459    /// The probe succeeding while the AppArmor setting reads `1`: all three attempts created,
6460    /// `mur` unconfined or confined by a container's profile. The host backs `sealed`, maps
6461    /// `/tmp`, and is refused nothing.
6462    #[test]
6463    fn a_probe_that_created_every_namespace_backs_sealed_while_the_setting_reads_one() {
6464        for attachment in [
6465            crate::sealed::ProfileAttachment::Unconfined,
6466            crate::sealed::ProfileAttachment::Other {
6467                profile: "docker-default".to_string(),
6468            },
6469        ] {
6470            let probe = HostProbe::from_parts(
6471                Some(true),
6472                crate::namespace_probe::NamespaceReading {
6473                    attachment,
6474                    ..sealed_capable()
6475                },
6476            );
6477            if !cfg!(target_os = "linux") {
6478                assert_eq!(probe.tier(), EnforcementTier::EnvironmentOnly);
6479                continue;
6480            }
6481            assert_eq!(probe.tier(), EnforcementTier::KernelSealed);
6482            assert_eq!(probe.sealed_shortfall(), None);
6483            assert!(probe.mount_namespace_usable());
6484            assert_eq!(probe.egress_namespace_failure(), None);
6485            assert_eq!(
6486                crate::temp_dir::temp_mapping(
6487                    EnforcementTier::KernelFull,
6488                    probe.mount_namespace_usable(),
6489                    Path::new("/home/u/w"),
6490                ),
6491                crate::temp_dir::TempMapping::MountNamespace
6492            );
6493        }
6494    }
6495
6496    /// No decision reads the grant: over every reading of the restriction, the tier, the
6497    /// shortfall, the `/tmp` question and the network failure are the same — for a probe that
6498    /// created everything, and for one that created only the network and mount namespaces.
6499    #[test]
6500    fn every_decision_is_identical_across_every_userns_grant() {
6501        use crate::namespace_probe::{
6502            MountAttempt, MountAttemptError, MountStep, NamespaceReading,
6503        };
6504        let root_refused = NamespaceReading {
6505            sealed_root: MountAttempt::Failed(MountAttemptError::Step {
6506                step: MountStep::PivotRoot,
6507                errno: libc::EPERM,
6508            }),
6509            ..sealed_capable()
6510        };
6511        for base in [sealed_capable(), root_refused, sealed_incapable()] {
6512            let decide = |grant: crate::sealed::UsernsGrant| {
6513                let probe = HostProbe::from_parts(
6514                    Some(true),
6515                    NamespaceReading {
6516                        userns_grant: grant,
6517                        ..base.clone()
6518                    },
6519                );
6520                (
6521                    probe.tier(),
6522                    probe.sealed_shortfall().map(|shortfall| shortfall.blocker),
6523                    probe.mount_namespace_usable(),
6524                    probe
6525                        .egress_namespace_failure()
6526                        .map(|failure| failure.blocker),
6527                )
6528            };
6529            let expected = decide(crate::sealed::UsernsGrant::Withheld);
6530            for grant in crate::sealed::UsernsGrant::ALL {
6531                assert_eq!(decide(*grant), expected, "{grant:?} over {base:?}");
6532            }
6533        }
6534    }
6535
6536    /// On whatever host this runs, one live reading is `KernelSealed` exactly when it names no
6537    /// `sealed` shortfall, maps `/tmp` exactly when the network and mount attempts created their
6538    /// namespaces, and reports `E-CAP-005` exactly when the network attempt failed.
6539    #[test]
6540    fn a_live_reading_answers_every_question_from_its_own_attempts() {
6541        let probe = HostProbe::probe();
6542        let namespaces = probe.namespaces().clone();
6543        let linux = cfg!(target_os = "linux");
6544        assert_eq!(
6545            probe.tier() == EnforcementTier::KernelSealed,
6546            probe.sealed_shortfall().is_none(),
6547            "{namespaces:?}"
6548        );
6549        assert_eq!(
6550            probe.mount_namespace_usable(),
6551            linux
6552                && namespaces.network.is_ok()
6553                && namespaces.mount == crate::namespace_probe::MountAttempt::Created
6554        );
6555        assert_eq!(
6556            probe.egress_namespace_failure().is_some(),
6557            linux && namespaces.network.is_err()
6558        );
6559    }
6560
6561    #[test]
6562    fn tier_from_probe_non_linux_is_always_environment_only() {
6563        for landlock in [None, Some(true), Some(false)] {
6564            for namespaces in [sealed_capable(), sealed_incapable()] {
6565                assert_eq!(
6566                    tier_from_probe(false, landlock, &namespaces),
6567                    EnforcementTier::EnvironmentOnly
6568                );
6569            }
6570        }
6571    }
6572
6573    #[test]
6574    fn tier_from_probe_linux_fully_enforced_is_kernel_full() {
6575        assert_eq!(
6576            tier_from_probe(true, Some(true), &sealed_incapable()),
6577            EnforcementTier::KernelFull
6578        );
6579    }
6580
6581    #[test]
6582    fn tier_from_probe_linux_partially_enforced_is_seccomp_only() {
6583        assert_eq!(
6584            tier_from_probe(true, Some(false), &sealed_incapable()),
6585            EnforcementTier::KernelSeccompOnly
6586        );
6587    }
6588
6589    #[test]
6590    fn tier_from_probe_linux_no_probe_result_is_seccomp_only() {
6591        assert_eq!(
6592            tier_from_probe(true, None, &sealed_incapable()),
6593            EnforcementTier::KernelSeccompOnly
6594        );
6595    }
6596
6597    /// The whole conjunction, one element removed at a time: `KernelSealed` needs Linux, a fully
6598    /// enforced Landlock ruleset and a sealed-root rehearsal that built the composed root — and
6599    /// drops to exactly the tier the missing element still supports, never further.
6600    #[test]
6601    fn tier_from_probe_reaches_sealed_only_when_every_precondition_holds() {
6602        use crate::namespace_probe::{
6603            MountAttempt, MountAttemptError, MountStep, NamespaceReading,
6604        };
6605
6606        assert_eq!(
6607            tier_from_probe(true, Some(true), &sealed_capable()),
6608            EnforcementTier::KernelSealed
6609        );
6610
6611        // A refused network namespace attempts nothing after it: `scoped`, not `advisory`.
6612        let network_refused = NamespaceReading {
6613            network: Err(crate::network_namespace::NamespaceAttemptError::Step {
6614                step: crate::network_namespace::CreationStep::IdentityMaps,
6615                errno: libc::EPERM,
6616            }),
6617            mount: MountAttempt::NotAttempted,
6618            sealed_root: MountAttempt::NotAttempted,
6619            ..sealed_capable()
6620        };
6621        assert_eq!(
6622            tier_from_probe(true, Some(true), &network_refused),
6623            EnforcementTier::KernelFull
6624        );
6625
6626        // Every way the sealed-root rehearsal can fail falls back to KernelFull, never below.
6627        for step in [
6628            MountStep::JoinSession,
6629            MountStep::UnshareUserMount,
6630            MountStep::IdentityMaps,
6631            MountStep::MakePrivateNested,
6632            MountStep::Tmpfs,
6633            MountStep::PivotPrepare,
6634            MountStep::PivotRoot,
6635            MountStep::DetachOldRoot,
6636        ] {
6637            let reading = NamespaceReading {
6638                sealed_root: MountAttempt::Failed(MountAttemptError::Step {
6639                    step,
6640                    errno: libc::EPERM,
6641                }),
6642                ..sealed_capable()
6643            };
6644            assert_eq!(
6645                tier_from_probe(true, Some(true), &reading),
6646                EnforcementTier::KernelFull,
6647                "a sealed root refused at {step:?} must fall back to KernelFull"
6648            );
6649        }
6650
6651        // The `/tmp` attempt is not the composed root's: refusing it leaves `sealed` reachable.
6652        let tmp_refused = NamespaceReading {
6653            mount: MountAttempt::Failed(MountAttemptError::Step {
6654                step: MountStep::MakePrivate,
6655                errno: libc::EACCES,
6656            }),
6657            ..sealed_capable()
6658        };
6659        assert_eq!(
6660            tier_from_probe(true, Some(true), &tmp_refused),
6661            EnforcementTier::KernelSealed
6662        );
6663
6664        // Landlock missing: sealed keeps Landlock inside as defence in depth, so a host without it
6665        // cannot reach sealed no matter how good its namespaces are.
6666        assert_eq!(
6667            tier_from_probe(true, Some(false), &sealed_capable()),
6668            EnforcementTier::KernelSeccompOnly
6669        );
6670        assert_eq!(
6671            tier_from_probe(true, None, &sealed_capable()),
6672            EnforcementTier::KernelSeccompOnly
6673        );
6674
6675        // Not Linux at all.
6676        assert_eq!(
6677            tier_from_probe(false, Some(true), &sealed_capable()),
6678            EnforcementTier::EnvironmentOnly
6679        );
6680    }
6681
6682    /// `/tmp` follows the probe's mount attempt: refused at `MS_REC|MS_PRIVATE` it is not mapped;
6683    /// created with the sealed root refused it is mapped at `KernelFull`; with the network
6684    /// namespace refused there is no mount namespace to map it in.
6685    #[test]
6686    fn the_tmp_mapping_follows_the_mount_attempt() {
6687        use crate::namespace_probe::{
6688            MountAttempt, MountAttemptError, MountStep, NamespaceReading,
6689        };
6690        use crate::temp_dir::{temp_mapping, NotMappedReason, TempMapping};
6691        if !cfg!(target_os = "linux") {
6692            return;
6693        }
6694        let workdir = Path::new("/home/u/w");
6695        let refused = |step, errno| MountAttempt::Failed(MountAttemptError::Step { step, errno });
6696
6697        let mount_refused = HostProbe::from_parts(
6698            Some(true),
6699            NamespaceReading {
6700                mount: refused(MountStep::MakePrivate, libc::EACCES),
6701                sealed_root: refused(MountStep::MakePrivateNested, libc::EACCES),
6702                ..sealed_capable()
6703            },
6704        );
6705        assert_eq!(mount_refused.tier(), EnforcementTier::KernelFull);
6706        assert_eq!(
6707            temp_mapping(
6708                mount_refused.tier(),
6709                mount_refused.mount_namespace_usable(),
6710                workdir
6711            ),
6712            TempMapping::NotMapped(NotMappedReason::NoMountNamespace)
6713        );
6714
6715        let root_refused = HostProbe::from_parts(
6716            Some(true),
6717            NamespaceReading {
6718                sealed_root: refused(MountStep::PivotRoot, libc::EPERM),
6719                ..sealed_capable()
6720            },
6721        );
6722        assert_eq!(root_refused.tier(), EnforcementTier::KernelFull);
6723        assert_eq!(
6724            temp_mapping(
6725                root_refused.tier(),
6726                root_refused.mount_namespace_usable(),
6727                workdir
6728            ),
6729            TempMapping::MountNamespace
6730        );
6731
6732        let network_refused = HostProbe::from_parts(
6733            Some(true),
6734            NamespaceReading {
6735                network: Err(crate::network_namespace::NamespaceAttemptError::Step {
6736                    step: crate::network_namespace::CreationStep::Unshare,
6737                    errno: libc::EPERM,
6738                }),
6739                mount: MountAttempt::NotAttempted,
6740                sealed_root: MountAttempt::NotAttempted,
6741                ..sealed_capable()
6742            },
6743        );
6744        assert!(!network_refused.mount_namespace_usable());
6745        assert!(matches!(
6746            temp_mapping(
6747                network_refused.tier(),
6748                network_refused.mount_namespace_usable(),
6749                workdir
6750            ),
6751            TempMapping::NotMapped(_)
6752        ));
6753        assert!(network_refused.egress_namespace_failure().is_some());
6754    }
6755
6756    /// The ruleset ABI is the kernel's, capped at v3, on the two Landlock tiers only.
6757    #[test]
6758    fn ruleset_landlock_abi_is_the_kernel_abi_capped_at_the_target_on_landlock_tiers() {
6759        for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
6760            for (kernel, built) in [
6761                (Some(1), Some(1)),
6762                (Some(2), Some(2)),
6763                (Some(3), Some(3)),
6764                (Some(8), Some(3)),
6765                (Some(u8::MAX), Some(3)),
6766            ] {
6767                assert_eq!(
6768                    ruleset_landlock_abi(kernel, tier),
6769                    built,
6770                    "kernel ABI {kernel:?} on {tier:?}"
6771                );
6772            }
6773        }
6774    }
6775
6776    /// No Landlock on the kernel, or a tier that installs no Landlock domain, builds at no ABI.
6777    #[test]
6778    fn ruleset_landlock_abi_is_none_without_a_kernel_abi_or_a_landlock_tier() {
6779        for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
6780            assert_eq!(ruleset_landlock_abi(None, tier), None);
6781            assert_eq!(ruleset_landlock_abi(Some(0), tier), None);
6782        }
6783        for tier in [
6784            EnforcementTier::KernelSeccompOnly,
6785            EnforcementTier::EnvironmentOnly,
6786        ] {
6787            for kernel in [None, Some(0), Some(1), Some(3), Some(8)] {
6788                assert_eq!(
6789                    ruleset_landlock_abi(kernel, tier),
6790                    None,
6791                    "kernel ABI {kernel:?} on {tier:?}"
6792                );
6793            }
6794        }
6795    }
6796
6797    /// `ShellEnforcement::resolve` carries the ruleset ABI of the probe it is handed, keyed on the
6798    /// applied tier, and an all-negative probe carries none.
6799    #[test]
6800    fn ruleset_landlock_abi_reaches_shell_enforcement_from_the_probe() {
6801        let negative = unprobed();
6802        for abi in [None, Some(1), Some(8)] {
6803            let enforcement = ShellEnforcement::resolve_declared(
6804                &CapabilityPolicy::default(),
6805                murmur_artifact::ContainmentClass::Advisory,
6806                negative.clone().with_landlock_kernel_abi(abi),
6807            )
6808            .unwrap()
6809            .with_test_session_dirs();
6810            assert_eq!(
6811                enforcement.landlock_abi,
6812                ruleset_landlock_abi(abi, enforcement.tier),
6813                "kernel ABI {abi:?}"
6814            );
6815            // An all-negative probe resolves below the Landlock tiers on every OS.
6816            assert_eq!(enforcement.landlock_abi, None);
6817        }
6818        if cfg!(target_os = "linux") {
6819            let full = HostProbe::from_parts(Some(true), sealed_incapable());
6820            let enforcement = ShellEnforcement::resolve_declared(
6821                &CapabilityPolicy::default(),
6822                murmur_artifact::ContainmentClass::Scoped,
6823                full.with_landlock_kernel_abi(Some(8)),
6824            )
6825            .unwrap()
6826            .with_test_session_dirs();
6827            assert_eq!(enforcement.tier, EnforcementTier::KernelFull);
6828            assert_eq!(enforcement.landlock_abi, Some(LANDLOCK_TARGET_ABI));
6829        }
6830    }
6831
6832    /// The `mur doctor` Landlock block for each kernel ABI that changes what it says.
6833    #[test]
6834    fn landlock_doctor_lines_name_the_kernel_and_ruleset_abi() {
6835        if !cfg!(target_os = "linux") {
6836            for abi in [None, Some(1), Some(8)] {
6837                assert_eq!(
6838                    landlock_doctor_lines(abi),
6839                    vec!["kernel ABI: n/a (Landlock is a Linux mechanism)".to_string()]
6840                );
6841            }
6842            return;
6843        }
6844
6845        let none = landlock_doctor_lines(None).join("\n");
6846        assert!(none.contains("no Landlock on this host"), "{none}");
6847        assert_eq!(landlock_doctor_lines(Some(0)), landlock_doctor_lines(None));
6848
6849        let v1 = landlock_doctor_lines(Some(1)).join("\n");
6850        assert!(v1.contains("kernel ABI: 1"), "{v1}");
6851        assert!(v1.contains("ruleset ABI: 1"), "{v1}");
6852        assert!(v1.contains("EXDEV"), "{v1}");
6853        assert!(v1.contains("Invalid cross-device link"), "{v1}");
6854        assert!(v1.contains("Linux 5.19 (Landlock ABI v2)"), "{v1}");
6855        assert!(v1.contains("uv"), "{v1}");
6856
6857        let v2 = landlock_doctor_lines(Some(2)).join("\n");
6858        assert!(v2.contains("kernel ABI: 2"), "{v2}");
6859        assert!(v2.contains("ruleset ABI: 2"), "{v2}");
6860        assert!(!v2.contains("EXDEV"), "{v2}");
6861        assert!(
6862            v2.contains("not mediated below Landlock ABI v3 (Linux 6.2)"),
6863            "{v2}"
6864        );
6865
6866        for kernel in [3, 8] {
6867            let lines = landlock_doctor_lines(Some(kernel));
6868            assert_eq!(
6869                lines,
6870                vec![
6871                    format!("kernel ABI: {kernel}"),
6872                    "ruleset ABI: 3 (the kernel's, capped at 3)".to_string(),
6873                ]
6874            );
6875        }
6876    }
6877
6878    /// The unconfined stance is the unconfined tier on every host, and the tier prints none of
6879    /// the tier warnings: the launch prints `W-SEC-036` instead.
6880    #[test]
6881    fn applied_tier_is_unconfined_on_every_host_for_the_unconfined_stance() {
6882        use murmur_artifact::ContainmentClass;
6883        for host_tier in [
6884            EnforcementTier::KernelSealed,
6885            EnforcementTier::KernelFull,
6886            EnforcementTier::KernelSeccompOnly,
6887            EnforcementTier::EnvironmentOnly,
6888        ] {
6889            assert_eq!(
6890                applied_tier(host_tier, ContainmentClass::Unconfined),
6891                EnforcementTier::Unconfined,
6892                "{host_tier:?}"
6893            );
6894        }
6895        assert_eq!(tier_warning(EnforcementTier::Unconfined, false), None);
6896        assert_eq!(
6897            ruleset_landlock_abi(Some(4), EnforcementTier::Unconfined),
6898            None
6899        );
6900    }
6901
6902    /// A capsule that declared the weaker class keeps the weaker mechanism, even where the host
6903    /// could give it more. Silently installing a composed root under a `scoped` declaration would
6904    /// delete host paths its `interpreter_runtime` grants legitimately name.
6905    #[test]
6906    fn applied_tier_never_installs_a_composed_root_for_a_weaker_declaration() {
6907        use murmur_artifact::ContainmentClass;
6908
6909        assert_eq!(
6910            applied_tier(EnforcementTier::KernelSealed, ContainmentClass::Sealed),
6911            EnforcementTier::KernelSealed
6912        );
6913        for declared in [ContainmentClass::Advisory, ContainmentClass::Scoped] {
6914            assert_eq!(
6915                applied_tier(EnforcementTier::KernelSealed, declared),
6916                EnforcementTier::KernelFull,
6917                "declaring {declared} must not opt a capsule into the composed root"
6918            );
6919        }
6920
6921        // Every other tier is passed through untouched, whatever was declared — the declared floor
6922        // can only ever weaken the applied mechanism, never strengthen it past the host.
6923        for host_tier in [
6924            EnforcementTier::KernelFull,
6925            EnforcementTier::KernelSeccompOnly,
6926            EnforcementTier::EnvironmentOnly,
6927        ] {
6928            for declared in [
6929                ContainmentClass::Advisory,
6930                ContainmentClass::Scoped,
6931                ContainmentClass::Sealed,
6932            ] {
6933                assert_eq!(applied_tier(host_tier, declared), host_tier);
6934            }
6935        }
6936    }
6937
6938    #[test]
6939    fn sealed_bind_dirs_are_whole_directories_outside_the_fixed_runtime_paths() {
6940        use murmur_artifact::{InterpreterRuntimeDir, InterpreterRuntimeGrant};
6941
6942        let policy = CapabilityPolicy {
6943            shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
6944                binary: "python3".to_string(),
6945                dirs: vec![
6946                    // Already inside /usr, which the fixed list binds wholesale.
6947                    InterpreterRuntimeDir {
6948                        path: "/usr/lib/python3.11".to_string(),
6949                        list_dir: true,
6950                    },
6951                    InterpreterRuntimeDir {
6952                        path: "/opt/py/lib".to_string(),
6953                        list_dir: true,
6954                    },
6955                    // A duplicate, and a relative path that names nothing absolute.
6956                    InterpreterRuntimeDir {
6957                        path: "/opt/py/lib".to_string(),
6958                        list_dir: false,
6959                    },
6960                    InterpreterRuntimeDir {
6961                        path: "relative/lib".to_string(),
6962                        list_dir: false,
6963                    },
6964                ],
6965            }],
6966            ..CapabilityPolicy::default()
6967        };
6968        let exec_allow = vec![
6969            PathBuf::from("/usr/bin/python3"),
6970            PathBuf::from("/opt/toolchain/bin/cc"),
6971        ];
6972
6973        assert_eq!(
6974            resolve_sealed_bind_dirs(&exec_allow, &policy),
6975            vec![
6976                PathBuf::from("/opt/toolchain/bin"),
6977                PathBuf::from("/opt/py/lib")
6978            ]
6979        );
6980    }
6981
6982    /// The execute roots a host path is checked against include each `interpreter_runtime`
6983    /// directory and each `staged_runtime` tree: a read-write entry inside the first is refused on
6984    /// both Landlock mechanisms, and one containing the second is refused at `scoped` and bound
6985    /// read-only beneath on `sealed`. Pure: none of the paths need exist.
6986    #[test]
6987    fn host_path_execute_roots_include_interpreter_runtime_dirs_and_staged_trees() {
6988        use crate::host_paths::{check_execute_roots, HostPath, HostPathAccess, HostPathMechanism};
6989        let policy = CapabilityPolicy {
6990            shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
6991                binary: "python3".to_string(),
6992                dirs: vec![murmur_artifact::InterpreterRuntimeDir {
6993                    path: "/nonexistent-host-path-test/interp".to_string(),
6994                    list_dir: true,
6995                }],
6996            }],
6997            shell_staged_runtime: vec![murmur_artifact::StagedRuntimeGrant {
6998                binary: "python3".to_string(),
6999                source_path: "/nonexistent-host-path-test/opt/py".to_string(),
7000                pin: "pin-1".to_string(),
7001            }],
7002            ..CapabilityPolicy::default()
7003        };
7004        let roots = execute_roots_from(&[], &policy);
7005        let dirs: Vec<&Path> = roots.iter().map(|root| root.dir.as_path()).collect();
7006        assert_eq!(
7007            dirs,
7008            [
7009                Path::new("/nonexistent-host-path-test/interp"),
7010                Path::new("/nonexistent-host-path-test/opt/py"),
7011            ]
7012        );
7013        let read_write = |path: &str| HostPath {
7014            declared: path.to_string(),
7015            path: PathBuf::from(path),
7016            access: HostPathAccess::ReadWrite,
7017            credential_locations: Vec::new(),
7018        };
7019
7020        let inside_interpreter = [read_write("/nonexistent-host-path-test/interp/cache")];
7021        for mechanism in [HostPathMechanism::Landlock, HostPathMechanism::Bind] {
7022            assert!(check_execute_roots(&inside_interpreter, &roots, mechanism).is_err());
7023        }
7024
7025        let holding_staged = [read_write("/nonexistent-host-path-test/opt")];
7026        assert!(check_execute_roots(&holding_staged, &roots, HostPathMechanism::Landlock).is_err());
7027        assert_eq!(
7028            check_execute_roots(&holding_staged, &roots, HostPathMechanism::Bind).unwrap(),
7029            [PathBuf::from("/nonexistent-host-path-test/opt/py")]
7030        );
7031    }
7032
7033    /// Staged trees are resolved by a different rule than `sealed_bind_dirs` above, and the
7034    /// differences are the point: a path inside a fixed runtime path is kept (dropping it would
7035    /// silently make a required grant depend on an unrelated list), duplicates collapse, and a
7036    /// relative path — which cannot be re-based under the composed root at all — is skipped.
7037    #[test]
7038    fn staged_runtime_dirs_keep_fixed_path_overlaps_and_drop_only_duplicates_and_relatives() {
7039        use murmur_artifact::StagedRuntimeGrant;
7040
7041        let grant = |binary: &str, source: &str| StagedRuntimeGrant {
7042            binary: binary.to_string(),
7043            source_path: source.to_string(),
7044            pin: "pin-1".to_string(),
7045        };
7046        let policy = CapabilityPolicy {
7047            shell_staged_runtime: vec![
7048                grant("python3", "/opt/py"),
7049                // Inside /usr, which the fixed list already binds. Kept anyway — unlike
7050                // `resolve_sealed_bind_dirs`, which drops exactly this case.
7051                grant("perl", "/usr/lib/perl5"),
7052                // A duplicate of the first, and a relative path naming nothing absolute.
7053                grant("python3.12", "/opt/py"),
7054                grant("ruby", "relative/ruby"),
7055            ],
7056            ..CapabilityPolicy::default()
7057        };
7058
7059        assert_eq!(
7060            resolve_staged_runtime_dirs(&policy),
7061            vec![PathBuf::from("/opt/py"), PathBuf::from("/usr/lib/perl5")]
7062        );
7063    }
7064
7065    /// The bind is only half of it: `sealed` keeps Landlock installed inside the composed root, so
7066    /// a staged tree with no Landlock rule is mounted-but-unreadable (`EACCES`). Both resolvers
7067    /// must therefore cover the same directories.
7068    #[test]
7069    fn every_staged_runtime_dir_also_gets_a_listable_landlock_grant() {
7070        use murmur_artifact::StagedRuntimeGrant;
7071
7072        let policy = CapabilityPolicy {
7073            shell_staged_runtime: vec![StagedRuntimeGrant {
7074                binary: "python3".to_string(),
7075                source_path: "/opt/py".to_string(),
7076                pin: "pin-1".to_string(),
7077            }],
7078            ..CapabilityPolicy::default()
7079        };
7080
7081        let dirs = resolve_staged_runtime_dirs(&policy);
7082        let grants = resolve_staged_runtime_landlock_grants(&policy);
7083
7084        assert_eq!(
7085            grants,
7086            vec![LandlockGrant {
7087                path: PathBuf::from("/opt/py"),
7088                list_dir: true,
7089                executable: true,
7090                pinned: None,
7091            }],
7092            "a staged tree is walked by the runtime that uses it, so it must be listable",
7093        );
7094        assert_eq!(
7095            grants
7096                .iter()
7097                .map(|grant| grant.path.clone())
7098                .collect::<Vec<_>>(),
7099            dirs,
7100            "the bound set and the granted set must not drift apart",
7101        );
7102    }
7103
7104    #[test]
7105    fn every_sealed_runtime_path_gets_a_listable_non_executable_landlock_grant() {
7106        let grants = resolve_sealed_runtime_landlock_grants(EnforcementTier::KernelSealed);
7107
7108        assert_eq!(
7109            grants,
7110            crate::sealed::SEALED_RUNTIME_PATHS
7111                .iter()
7112                .map(|path| LandlockGrant {
7113                    path: PathBuf::from(path),
7114                    list_dir: true,
7115                    executable: false,
7116                    pinned: None,
7117                })
7118                .collect::<Vec<_>>(),
7119            "the composed root binds exactly these, so exactly these must be enumerable — \
7120             the bound set and the granted set must not drift apart",
7121        );
7122        assert!(
7123            grants.iter().all(|grant| grant.list_dir),
7124            "a runtime tree an interpreter walks is useless without getdents64",
7125        );
7126        assert!(
7127            grants.iter().all(|grant| !grant.executable),
7128            "Execute is the exec allowlist: granting it over /usr, /bin and /sbin would let a \
7129             sealed capsule run every binary the host ships, whatever shell.allow says",
7130        );
7131    }
7132
7133    #[test]
7134    fn sealed_runtime_landlock_grants_are_sealed_tier_only() {
7135        for tier in [
7136            EnforcementTier::KernelFull,
7137            EnforcementTier::KernelSeccompOnly,
7138            EnforcementTier::EnvironmentOnly,
7139        ] {
7140            assert!(
7141                resolve_sealed_runtime_landlock_grants(tier).is_empty(),
7142                "{tier:?} has no composed root, so granting ReadDir on SEALED_RUNTIME_PATHS \
7143                 would enumerate the real host filesystem",
7144            );
7145        }
7146    }
7147
7148    /// The gate as `ShellEnforcement::resolve` actually applies it. `applied_tier` never returns
7149    /// `KernelSealed` for a capsule declaring `scoped`, on any host, so this half is host-
7150    /// independent; the `sealed` half asserts against whatever tier this host resolved to, which
7151    /// is the only honest thing a test can do about a kernel capability it may not have.
7152    #[test]
7153    fn shell_enforcement_grants_sealed_runtime_paths_only_when_sealed_applies() {
7154        let policy = CapabilityPolicy::default();
7155        let usr = PathBuf::from("/usr");
7156
7157        let scoped = ShellEnforcement::resolve_declared(
7158            &policy,
7159            murmur_artifact::ContainmentClass::Scoped,
7160            HostProbe::probe(),
7161        )
7162        .expect("an empty policy resolves")
7163        .with_test_session_dirs();
7164        assert!(
7165            !scoped.landlock_grants.iter().any(|grant| grant.path == usr),
7166            "a scoped capsule runs Landlock over the real host filesystem — /usr must stay \
7167             unenumerable there",
7168        );
7169
7170        let sealed = ShellEnforcement::resolve_declared(
7171            &policy,
7172            murmur_artifact::ContainmentClass::Sealed,
7173            HostProbe::probe(),
7174        )
7175        .expect("an empty policy resolves")
7176        .with_test_session_dirs();
7177        let granted: Vec<&LandlockGrant> = sealed
7178            .landlock_grants
7179            .iter()
7180            .filter(|grant| {
7181                crate::sealed::SEALED_RUNTIME_PATHS.contains(&grant.path.to_str().unwrap_or(""))
7182            })
7183            .collect();
7184        if sealed.tier == EnforcementTier::KernelSealed {
7185            assert_eq!(
7186                granted.len(),
7187                crate::sealed::SEALED_RUNTIME_PATHS.len(),
7188                "a sealed session must carry one listable grant per bound runtime path",
7189            );
7190            assert!(granted
7191                .iter()
7192                .all(|grant| grant.list_dir && !grant.executable));
7193        } else {
7194            assert!(
7195                granted.is_empty(),
7196                "this host fell back to {:?}, where there is no composed root to enumerate",
7197                sealed.tier,
7198            );
7199        }
7200    }
7201
7202    #[test]
7203    fn every_sealed_etc_path_gets_a_non_executable_landlock_grant() {
7204        let grants = resolve_sealed_etc_landlock_grants(EnforcementTier::KernelSealed);
7205
7206        assert_eq!(
7207            grants,
7208            crate::sealed::SEALED_ETC_PATHS
7209                .iter()
7210                .map(|entry| LandlockGrant {
7211                    path: PathBuf::from(entry.path),
7212                    list_dir: entry.directory,
7213                    executable: false,
7214                    pinned: None,
7215                })
7216                .collect::<Vec<_>>(),
7217            "the composed root binds exactly these /etc entries, so exactly these must be \
7218             readable — the bound set and the granted set must not drift apart",
7219        );
7220        assert!(
7221            grants.iter().all(|grant| !grant.executable),
7222            "Execute is the exec allowlist, and nothing in /etc is a program a manifest asked to \
7223             run — /etc/alternatives is a directory of symlinks into /usr/bin",
7224        );
7225
7226        // The `directory` classification itself, spelled out independently of the constant so a
7227        // silent reclassification (which would cost an entry its ReadDir, or claim ReadDir on a
7228        // regular file) shows up as a test failure rather than as a TLS bug on a real host.
7229        let listable: Vec<&str> = grants
7230            .iter()
7231            .filter(|grant| grant.list_dir)
7232            .map(|grant| grant.path.to_str().unwrap())
7233            .collect();
7234        assert_eq!(
7235            listable,
7236            vec![
7237                "/etc/ld.so.conf.d",
7238                "/etc/alternatives",
7239                "/etc/ssl",
7240                "/etc/pki",
7241                "/etc/ca-certificates",
7242                "/etc/terminfo",
7243            ],
7244            "ReadDir belongs to exactly the directory entries: os.listdir('/etc/ssl/certs') and \
7245             the c_rehash symlink lookup need it, and a regular file has no meaningful ReadDir",
7246        );
7247    }
7248
7249    #[test]
7250    fn sealed_etc_landlock_grants_are_sealed_tier_only() {
7251        for tier in [
7252            EnforcementTier::KernelFull,
7253            EnforcementTier::KernelSeccompOnly,
7254            EnforcementTier::EnvironmentOnly,
7255        ] {
7256            assert!(
7257                resolve_sealed_etc_landlock_grants(tier).is_empty(),
7258                "{tier:?} has no composed root, so these rules would hand out the real host's \
7259                 /etc — its trust store, resolv.conf and account databases",
7260            );
7261        }
7262    }
7263
7264    /// The gate as `ShellEnforcement::resolve` actually applies it, modelled on
7265    /// `shell_enforcement_grants_sealed_runtime_paths_only_when_sealed_applies`: the `scoped` half
7266    /// is host-independent (`applied_tier` never returns `KernelSealed` for a `scoped`
7267    /// declaration), and the `sealed` half asserts against whatever tier this host resolved to.
7268    #[test]
7269    fn shell_enforcement_grants_sealed_etc_paths_only_when_sealed_applies() {
7270        let policy = CapabilityPolicy::default();
7271        let is_etc_grant = |grant: &LandlockGrant| {
7272            crate::sealed::SEALED_ETC_PATHS
7273                .iter()
7274                .any(|entry| Path::new(entry.path) == grant.path)
7275        };
7276
7277        let scoped = ShellEnforcement::resolve_declared(
7278            &policy,
7279            murmur_artifact::ContainmentClass::Scoped,
7280            HostProbe::probe(),
7281        )
7282        .expect("an empty policy resolves")
7283        .with_test_session_dirs();
7284        assert!(
7285            !scoped.landlock_grants.iter().any(is_etc_grant),
7286            "a scoped capsule runs Landlock over the real host filesystem — /etc/shadow's \
7287             neighbours must not become readable there",
7288        );
7289
7290        let sealed = ShellEnforcement::resolve_declared(
7291            &policy,
7292            murmur_artifact::ContainmentClass::Sealed,
7293            HostProbe::probe(),
7294        )
7295        .expect("an empty policy resolves")
7296        .with_test_session_dirs();
7297        let granted: Vec<&LandlockGrant> = sealed
7298            .landlock_grants
7299            .iter()
7300            .filter(|grant| is_etc_grant(grant))
7301            .collect();
7302        if sealed.tier == EnforcementTier::KernelSealed {
7303            assert_eq!(
7304                granted.len(),
7305                crate::sealed::SEALED_ETC_PATHS.len(),
7306                "a sealed session must carry one read grant per bound /etc entry",
7307            );
7308            assert!(granted.iter().all(|grant| !grant.executable));
7309        } else {
7310            assert!(
7311                granted.is_empty(),
7312                "this host fell back to {:?}, where there is no composed root and /etc is the \
7313                 host's own",
7314                sealed.tier,
7315            );
7316        }
7317    }
7318
7319    #[test]
7320    fn resolve_network_allowlist_ips_empty_list_is_empty() {
7321        let ips = resolve_network_allowlist_ips(&[]).unwrap();
7322        assert!(ips.is_empty());
7323    }
7324
7325    #[test]
7326    fn resolve_network_allowlist_ips_resolves_localhost_to_loopback() {
7327        let ips = resolve_network_allowlist_ips(&["localhost".to_string()]).unwrap();
7328        assert!(
7329            !ips.is_empty(),
7330            "localhost should resolve to at least one address"
7331        );
7332        assert!(
7333            ips.iter().all(|ip| ip.is_loopback()),
7334            "every resolved localhost address should be loopback, got {ips:?}"
7335        );
7336    }
7337
7338    #[test]
7339    fn resolve_network_allowlist_ips_rejects_invalid_host_syntax_cleanly() {
7340        let error = resolve_network_allowlist_ips(&["".to_string()]).unwrap_err();
7341        assert!(!error.is_empty());
7342    }
7343
7344    /// A host that does not resolve contributes zero IPs instead of failing the whole call. The
7345    /// resolved set only ever *shrinks* the kernel-level `shell.allow` enforcement set, so a DNS
7346    /// miss is fail-closed, not fatal. `.invalid` is reserved by RFC 2606 and is guaranteed never
7347    /// to resolve, which keeps this test independent of the host's DNS.
7348    #[test]
7349    fn resolve_network_allowlist_ips_skips_unresolvable_host_without_failing() {
7350        let ips =
7351            resolve_network_allowlist_ips(&["definitely-not-a-real-host.invalid".to_string()])
7352                .expect("an unresolvable host must not be a hard error");
7353        assert!(
7354            ips.is_empty(),
7355            "an unresolvable host should contribute no IPs, got {ips:?}"
7356        );
7357    }
7358
7359    /// The unresolvable host is skipped while the resolvable one still contributes its IPs — the
7360    /// loop continues rather than aborting on the first miss.
7361    #[test]
7362    fn resolve_network_allowlist_ips_keeps_resolvable_hosts_when_another_is_unresolvable() {
7363        let ips = resolve_network_allowlist_ips(&[
7364            "definitely-not-a-real-host.invalid".to_string(),
7365            "localhost".to_string(),
7366        ])
7367        .expect("a mix of resolvable and unresolvable hosts must not be a hard error");
7368
7369        assert!(
7370            !ips.is_empty(),
7371            "localhost's addresses should survive alongside an unresolvable host"
7372        );
7373        assert!(
7374            ips.iter().all(|ip| ip.is_loopback()),
7375            "only localhost should have contributed addresses, got {ips:?}"
7376        );
7377    }
7378
7379    #[test]
7380    fn resolve_network_allowlist_ips_dedupes_across_hosts() {
7381        // Two entries that both resolve to loopback should not produce duplicate IPs beyond
7382        // what's actually distinct.
7383        let ips =
7384            resolve_network_allowlist_ips(&["localhost".to_string(), "localhost:8080".to_string()])
7385                .unwrap();
7386        let mut sorted = ips.clone();
7387        sorted.sort();
7388        sorted.dedup();
7389        assert_eq!(
7390            ips.len(),
7391            sorted.len(),
7392            "resolved IPs should already be deduplicated"
7393        );
7394    }
7395
7396    #[test]
7397    fn environment_only_has_no_resolved_ips_and_environment_only_tier() {
7398        let enforcement = ShellEnforcement::environment_only();
7399        assert_eq!(enforcement.tier, EnforcementTier::EnvironmentOnly);
7400        assert!(enforcement.network_allow_ips.is_empty());
7401    }
7402
7403    /// With no scope a spawn's `RLIMIT_NPROC` is headroom over a real count. A `0` baseline
7404    /// would turn `max_processes` into an absolute per-uid ceiling that an interactive account
7405    /// is already past, failing the subprocess's first `fork()`.
7406    #[cfg(any(target_os = "linux", target_os = "macos"))]
7407    #[test]
7408    fn nproc_bound_without_a_scope_is_headroom_over_a_live_count() {
7409        match ShellEnforcement::environment_only()
7410            .with_test_session_dirs()
7411            .nproc_bound()
7412        {
7413            crate::resources::NprocBound::Headroom { baseline } => assert!(
7414                baseline > 0,
7415                "the test process is owned by this uid, so the count cannot be zero"
7416            ),
7417            other => panic!("no scope must give headroom, got {other:?}"),
7418        }
7419    }
7420
7421    /// The baseline is taken on each call, not when the enforcement was built: threads the host
7422    /// starts in between are counted by the next spawn.
7423    #[cfg(target_os = "linux")]
7424    #[test]
7425    fn nproc_bound_without_a_scope_is_measured_on_each_call() {
7426        let _lock = crate::resources::test_support::host_threads_lock();
7427        let enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
7428        let baseline = |bound| match bound {
7429            crate::resources::NprocBound::Headroom { baseline } => baseline,
7430            other => panic!("no scope must give headroom, got {other:?}"),
7431        };
7432
7433        let first = baseline(enforcement.nproc_bound());
7434        let holders = crate::resources::test_support::HeldThreads::start(200);
7435        let second = baseline(enforcement.nproc_bound());
7436        drop(holders);
7437
7438        assert!(
7439            second > first,
7440            "200 threads started between the calls must raise the baseline: {first} then {second}"
7441        );
7442    }
7443
7444    /// A spawn that will join a live scope leaves `RLIMIT_NPROC` to `pids.max`.
7445    #[cfg(target_os = "linux")]
7446    #[test]
7447    fn nproc_bound_with_a_scope_leaves_rlimit_nproc_to_pids_max() {
7448        if !crate::cgroup::cgroup_delegation_available() {
7449            crate::runtime_err!(
7450                "[SKIP-HOST] nproc_bound_with_a_scope_leaves_rlimit_nproc_to_pids_max: this host \
7451                 cannot delegate a cgroup v2 scope"
7452            );
7453            return;
7454        }
7455        let temp = tempfile::tempdir().unwrap();
7456        let limits = crate::resources::HostResourceLimits::default();
7457        let scope = crate::cgroup::prepare_scope(true, &limits, "ses_nproc_bound", temp.path())
7458            .expect("this host delegates a cgroup scope")
7459            .scope;
7460        let enforcement = ShellEnforcement::environment_only()
7461            .with_test_session_dirs()
7462            .with_host_bounding(scope, None);
7463
7464        assert_eq!(
7465            enforcement.nproc_bound(),
7466            crate::resources::NprocBound::CgroupPidsMax
7467        );
7468    }
7469
7470    /// A Linux capsule with a cgroup scope gets no `RLIMIT_NPROC` from the runtime, so threads the
7471    /// rest of the host starts after the enforcement was resolved cannot spend its fork headroom.
7472    /// The 400 holders put the uid well past any `RLIMIT_NPROC` derived from a count taken before
7473    /// they started, which fails the first `fork()` in the subprocess with `EAGAIN`. The 40
7474    /// concurrent `sleep`s and the concurrent `node` children each need headroom well past the
7475    /// declared `max_processes` of 32.
7476    #[cfg(target_os = "linux")]
7477    #[test]
7478    fn a_scoped_capsule_keeps_spawning_after_the_host_adds_threads() {
7479        const TEST: &str = "a_scoped_capsule_keeps_spawning_after_the_host_adds_threads";
7480        if !crate::cgroup::cgroup_delegation_available() {
7481            crate::runtime_err!("[SKIP-HOST] {TEST}: this host cannot delegate a cgroup v2 scope");
7482            return;
7483        }
7484        let _lock = crate::resources::test_support::host_threads_lock();
7485
7486        let temp = tempfile::tempdir().unwrap();
7487        let limits = crate::resources::HostResourceLimits {
7488            max_processes: 32,
7489            max_open_files: 256,
7490            ..crate::resources::HostResourceLimits::default()
7491        };
7492        let scope = crate::cgroup::prepare_scope(true, &limits, "ses_nproc_race", temp.path())
7493            .expect("this host delegates a cgroup scope")
7494            .scope;
7495        assert!(
7496            scope.is_some(),
7497            "a required scope on Linux is always created"
7498        );
7499        let node_available = crate::sandbox::resolve_invoked_binary_path("node") != "node";
7500        let mut shell_allow = vec!["bash".to_string()];
7501        if node_available {
7502            shell_allow.push("node".to_string());
7503        }
7504        let policy = CapabilityPolicy {
7505            shell_allow,
7506            resources: limits,
7507            ..CapabilityPolicy::default()
7508        };
7509        let mut enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
7510        enforcement.resource_limits = limits;
7511        let enforcement = enforcement.with_host_bounding(scope, None);
7512
7513        let holders = crate::resources::test_support::HeldThreads::start(400);
7514
7515        let result = crate::shell::execute_shell(
7516            "bash",
7517            &[
7518                "-c",
7519                "for i in $(seq 1 40); do sleep 0.2 & done; wait; ulimit -Hu; ulimit -Hn",
7520            ],
7521            &[],
7522            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
7523            &policy,
7524            &enforcement,
7525        )
7526        .expect("bash must run");
7527        assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
7528        assert_eq!(result.resource_limit_hit, None);
7529        let (_, own_hard) = crate::resources::test_support::own_rlimit_nproc();
7530        let lines: Vec<&str> = result.stdout.lines().collect();
7531        assert_eq!(
7532            lines,
7533            [
7534                crate::resources::test_support::rendered_rlimit(own_hard).as_str(),
7535                "256"
7536            ],
7537            "RLIMIT_NPROC is inherited unchanged and RLIMIT_NOFILE is the declared ceiling",
7538        );
7539
7540        if node_available {
7541            let script = "const { spawn } = require('child_process');\n\
7542                const runs = Array.from({ length: 8 }, () => new Promise((resolve) => {\n\
7543                  const child = spawn(process.execPath, ['-e', 'process.exit(0)']);\n\
7544                  child.once('error', (error) => resolve(error.message));\n\
7545                  child.once('exit', (code) => resolve(code === 0 ? null : `exit ${code}`));\n\
7546                }));\n\
7547                Promise.all(runs).then((outcomes) => {\n\
7548                  const failures = outcomes.filter((outcome) => outcome !== null);\n\
7549                  if (failures.length > 0) { console.error(failures.join('\\n')); process.exit(1); }\n\
7550                });\n";
7551            let result = crate::shell::execute_shell(
7552                "node",
7553                &["-e", script],
7554                &[],
7555                &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
7556                &policy,
7557                &enforcement,
7558            )
7559            .expect("node must run");
7560            assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
7561        } else {
7562            crate::runtime_err!("[SKIP-HOST] {TEST}: no node on PATH, skipping the node half");
7563        }
7564
7565        drop(holders);
7566    }
7567
7568    /// Without a scope `RLIMIT_NPROC` is the only process bound, so its baseline has to follow the
7569    /// host: 300 threads started after the enforcement was resolved raise the next spawn's
7570    /// ceiling, and releasing them lowers the one after.
7571    #[cfg(target_os = "linux")]
7572    #[test]
7573    fn without_a_scope_the_nproc_baseline_is_measured_per_spawn() {
7574        const TEST: &str = "without_a_scope_the_nproc_baseline_is_measured_per_spawn";
7575        let _lock = crate::resources::test_support::host_threads_lock();
7576
7577        let temp = tempfile::tempdir().unwrap();
7578        let policy = CapabilityPolicy {
7579            shell_allow: vec!["bash".to_string()],
7580            resources: crate::resources::HostResourceLimits {
7581                max_processes: 64,
7582                ..crate::resources::HostResourceLimits::default()
7583            },
7584            ..CapabilityPolicy::default()
7585        };
7586        let mut enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
7587        enforcement.resource_limits = policy.resources;
7588
7589        let before = crate::resources::uid_task_count().expect("Linux can count the uid's tasks");
7590        let (_, own_hard) = crate::resources::test_support::own_rlimit_nproc();
7591        if own_hard != libc::RLIM_INFINITY && own_hard < before + 664 {
7592            crate::runtime_err!(
7593                "[SKIP-HOST] {TEST}: the inherited hard RLIMIT_NPROC is {own_hard}, too close to \
7594                 the uid's {before} tasks for 300 more threads to show in a child's ceiling"
7595            );
7596            return;
7597        }
7598
7599        let hard_nproc = || -> u64 {
7600            let result = crate::shell::execute_shell(
7601                "bash",
7602                &["-c", "ulimit -Hu"],
7603                &[],
7604                &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
7605                &policy,
7606                &enforcement,
7607            )
7608            .expect("bash must run");
7609            assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
7610            result.stdout.trim().parse().expect("a finite ceiling")
7611        };
7612
7613        let holders = crate::resources::test_support::HeldThreads::start(300);
7614        let held = hard_nproc();
7615        assert!(
7616            held >= before + 64 + 150,
7617            "the ceiling must count the holder threads started after the enforcement was \
7618             resolved: {held} with {before} tasks before them"
7619        );
7620
7621        drop(holders);
7622        let released = hard_nproc();
7623        assert!(
7624            released < held,
7625            "the ceiling must drop once the holders exit: {released} after, {held} while held"
7626        );
7627    }
7628
7629    fn make_executable(path: &Path, contents: &str) {
7630        use std::os::unix::fs::PermissionsExt;
7631        std::fs::write(path, contents).unwrap();
7632        let mut perms = std::fs::metadata(path).unwrap().permissions();
7633        perms.set_mode(0o755);
7634        std::fs::set_permissions(path, perms).unwrap();
7635    }
7636
7637    fn bootstrap_log_contents(workdir: &Path) -> String {
7638        std::fs::read_to_string(workdir.join("logs").join("bootstrap.log")).unwrap_or_default()
7639    }
7640
7641    // ---- exec allowlist resolution (identity, not names) ----
7642
7643    #[test]
7644    fn resolve_exec_allowlist_resolves_bare_name_via_path_dirs() {
7645        let temp = tempfile::tempdir().unwrap();
7646        let bin_dir = temp.path().join("bin");
7647        std::fs::create_dir(&bin_dir).unwrap();
7648        let tool = bin_dir.join("mytool");
7649        make_executable(&tool, "#!/bin/sh\nexit 0\n");
7650
7651        let resolved = resolve_exec_allowlist_in(&["mytool".to_string()], &[bin_dir]);
7652        assert_eq!(resolved, vec![std::fs::canonicalize(&tool).unwrap()]);
7653    }
7654
7655    #[test]
7656    fn resolve_exec_allowlist_skips_entries_that_do_not_resolve() {
7657        let temp = tempfile::tempdir().unwrap();
7658        let resolved = resolve_exec_allowlist_in(
7659            &["definitely-not-a-real-binary".to_string(), String::new()],
7660            &[temp.path().to_path_buf()],
7661        );
7662        assert!(
7663            resolved.is_empty(),
7664            "unresolvable entries must shrink (not fail) the allow set: {resolved:?}"
7665        );
7666    }
7667
7668    #[test]
7669    fn resolve_exec_allowlist_ignores_non_executable_path_matches() {
7670        let temp = tempfile::tempdir().unwrap();
7671        std::fs::write(temp.path().join("mytool"), "not executable").unwrap();
7672        let resolved =
7673            resolve_exec_allowlist_in(&["mytool".to_string()], &[temp.path().to_path_buf()]);
7674        assert!(resolved.is_empty());
7675    }
7676
7677    #[test]
7678    fn resolve_exec_allowlist_canonicalizes_path_matches_through_symlinks() {
7679        let temp = tempfile::tempdir().unwrap();
7680        let real = temp.path().join("real-tool");
7681        make_executable(&real, "#!/bin/sh\nexit 0\n");
7682        let bin_dir = temp.path().join("bin");
7683        std::fs::create_dir(&bin_dir).unwrap();
7684        std::os::unix::fs::symlink(&real, bin_dir.join("link-tool")).unwrap();
7685
7686        let resolved = resolve_exec_allowlist_in(&["link-tool".to_string()], &[bin_dir]);
7687        assert_eq!(resolved, vec![std::fs::canonicalize(&real).unwrap()]);
7688    }
7689
7690    // ---- invoked-binary reporting (shell-event.binary / trace.jsonl `binary`) ----
7691
7692    #[test]
7693    fn resolve_invoked_binary_path_resolves_bare_name_via_path_dirs() {
7694        let temp = tempfile::tempdir().unwrap();
7695        let bin_dir = temp.path().join("bin");
7696        std::fs::create_dir(&bin_dir).unwrap();
7697        let tool = bin_dir.join("mytool");
7698        make_executable(&tool, "#!/bin/sh\nexit 0\n");
7699
7700        let resolved = resolve_invoked_binary_path_in("mytool", &[bin_dir]);
7701        assert_eq!(
7702            resolved,
7703            std::fs::canonicalize(&tool)
7704                .unwrap()
7705                .to_string_lossy()
7706                .into_owned(),
7707            "a resolvable name must report the canonical absolute path, not the bare name"
7708        );
7709    }
7710
7711    #[test]
7712    fn resolve_invoked_binary_path_falls_back_to_bare_name_when_unresolvable() {
7713        let temp = tempfile::tempdir().unwrap();
7714        let path_dirs = [temp.path().to_path_buf()];
7715        let resolved = resolve_invoked_binary_path_in("definitely-not-a-real-binary", &path_dirs);
7716        assert_eq!(
7717            resolved, "definitely-not-a-real-binary",
7718            "an unresolvable name must fall back to the bare name, never error"
7719        );
7720        assert_eq!(resolve_invoked_binary_path_in("", &[]), "");
7721    }
7722
7723    #[test]
7724    fn resolve_invoked_binary_path_takes_the_first_path_match() {
7725        let temp = tempfile::tempdir().unwrap();
7726        let first = temp.path().join("first");
7727        let second = temp.path().join("second");
7728        std::fs::create_dir(&first).unwrap();
7729        std::fs::create_dir(&second).unwrap();
7730        make_executable(&first.join("mytool"), "#!/bin/sh\nexit 0\n");
7731        make_executable(&second.join("mytool"), "#!/bin/sh\nexit 1\n");
7732
7733        let resolved = resolve_invoked_binary_path_in("mytool", &[first.clone(), second]);
7734        assert_eq!(
7735            resolved,
7736            std::fs::canonicalize(first.join("mytool"))
7737                .unwrap()
7738                .to_string_lossy()
7739                .into_owned(),
7740            "reporting must match execvp: the first PATH hit is the one that ran"
7741        );
7742    }
7743
7744    #[test]
7745    fn resolve_invoked_binary_path_ignores_non_executable_path_matches() {
7746        let temp = tempfile::tempdir().unwrap();
7747        std::fs::write(temp.path().join("mytool"), "not executable").unwrap();
7748        assert_eq!(
7749            resolve_invoked_binary_path_in("mytool", &[temp.path().to_path_buf()]),
7750            "mytool"
7751        );
7752    }
7753
7754    #[test]
7755    fn resolve_invoked_binary_path_canonicalizes_a_path_entry_through_symlinks() {
7756        let temp = tempfile::tempdir().unwrap();
7757        let real = temp.path().join("real-tool");
7758        make_executable(&real, "#!/bin/sh\nexit 0\n");
7759        let link = temp.path().join("link-tool");
7760        std::os::unix::fs::symlink(&real, &link).unwrap();
7761
7762        assert_eq!(
7763            resolve_invoked_binary_path_in(&link.to_string_lossy(), &[]),
7764            std::fs::canonicalize(&real)
7765                .unwrap()
7766                .to_string_lossy()
7767                .into_owned()
7768        );
7769    }
7770
7771    // ---- ELF dependency parsing (pure, synthetic fixtures — runs on every OS) ----
7772
7773    #[test]
7774    fn parse_elf_extracts_interp_needed_and_runpath() {
7775        let bytes = build_elf64(
7776            Some("/lib64/ld-linux-x86-64.so.2"),
7777            &["libc.so.6", "libm.so.6"],
7778            Some("$ORIGIN/../lib:/opt/custom/lib"),
7779        );
7780        let deps = parse_elf_dependencies(bytes.as_slice()).expect("well-formed ELF64 must parse");
7781        assert_eq!(deps.interp.as_deref(), Some("/lib64/ld-linux-x86-64.so.2"));
7782        assert_eq!(
7783            deps.needed,
7784            vec!["libc.so.6".to_string(), "libm.so.6".to_string()]
7785        );
7786        assert_eq!(
7787            deps.runpaths,
7788            vec!["$ORIGIN/../lib".to_string(), "/opt/custom/lib".to_string()],
7789            "DT_RUNPATH must be split on ':' with empty parts dropped"
7790        );
7791    }
7792
7793    #[test]
7794    fn parse_elf_static_binary_contributes_only_itself() {
7795        // No PT_INTERP, no PT_DYNAMIC — a statically linked binary.
7796        let bytes = build_elf64(None, &[], None);
7797        let deps =
7798            parse_elf_dependencies(bytes.as_slice()).expect("a static ELF64 is still valid ELF");
7799        assert_eq!(deps, ElfDependencies::default());
7800    }
7801
7802    #[test]
7803    fn parse_elf_rejects_non_elf_and_non_elf64() {
7804        assert!(parse_elf_dependencies(&b"not an elf at all, way too short"[..]).is_none());
7805        // Correct magic but ELFCLASS32 (byte 4 == 1) must be rejected.
7806        let mut elf32 = build_elf64(Some("/lib/ld.so"), &["libc.so.6"], None);
7807        elf32[4] = 1;
7808        assert!(
7809            parse_elf_dependencies(elf32.as_slice()).is_none(),
7810            "32-bit ELF is not a binary this runtime execs and must not parse"
7811        );
7812    }
7813
7814    /// Every ELF image of this host's `python3`, `perl`, `bash` and `git` closures and every file
7815    /// in python's `lib-dynload`, and truncated and malformed copies of each binary, parse the same
7816    /// read from memory and read by position from the file, `None` included.
7817    #[cfg(target_os = "linux")]
7818    #[test]
7819    #[allow(clippy::print_stdout)]
7820    fn a_file_read_by_position_parses_exactly_as_its_bytes_in_memory() {
7821        let mut binaries = Vec::new();
7822        for name in ["python3", "perl", "bash", "git"] {
7823            match find_on_path(name) {
7824                Some(path) => binaries.push(std::fs::canonicalize(path).unwrap()),
7825                None => println!("[SKIP] no {name} on PATH"),
7826            }
7827        }
7828        let mut images: std::collections::BTreeSet<PathBuf> =
7829            resolve_landlock_grants(&binaries).into_iter().collect();
7830        let dynload = std::process::Command::new("python3")
7831            .args(["-I", "-S", "-c", "import sys; print('\\n'.join(sys.path))"])
7832            .output()
7833            .ok()
7834            .map(|output| String::from_utf8_lossy(&output.stdout).into_owned())
7835            .unwrap_or_default();
7836        for dir in dynload.lines().filter(|line| line.ends_with("lib-dynload")) {
7837            for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
7838                images.insert(entry.path());
7839            }
7840        }
7841        let compare = |path: &Path| {
7842            let Ok(bytes) = std::fs::read(path) else {
7843                return;
7844            };
7845            let file = ElfFile::open_host(path).unwrap();
7846            assert_eq!(
7847                parse_elf_dependencies(bytes.as_slice()),
7848                parse_elf_dependencies(&file),
7849                "{}",
7850                path.display()
7851            );
7852            assert_eq!(
7853                elf_dynamic_symbol_names(bytes.as_slice()),
7854                elf_dynamic_symbol_names(&file),
7855                "{}",
7856                path.display()
7857            );
7858            assert_eq!(
7859                elf_is_shared_object(bytes.as_slice()),
7860                elf_is_shared_object(&file),
7861                "{}",
7862                path.display()
7863            );
7864        };
7865        for path in &images {
7866            compare(path);
7867        }
7868        println!("compared {} host images", images.len());
7869
7870        // Truncated and malformed copies: cut inside each table the parsers read, and with the
7871        // header's table offsets pointed past the end of the file.
7872        let temp = tempfile::tempdir().unwrap();
7873        let mut variants = 0;
7874        for binary in &binaries {
7875            let bytes = std::fs::read(binary).unwrap();
7876            let mut cuts = vec![
7877                0,
7878                1,
7879                17,
7880                18,
7881                63,
7882                64,
7883                100,
7884                4096,
7885                bytes.len() / 2,
7886                bytes.len() - 1,
7887            ];
7888            for field in [32usize, 40] {
7889                if let Some(offset) = read_u64(&bytes, field, true) {
7890                    cuts.push(offset as usize + 30);
7891                }
7892            }
7893            let mut copies: Vec<Vec<u8>> = cuts
7894                .into_iter()
7895                .filter(|cut| *cut <= bytes.len())
7896                .map(|cut| bytes[..cut].to_vec())
7897                .collect();
7898            for field in [32usize, 40] {
7899                let mut moved = bytes.clone();
7900                moved[field..field + 8].copy_from_slice(&(bytes.len() as u64 - 8).to_le_bytes());
7901                copies.push(moved);
7902            }
7903            let mut big_endian = bytes.clone();
7904            big_endian[5] = 2;
7905            copies.push(big_endian);
7906            for copy in copies {
7907                let path = temp.path().join(format!("variant-{variants}"));
7908                std::fs::write(&path, &copy).unwrap();
7909                compare(&path);
7910                variants += 1;
7911            }
7912        }
7913        println!("compared {variants} malformed images");
7914    }
7915
7916    /// Resolving a binary's closure reads its headers and tables, not the whole file: a stand-in
7917    /// padded to 64 MiB costs a few KiB.
7918    #[cfg(target_os = "linux")]
7919    #[test]
7920    #[allow(clippy::print_stdout)]
7921    fn resolving_a_closure_reads_no_binary_in_full() {
7922        let (_temp, root) = crate::interpreter_runtime::tests::canonical_tempdir();
7923        let Some(stand_in) = crate::interpreter_runtime::tests::dash_copy(&root, "padded") else {
7924            return;
7925        };
7926        crate::interpreter_runtime::tests::pad_with_zeros(&stand_in, 64 << 20);
7927        let unpadded = resolve_landlock_grants(&[std::fs::canonicalize("/bin/dash").unwrap()]);
7928        let before = crate::interpreter_runtime::tests::thread_rchar();
7929        let grants = resolve_landlock_grants(std::slice::from_ref(&stand_in));
7930        let read = crate::interpreter_runtime::tests::thread_rchar() - before;
7931        assert_eq!(grants.len(), unpadded.len(), "{grants:?}");
7932        assert!(grants.contains(&stand_in));
7933        assert!(
7934            read < 8 * 1024 * 1024,
7935            "resolving the closure read {read} bytes"
7936        );
7937        println!("resolving the padded stand-in's closure read {read} bytes");
7938    }
7939
7940    // ---- soname resolution (loader search-order, synthetic fixtures) ----
7941
7942    #[test]
7943    fn resolve_soname_finds_via_search_dirs_and_runpath_origin() {
7944        let temp = tempfile::tempdir().unwrap();
7945        let origin = temp.path().join("app");
7946        std::fs::create_dir(&origin).unwrap();
7947        let libs = temp.path().join("libs");
7948        std::fs::create_dir(&libs).unwrap();
7949        std::fs::write(libs.join("libc.so.6"), "x").unwrap();
7950
7951        // Found in a standard search directory.
7952        assert_eq!(
7953            resolve_soname("libc.so.6", &origin, &[], std::slice::from_ref(&libs)),
7954            Some(std::fs::canonicalize(libs.join("libc.so.6")).unwrap())
7955        );
7956        // Unresolvable soname → None (shrink-not-fail).
7957        assert_eq!(
7958            resolve_soname("libmissing.so", &origin, &[], std::slice::from_ref(&libs)),
7959            None
7960        );
7961
7962        // Found via a `$ORIGIN`-relative DT_RUNPATH, taking precedence over the search dirs.
7963        let origin_lib = origin.join("lib");
7964        std::fs::create_dir(&origin_lib).unwrap();
7965        std::fs::write(origin_lib.join("libfoo.so"), "x").unwrap();
7966        assert_eq!(
7967            resolve_soname("libfoo.so", &origin, &["$ORIGIN/lib".to_string()], &[]),
7968            Some(std::fs::canonicalize(origin_lib.join("libfoo.so")).unwrap())
7969        );
7970    }
7971
7972    // ---- transitive Landlock grant derivation (synthetic ELF + fake libs) ----
7973
7974    #[test]
7975    fn resolve_landlock_grants_derives_interp_and_needed_closure() {
7976        let temp = tempfile::tempdir().unwrap();
7977        let bin_dir = temp.path().join("bin");
7978        let lib_dir = temp.path().join("lib");
7979        std::fs::create_dir(&bin_dir).unwrap();
7980        std::fs::create_dir(&lib_dir).unwrap();
7981
7982        // A fake interpreter and a fake shared library, both outside any "workdir".
7983        let interp = bin_dir.join("ld-fake.so");
7984        std::fs::write(&interp, "not really elf").unwrap();
7985        std::fs::write(lib_dir.join("libfake.so.1"), "not really elf").unwrap();
7986
7987        // A synthetic dynamically-linked binary naming that interp + that soname.
7988        let prog = bin_dir.join("prog");
7989        let elf = build_elf64(Some(interp.to_str().unwrap()), &["libfake.so.1"], None);
7990        std::fs::write(&prog, &elf).unwrap();
7991
7992        let grants =
7993            resolve_landlock_grants_in(std::slice::from_ref(&prog), std::slice::from_ref(&lib_dir));
7994
7995        let expect = |p: PathBuf| std::fs::canonicalize(p).unwrap();
7996        assert!(
7997            grants.contains(&expect(prog)),
7998            "the binary itself must be granted: {grants:?}"
7999        );
8000        assert!(
8001            grants.contains(&expect(interp)),
8002            "the ELF interpreter must be granted: {grants:?}"
8003        );
8004        assert!(
8005            grants.contains(&expect(lib_dir.join("libfake.so.1"))),
8006            "each resolved DT_NEEDED library must be granted: {grants:?}"
8007        );
8008    }
8009
8010    #[test]
8011    fn resolve_landlock_grants_skips_unresolvable_deps_and_missing_binaries() {
8012        let temp = tempfile::tempdir().unwrap();
8013        let bin_dir = temp.path().join("bin");
8014        std::fs::create_dir(&bin_dir).unwrap();
8015
8016        // A binary naming a soname that resolves nowhere, plus an interp that doesn't exist.
8017        let prog = bin_dir.join("prog");
8018        std::fs::write(
8019            &prog,
8020            build_elf64(Some("/definitely/not/here.so"), &["libnope.so"], None),
8021        )
8022        .unwrap();
8023        let missing = bin_dir.join("does-not-exist");
8024
8025        let grants =
8026            resolve_landlock_grants_in(&[prog.clone(), missing], std::slice::from_ref(&bin_dir));
8027
8028        // Only the real binary survives; the missing binary, missing interp, and unresolvable
8029        // soname each contribute nothing (shrink-not-fail).
8030        assert_eq!(grants, vec![std::fs::canonicalize(&prog).unwrap()]);
8031    }
8032
8033    // ---- interpreter_runtime → LandlockGrant construction (pure, syscall-free, every OS) ----
8034
8035    fn interp_grant(binary: &str, dirs: &[(&str, bool)]) -> InterpreterRuntimeGrant {
8036        InterpreterRuntimeGrant {
8037            binary: binary.to_string(),
8038            dirs: dirs
8039                .iter()
8040                .map(|(path, list_dir)| murmur_artifact::InterpreterRuntimeDir {
8041                    path: path.to_string(),
8042                    list_dir: *list_dir,
8043                })
8044                .collect(),
8045        }
8046    }
8047
8048    #[test]
8049    fn resolve_interpreter_runtime_grants_copies_list_dir_verbatim() {
8050        // Two directories under the same binary, opposite `list_dir` — proving enumerability is
8051        // per-directory and copied exactly, not inferred or unified.
8052        let grants = resolve_interpreter_runtime_grants(&[interp_grant(
8053            "python3",
8054            &[
8055                ("/usr/lib/python3.11", true),
8056                ("/usr/lib/python3.11/lib-dynload", false),
8057            ],
8058        )]);
8059
8060        assert_eq!(
8061            grants,
8062            vec![
8063                LandlockGrant {
8064                    path: PathBuf::from("/usr/lib/python3.11"),
8065                    list_dir: true,
8066                    executable: true,
8067                    pinned: None,
8068                },
8069                LandlockGrant {
8070                    path: PathBuf::from("/usr/lib/python3.11/lib-dynload"),
8071                    list_dir: false,
8072                    executable: true,
8073                    pinned: None,
8074                },
8075            ]
8076        );
8077    }
8078
8079    #[test]
8080    fn resolve_interpreter_runtime_grants_flattens_multiple_grants_and_needs_no_existing_path() {
8081        // The declared paths need not exist on this host (this is pure resolution — `apply_landlock_
8082        // scope` skips any that fail to open), and multiple grants flatten in order.
8083        let grants = resolve_interpreter_runtime_grants(&[
8084            interp_grant("python3", &[("/opt/py/nonexistent", false)]),
8085            interp_grant("ruby", &[("/opt/rb/also-nonexistent", true)]),
8086        ]);
8087
8088        assert_eq!(
8089            grants,
8090            vec![
8091                LandlockGrant {
8092                    path: PathBuf::from("/opt/py/nonexistent"),
8093                    list_dir: false,
8094                    executable: true,
8095                    pinned: None,
8096                },
8097                LandlockGrant {
8098                    path: PathBuf::from("/opt/rb/also-nonexistent"),
8099                    list_dir: true,
8100                    executable: true,
8101                    pinned: None,
8102                },
8103            ]
8104        );
8105        assert!(resolve_interpreter_runtime_grants(&[]).is_empty());
8106    }
8107
8108    #[test]
8109    fn non_listable_files_marks_every_closure_file_non_enumerable() {
8110        // The DT_NEEDED closure yields individual files; wrapping them must never set `list_dir`
8111        // (ReadDir on a regular file is a no-op anyway).
8112        let wrapped = LandlockGrant::non_listable_files(vec![
8113            PathBuf::from("/lib/x86_64-linux-gnu/libc.so.6"),
8114            PathBuf::from("/usr/bin/bash"),
8115        ]);
8116        assert!(wrapped.iter().all(|grant| !grant.list_dir), "{wrapped:?}");
8117        assert_eq!(
8118            wrapped.iter().map(|g| g.path.clone()).collect::<Vec<_>>(),
8119            vec![
8120                PathBuf::from("/lib/x86_64-linux-gnu/libc.so.6"),
8121                PathBuf::from("/usr/bin/bash"),
8122            ]
8123        );
8124    }
8125
8126    // ---- native tool entrypoints ------------------------------------------------------------
8127
8128    fn write_entrypoint(dir: &Path, name: &str, head: &[u8]) -> PathBuf {
8129        let path = dir.join(name);
8130        std::fs::write(&path, head).unwrap();
8131        path
8132    }
8133
8134    /// `path` as the runtime records a native tool it has just written there, inside `workdir`.
8135    fn installed(path: &Path, workdir: &Path) -> NativeEntrypoint {
8136        NativeEntrypoint::derive(path, &std::fs::read(path).unwrap(), workdir)
8137    }
8138
8139    #[test]
8140    fn shebang_interpreter_reads_the_absolute_interpreter_and_nothing_else() {
8141        let cases: &[(&[u8], Option<&str>)] = &[
8142            (b"#!/bin/sh\necho hi\n", Some("/bin/sh")),
8143            (b"#! /usr/bin/python3 -u\n", Some("/usr/bin/python3")),
8144            // `env` is what the kernel runs; `python3` is found by `env` and needs its own grant.
8145            (b"#!/usr/bin/env python3\n", Some("/usr/bin/env")),
8146            (b"#!sh\n", None),
8147            (b"#!\n", None),
8148            (b"\x7fELF\x02\x01\x01", None),
8149            (b"", None),
8150        ];
8151        for (head, expected) in cases {
8152            assert_eq!(
8153                shebang_interpreter(head),
8154                expected.map(PathBuf::from),
8155                "{:?}",
8156                String::from_utf8_lossy(head)
8157            );
8158        }
8159
8160        // A `#!` line longer than the kernel's own buffer names nothing past it.
8161        let mut long = b"#!".to_vec();
8162        long.extend(std::iter::repeat_n(b' ', SHEBANG_READ_LIMIT as usize));
8163        long.extend(b"/bin/sh\n");
8164        assert_eq!(shebang_interpreter(&long), None);
8165    }
8166
8167    /// The entrypoint, pinned to its inode, and its interpreter are added as `Execute + ReadFile`
8168    /// file grants, and nothing is taken away from the enforcement they extend.
8169    #[test]
8170    fn for_native_entrypoint_adds_the_pinned_entrypoint_and_its_interpreter_only() {
8171        let dir = tempfile::tempdir().unwrap();
8172        let entrypoint = write_entrypoint(dir.path(), "tool", b"#!/bin/sh\ncat\n");
8173        let existing = LandlockGrant {
8174            path: PathBuf::from("/usr/bin/git"),
8175            list_dir: false,
8176            executable: true,
8177            pinned: None,
8178        };
8179        let base = ShellEnforcement {
8180            tier: EnforcementTier::KernelFull,
8181            landlock_grants: vec![existing.clone()],
8182            ..ShellEnforcement::environment_only().with_test_session_dirs()
8183        };
8184
8185        let native = base.for_native_entrypoint(&installed(&entrypoint, dir.path()), None);
8186
8187        assert_eq!(native.landlock_grants[0], existing);
8188        let added: Vec<&LandlockGrant> = native.landlock_grants[1..].iter().collect();
8189        let identity = FileIdentity::of(&std::fs::metadata(&entrypoint).unwrap());
8190        assert!(
8191            added
8192                .iter()
8193                .any(|grant| grant.path == entrypoint && grant.pinned == Some(identity)),
8194            "the entrypoint must be granted, pinned to its inode: {added:?}"
8195        );
8196        let canonical_sh = std::fs::canonicalize("/bin/sh").unwrap();
8197        assert!(
8198            added.iter().any(|grant| grant.path == canonical_sh),
8199            "/bin/sh must be granted: {added:?}"
8200        );
8201        assert!(
8202            added
8203                .iter()
8204                .all(|grant| grant.executable && !grant.list_dir),
8205            "{added:?}"
8206        );
8207        // The closure is files, never a directory such as the workdir or /etc.
8208        assert!(added.iter().all(|grant| !grant.path.is_dir()), "{added:?}");
8209        assert!(native.sealed_bind_dirs.is_empty(), "not sealed: no binds");
8210        assert!(
8211            Arc::ptr_eq(&native.session_netns, &base.session_netns),
8212            "a native spawn joins the session's own namespace"
8213        );
8214        assert_eq!(
8215            base.landlock_grants.len(),
8216            1,
8217            "the session's grants are untouched"
8218        );
8219    }
8220
8221    /// A store handed to one native spawn is that spawn's alone: the clone carries exactly that
8222    /// path, and neither the session's enforcement nor a spawn with no store carries any.
8223    #[test]
8224    fn for_native_entrypoint_sets_exactly_the_given_store_and_the_session_holds_none() {
8225        let dir = tempfile::tempdir().unwrap();
8226        let entrypoint = installed(
8227            &write_entrypoint(
8228                dir.path(),
8229                "tool",
8230                b"#!/bin/sh
8231",
8232            ),
8233            dir.path(),
8234        );
8235        let store = dir.path().join("state").join("config-capsule");
8236        for tier in [
8237            EnforcementTier::KernelFull,
8238            EnforcementTier::KernelSealed,
8239            EnforcementTier::KernelSeccompOnly,
8240            EnforcementTier::EnvironmentOnly,
8241        ] {
8242            let session = ShellEnforcement {
8243                tier,
8244                ..ShellEnforcement::environment_only().with_test_session_dirs()
8245            };
8246            let native = session.for_native_entrypoint(&entrypoint, Some(&store));
8247            assert_eq!(
8248                native.native_state_dir.as_deref(),
8249                Some(store.as_path()),
8250                "{tier:?}"
8251            );
8252            assert_eq!(session.native_state_dir, None, "{tier:?}");
8253            assert_eq!(
8254                session
8255                    .for_native_entrypoint(&entrypoint, None)
8256                    .native_state_dir,
8257                None,
8258                "{tier:?}"
8259            );
8260            // The store is a writable rule of its own, never an exec grant or a read-only bind.
8261            assert!(
8262                native
8263                    .landlock_grants
8264                    .iter()
8265                    .all(|grant| !grant.path.starts_with(&store)),
8266                "{tier:?}"
8267            );
8268            assert!(
8269                native
8270                    .sealed_bind_dirs
8271                    .iter()
8272                    .all(|bind| !bind.starts_with(&store)),
8273                "{tier:?}"
8274            );
8275        }
8276    }
8277
8278    /// On `KernelSealed`, an interpreter outside the fixed runtime paths has its directory bound
8279    /// into the composed root as an optional bind; one inside them adds nothing.
8280    #[test]
8281    fn for_native_entrypoint_binds_an_interpreter_outside_the_runtime_paths_when_sealed() {
8282        let dir = tempfile::tempdir().unwrap();
8283        let sealed = ShellEnforcement {
8284            tier: EnforcementTier::KernelSealed,
8285            ..ShellEnforcement::environment_only().with_test_session_dirs()
8286        };
8287
8288        let system = installed(
8289            &write_entrypoint(dir.path(), "system", b"#!/bin/sh\n"),
8290            dir.path(),
8291        );
8292        assert!(sealed
8293            .for_native_entrypoint(&system, None)
8294            .sealed_bind_dirs
8295            .is_empty());
8296
8297        let custom = installed(
8298            &write_entrypoint(dir.path(), "custom", b"#!/opt/tools/bin/run\n"),
8299            dir.path(),
8300        );
8301        assert_eq!(
8302            sealed.for_native_entrypoint(&custom, None).sealed_bind_dirs,
8303            vec![PathBuf::from("/opt/tools/bin")]
8304        );
8305
8306        let scoped = ShellEnforcement {
8307            tier: EnforcementTier::KernelFull,
8308            ..ShellEnforcement::environment_only().with_test_session_dirs()
8309        };
8310        assert!(scoped
8311            .for_native_entrypoint(&custom, None)
8312            .sealed_bind_dirs
8313            .is_empty());
8314    }
8315
8316    /// The capsule can rewrite the installed entrypoint between calls. Whatever it writes, the
8317    /// next spawn is granted exactly what the installed image was: a new `#!` interpreter, on any
8318    /// tier, adds no exec grant and no composed-root bind.
8319    #[test]
8320    fn a_rewritten_entrypoint_gains_no_grant() {
8321        let dir = tempfile::tempdir().unwrap();
8322        let entrypoint = write_entrypoint(dir.path(), "tool", b"#!/bin/sh\ncat\n");
8323        let native = installed(&entrypoint, dir.path());
8324
8325        for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
8326            let base = ShellEnforcement {
8327                tier,
8328                ..ShellEnforcement::environment_only().with_test_session_dirs()
8329            };
8330            let before = base.for_native_entrypoint(&native, None);
8331
8332            std::fs::write(&entrypoint, b"#!/opt/elsewhere/bin/run\n").unwrap();
8333            let after = base.for_native_entrypoint(&native, None);
8334            std::fs::write(&entrypoint, b"#!/bin/sh\ncat\n").unwrap();
8335
8336            assert_eq!(after.landlock_grants, before.landlock_grants, "{tier:?}");
8337            assert_eq!(after.sealed_bind_dirs, before.sealed_bind_dirs, "{tier:?}");
8338            assert!(
8339                after
8340                    .landlock_grants
8341                    .iter()
8342                    .all(|grant| !grant.path.starts_with("/opt/elsewhere")),
8343                "{tier:?}: {:?}",
8344                after.landlock_grants
8345            );
8346        }
8347    }
8348
8349    /// The dependency walk neither grants nor reads a file under the accessible workdir, where the
8350    /// capsule could plant a library whose own entries widen the closure.
8351    #[test]
8352    fn the_entrypoint_closure_does_not_reach_into_the_workdir() {
8353        let temp = tempfile::tempdir().unwrap();
8354        let workdir = temp.path().join("workdir");
8355        let tool_dir = workdir.join("tools").join("tool");
8356        std::fs::create_dir_all(&tool_dir).unwrap();
8357        let system = temp.path().join("system");
8358        std::fs::create_dir(&system).unwrap();
8359        std::fs::write(system.join("libsys.so"), "x").unwrap();
8360        let outside = temp.path().join("outside");
8361        std::fs::create_dir(&outside).unwrap();
8362        std::fs::write(outside.join("libpicked.so"), "x").unwrap();
8363
8364        // Planted next to the entrypoint, found through `$ORIGIN`, and naming a file outside.
8365        std::fs::write(
8366            tool_dir.join("libplanted.so"),
8367            build_elf64(
8368                None,
8369                &[outside.join("libpicked.so").to_str().unwrap()],
8370                None,
8371            ),
8372        )
8373        .unwrap();
8374        // A symlink next to the entrypoint: canonicalising it lands outside the workdir, so only
8375        // refusing the `$ORIGIN` lookup itself keeps the host file out of the closure.
8376        std::fs::write(outside.join("host-binary"), "x").unwrap();
8377        std::os::unix::fs::symlink(outside.join("host-binary"), tool_dir.join("liblinked.so"))
8378            .unwrap();
8379        let image = build_elf64(
8380            None,
8381            &["libplanted.so", "liblinked.so", "libsys.so"],
8382            Some("$ORIGIN"),
8383        );
8384        let entrypoint = tool_dir.join("tool");
8385        std::fs::write(&entrypoint, &image).unwrap();
8386
8387        let native = NativeEntrypoint::derive_in(
8388            &entrypoint,
8389            &image,
8390            &workdir,
8391            std::slice::from_ref(&system),
8392        );
8393
8394        let sys = std::fs::canonicalize(system.join("libsys.so")).unwrap();
8395        assert_eq!(native.closure, vec![sys]);
8396    }
8397
8398    /// A pinned grant whose path no longer opens to the pinned inode is dropped: replacing the
8399    /// entrypoint, here with a symlink to a host binary, leaves it with no `Execute` rule.
8400    #[cfg(target_os = "linux")]
8401    #[test]
8402    fn a_replaced_entrypoint_loses_its_pinned_grant() {
8403        let accessible = tempfile::tempdir().unwrap();
8404        let session = accessible
8405            .path()
8406            .join(".murmur")
8407            .join("ses_native_entrypoint");
8408        std::fs::create_dir_all(&session).unwrap();
8409        let entrypoint = write_entrypoint(&session, "tool", b"#!/bin/sh\n");
8410        let native = installed(&entrypoint, accessible.path());
8411        let grants = ShellEnforcement {
8412            tier: EnforcementTier::KernelFull,
8413            ..ShellEnforcement::environment_only().with_test_session_dirs()
8414        }
8415        .for_native_entrypoint(&native, None)
8416        .landlock_grants;
8417        let open = |grants: &[LandlockGrant]| {
8418            linux_enforce::open_landlock_fds(linux_enforce::LandlockFdRequest {
8419                landlock_grants: grants,
8420                ..linux_enforce::LandlockFdRequest::workdir_only(accessible.path())
8421            })
8422            .unwrap()
8423            .grant_count()
8424        };
8425        let installed_count = open(&grants);
8426        // Holds the installed inode for the rest of the test. Freed, its number may be handed to
8427        // the next file created on this filesystem, which would then match the pin.
8428        std::fs::hard_link(&entrypoint, session.join("installed")).unwrap();
8429
8430        std::fs::remove_file(&entrypoint).unwrap();
8431        std::os::unix::fs::symlink("/bin/sh", &entrypoint).unwrap();
8432        assert_eq!(open(&grants), installed_count - 1, "symlink swapped in");
8433
8434        std::fs::remove_file(&entrypoint).unwrap();
8435        std::fs::write(&entrypoint, b"#!/bin/sh\n").unwrap();
8436        assert_eq!(open(&grants), installed_count - 1, "new file, new inode");
8437    }
8438
8439    // ---- `CAPSULE_DEVICE_GRANTS` (fixed device set) ----------------------------------------
8440    //
8441    // The canonical statement of what the *content* checks in this file are worth, referenced by
8442    // the `denied_socket_domains` and `SECCOMP_SYSCALL_ALLOWLIST` sections below: they assert what
8443    // a Rust constant holds, NOT that any kernel grants or refuses anything. A green run is not
8444    // evidence that `/dev/null` is writable inside a capsule or that `/dev/random` is refused —
8445    // on a host below `KernelFull`, `apply_landlock_scope` never runs at all. A content check
8446    // buys one thing: changing a constant cannot happen by accident.
8447    //
8448    // The enforcement claim for this constant is verified only by the manual procedure in
8449    // `docs/content/reference/capsule-device-set-manual-verification.md`, on real,
8450    // uncontainerized Linux hardware.
8451
8452    #[test]
8453    fn capsule_device_grants_are_exactly_three_devices_with_only_dev_null_writable() {
8454        let listed: Vec<(&str, bool)> = CAPSULE_DEVICE_GRANTS
8455            .iter()
8456            .map(|device| (device.path, device.writable))
8457            .collect();
8458        assert_eq!(
8459            listed,
8460            vec![
8461                ("/dev/null", true),
8462                ("/dev/zero", false),
8463                ("/dev/urandom", false),
8464            ],
8465            "the capsule device set is fixed at exactly these three paths and these access \
8466             levels. /dev/null must stay writable — Python's subprocess.DEVNULL opens it O_RDWR \
8467             and a shell `2>/dev/null` redirect opens it O_WRONLY, so a read-only grant breaks \
8468             both. Widening this list is allowed only on demonstrated workload failure, and the \
8469             failure gets recorded alongside the widening (see the manual acceptance procedure on \
8470             the diagnostics reference page)."
8471        );
8472    }
8473
8474    #[test]
8475    fn capsule_device_grants_exclude_the_devices_deliberately_left_out() {
8476        // `/dev/random` is the one with a reasoning trap attached: a plain write() to it credits
8477        // *zero* entropy (crediting needs the RNDADDENTROPY ioctl under CAP_SYS_ADMIN, which the
8478        // shell child has dropped). It is excluded because nothing needs a blocking RNG when
8479        // /dev/urandom is granted — not because writing to it would poison the kernel pool.
8480        for excluded in [
8481            "/dev/random",
8482            "/dev/full",
8483            "/dev/tty",
8484            "/dev/console",
8485            "/dev/mem",
8486            "/dev/sda",
8487            "/dev",
8488        ] {
8489            assert!(
8490                !CAPSULE_DEVICE_GRANTS
8491                    .iter()
8492                    .any(|device| device.path == excluded),
8493                "{excluded} must not be in the capsule device set — it is denied by the ordinary \
8494                 no-matching-rule path, and adding it here would be the only way to change that"
8495            );
8496        }
8497    }
8498
8499    #[test]
8500    fn capsule_device_grants_name_absolute_paths_under_dev() {
8501        // The list is opened verbatim with `O_PATH` in the parent: a relative path would resolve
8502        // against whatever cwd the host process happens to have.
8503        for device in CAPSULE_DEVICE_GRANTS {
8504            assert!(
8505                device.path.starts_with("/dev/"),
8506                "{} must be an absolute path under /dev",
8507                device.path
8508            );
8509        }
8510    }
8511
8512    // ---- `denied_socket_domains` ----------------------------------------------------------
8513    //
8514    // Content checks — see the `CAPSULE_DEVICE_GRANTS` section above for what that is and is not
8515    // worth. That `socket(AF_UNIX, ...)` is actually denied on a real host is verified only by the
8516    // manual procedure in `docs/content/reference/af-unix-sockets-manual-verification.md`, on
8517    // real Linux hardware.
8518
8519    #[test]
8520    fn denied_socket_domains_denies_unix_by_default() {
8521        let denied = denied_socket_domains(false);
8522        assert!(
8523            denied.contains(&LINUX_AF_UNIX),
8524            "AF_UNIX must be denied when the manifest does not declare \
8525             capabilities.network.unix_sockets — this is the /var/run/docker.sock escape"
8526        );
8527    }
8528
8529    #[test]
8530    fn denied_socket_domains_omits_unix_only_when_explicitly_allowed() {
8531        let denied = denied_socket_domains(true);
8532        assert!(
8533            !denied.contains(&LINUX_AF_UNIX),
8534            "a capsule that declared capabilities.network.unix_sockets: true must get no \
8535             AF_UNIX deny rule at all"
8536        );
8537    }
8538
8539    /// Netlink and packet sockets have no manifest key that widens them: the deny list must be
8540    /// identical for both values of the one flag that exists.
8541    #[test]
8542    fn denied_socket_domains_always_denies_netlink_and_packet() {
8543        for unix_sockets_allowed in [false, true] {
8544            let denied = denied_socket_domains(unix_sockets_allowed);
8545            assert!(
8546                denied.contains(&LINUX_AF_NETLINK),
8547                "AF_NETLINK must be denied unconditionally (unix_sockets_allowed = \
8548                 {unix_sockets_allowed})"
8549            );
8550            assert!(
8551                denied.contains(&LINUX_AF_PACKET),
8552                "AF_PACKET must be denied unconditionally (unix_sockets_allowed = \
8553                 {unix_sockets_allowed})"
8554            );
8555        }
8556    }
8557
8558    /// The rule only ever subtracts: IP sockets stay governed by the capsule's own network
8559    /// namespace and egress proxy against `capabilities.network.allow`, never by a domain-level
8560    /// deny.
8561    #[test]
8562    fn denied_socket_domains_never_denies_ip_families() {
8563        for unix_sockets_allowed in [false, true] {
8564            let denied = denied_socket_domains(unix_sockets_allowed);
8565            assert!(!denied.contains(&LINUX_AF_INET_DOMAIN));
8566            assert!(!denied.contains(&LINUX_AF_INET6_DOMAIN));
8567        }
8568    }
8569
8570    /// Pins the exact sets, so widening either one is a deliberate edit to this test rather than
8571    /// an unnoticed side effect of touching the function.
8572    #[test]
8573    fn denied_socket_domains_returns_exactly_the_documented_sets() {
8574        assert_eq!(
8575            denied_socket_domains(false),
8576            vec![LINUX_AF_NETLINK, LINUX_AF_PACKET, LINUX_AF_UNIX]
8577        );
8578        assert_eq!(
8579            denied_socket_domains(true),
8580            vec![LINUX_AF_NETLINK, LINUX_AF_PACKET]
8581        );
8582    }
8583
8584    /// The domain constants are Linux ABI numbers spelled as literals (see their doc comment),
8585    /// so nothing checks them against `libc` at compile time. On a Linux build machine, assert
8586    /// they really do agree with `libc`; on macOS this test compiles away, because
8587    /// `libc::AF_NETLINK`/`AF_PACKET` do not exist there at all — which is the whole reason the
8588    /// constants are literals.
8589    #[cfg(target_os = "linux")]
8590    #[test]
8591    fn linux_socket_domain_constants_match_libc() {
8592        assert_eq!(LINUX_AF_UNIX, libc::AF_UNIX);
8593        assert_eq!(LINUX_AF_NETLINK, libc::AF_NETLINK);
8594        assert_eq!(LINUX_AF_PACKET, libc::AF_PACKET);
8595        assert_eq!(LINUX_AF_INET_DOMAIN, libc::AF_INET);
8596        assert_eq!(LINUX_AF_INET6_DOMAIN, libc::AF_INET6);
8597    }
8598
8599    /// The allow and deny domain sets feed two rule loops on the *same* syscall and argument, so
8600    /// an overlap would mean one `socket()` call matching both an `Allow` and an `Errno` rule —
8601    /// a filter whose behavior depends on libseccomp's internal rule ordering rather than on
8602    /// anything stated here.
8603    #[test]
8604    fn socket_domain_allow_and_deny_sets_never_overlap() {
8605        for unix_sockets_allowed in [false, true] {
8606            let denied = denied_socket_domains(unix_sockets_allowed);
8607            for domain in allowed_socket_domains(unix_sockets_allowed) {
8608                assert!(
8609                    !denied.contains(&domain),
8610                    "socket domain {domain} is both allowed and denied \
8611                     (unix_sockets_allowed = {unix_sockets_allowed})"
8612                );
8613            }
8614        }
8615    }
8616
8617    /// `capabilities.network.unix_sockets` is the only manifest key either set consults, and it
8618    /// must move `AF_UNIX` from one set to the other — never leave it in neither, which under a
8619    /// default-deny filter would silently keep unix sockets refused even after a capsule declared
8620    /// the capability.
8621    #[test]
8622    fn allowed_socket_domains_takes_unix_back_exactly_when_declared() {
8623        assert_eq!(
8624            allowed_socket_domains(false),
8625            vec![LINUX_AF_INET_DOMAIN, LINUX_AF_INET6_DOMAIN]
8626        );
8627        assert_eq!(
8628            allowed_socket_domains(true),
8629            vec![LINUX_AF_INET_DOMAIN, LINUX_AF_INET6_DOMAIN, LINUX_AF_UNIX]
8630        );
8631    }
8632
8633    // ---- `SECCOMP_SYSCALL_ALLOWLIST` -------------------------------------------------------
8634    //
8635    // Content checks — see the `CAPSULE_DEVICE_GRANTS` section above for what that is and is not
8636    // worth. These assert the allowlist's contents, not that the kernel denies anything. They buy
8637    // one thing: re-permitting a dangerous syscall cannot happen by accident while reconciling
8638    // the allowlist against a newer upstream profile.
8639
8640    #[test]
8641    fn allowlist_contains_no_syscall_that_must_stay_denied() {
8642        for name in SECCOMP_MUST_STAY_DENIED {
8643            assert!(
8644                !SECCOMP_SYSCALL_ALLOWLIST.contains(name),
8645                "{name} is in SECCOMP_MUST_STAY_DENIED but was added to \
8646                 SECCOMP_SYSCALL_ALLOWLIST — if this is deliberate, remove it from the deny list \
8647                 in the same edit and say why in the commit"
8648            );
8649        }
8650    }
8651
8652    /// `execve`/`execveat` must be *present*: the default-deny action applies to anything unnamed,
8653    /// so omitting them would refuse the child's own first `execve` and no capsule could run a
8654    /// shell tool at all. Exec is decided by the Landlock domain instead; see
8655    /// `linux_enforce::workdir_access_rights` and `resolve_landlock_grants`.
8656    #[test]
8657    fn allowlist_permits_exec_because_landlock_now_decides_it() {
8658        for name in ["execve", "execveat"] {
8659            assert!(
8660                SECCOMP_SYSCALL_ALLOWLIST.contains(&name),
8661                "{name} must reach the kernel — the default action is a deny, so omitting it \
8662                 refuses the child's own exec and nothing runs"
8663            );
8664        }
8665    }
8666
8667    /// `socket` carries argument-conditional rules, and libseccomp resolves an unconditional rule
8668    /// for a syscall by discarding every conditional chain already recorded for it. A bare
8669    /// `"socket"` in the allowlist array would therefore delete the AF_UNIX/AF_NETLINK/AF_PACKET
8670    /// denials at filter-build time, with nothing at runtime to show for it.
8671    #[test]
8672    fn allowlist_excludes_socket_which_is_ruled_on_by_domain() {
8673        assert!(
8674            !SECCOMP_SYSCALL_ALLOWLIST.contains(&"socket"),
8675            "socket must be permitted per-domain by allowed_socket_domains, never unconditionally"
8676        );
8677    }
8678
8679    /// A duplicate is harmless to the kernel but means two edits disagree about the same syscall,
8680    /// which is exactly the state where one of them later gets removed and the other is missed.
8681    #[test]
8682    fn allowlist_has_no_duplicates() {
8683        let mut seen = std::collections::HashSet::new();
8684        for name in SECCOMP_SYSCALL_ALLOWLIST {
8685            assert!(seen.insert(*name), "{name} appears twice in the allowlist");
8686        }
8687    }
8688
8689    /// The whole design is an allowlist. An empty (or near-empty) array would still compile and
8690    /// still "pass" every other test here, while denying every syscall a shell needs.
8691    #[test]
8692    fn allowlist_covers_the_syscalls_no_process_can_run_without() {
8693        for name in [
8694            "read",
8695            "write",
8696            "openat",
8697            "close",
8698            "mmap",
8699            "munmap",
8700            "brk",
8701            "exit_group",
8702            "futex",
8703            "rt_sigreturn",
8704            "clone",
8705            "wait4",
8706            "getpid",
8707            "getdents64",
8708        ] {
8709            assert!(
8710                SECCOMP_SYSCALL_ALLOWLIST.contains(&name),
8711                "{name} is missing from the allowlist — no process could run under this filter"
8712            );
8713        }
8714    }
8715
8716    #[test]
8717    fn network_ip_allowed_matches_only_the_resolved_allowlist() {
8718        let allow = vec![IpAddr::from([127, 0, 0, 1])];
8719        assert!(network_ip_allowed(IpAddr::from([127, 0, 0, 1]), &allow));
8720        assert!(!network_ip_allowed(IpAddr::from([8, 8, 8, 8]), &allow));
8721        assert!(
8722            !network_ip_allowed(IpAddr::from([127, 0, 0, 1]), &[]),
8723            "an empty resolved allowlist must deny every destination"
8724        );
8725    }
8726
8727    // ---- tier warnings (finding #3: assert behavior, not just "doesn't panic") ----
8728
8729    #[test]
8730    fn tier_warning_kernel_full_warns_when_a_shell_is_declared() {
8731        // The Linux "full" tier must NOT be silent: it is a filesystem and syscall scope, not a
8732        // namespace, and a silent tier would imply it has no documented limits. It warns when a
8733        // shell is declared, and stays silent only when there is no subprocess to enforce on.
8734        assert_eq!(
8735            tier_warning(EnforcementTier::KernelFull, false),
8736            Some((W_SEC_005, KERNEL_ENFORCEMENT_NOTICE))
8737        );
8738        assert_eq!(tier_warning(EnforcementTier::KernelFull, true), None);
8739    }
8740
8741    /// The sealed notice states what `/proc` leaks: Landlock refuses reading host
8742    /// `/proc` content at both classes, and only `stat(2)` on `/proc/<pid>` and same-user signals
8743    /// reach outside the capsule.
8744    #[test]
8745    fn tier_warning_kernel_sealed_states_what_proc_still_leaks() {
8746        let (code, notice) = tier_warning(EnforcementTier::KernelSealed, false).unwrap();
8747        assert_eq!(code, W_SEC_005);
8748        assert!(!notice.contains("stays visible"), "{notice}");
8749        assert!(
8750            !notice.contains("exactly as it is under scoped"),
8751            "{notice}"
8752        );
8753        assert!(notice.contains("no PID namespace"), "{notice}");
8754        assert!(
8755            notice
8756                .contains("Landlock refuses reading host /proc content at both scoped and sealed"),
8757            "{notice}"
8758        );
8759        assert!(notice.contains("stat(2) on /proc/<pid>"), "{notice}");
8760        assert!(
8761            notice.contains("exists, its owner and its start time"),
8762            "{notice}"
8763        );
8764        assert!(
8765            notice.contains("kill(2)") && notice.contains("any process of the same user"),
8766            "{notice}"
8767        );
8768        assert_eq!(tier_warning(EnforcementTier::KernelSealed, true), None);
8769    }
8770
8771    #[test]
8772    fn tier_warning_all_tiers_warn_only_when_shell_allow_is_nonempty() {
8773        assert_eq!(
8774            tier_warning(EnforcementTier::KernelSeccompOnly, false),
8775            Some((W_SEC_002, SECCOMP_ONLY_WARNING))
8776        );
8777        assert_eq!(
8778            tier_warning(EnforcementTier::EnvironmentOnly, false),
8779            Some((W_SEC_001, ENVIRONMENT_ONLY_WARNING))
8780        );
8781        assert_eq!(tier_warning(EnforcementTier::KernelFull, true), None);
8782        assert_eq!(tier_warning(EnforcementTier::KernelSeccompOnly, true), None);
8783        assert_eq!(tier_warning(EnforcementTier::EnvironmentOnly, true), None);
8784    }
8785
8786    /// The aggregate-bounding gap is warned about exactly where it is real *and* permanent: a
8787    /// non-Linux host running a capsule that can spawn a subprocess. Never on Linux — there the
8788    /// same condition refuses the launch outright, so a warning would describe a session that
8789    /// does not exist — and never for a capsule with no process tree to bound.
8790    #[test]
8791    fn aggregate_bounding_warns_only_off_linux_and_only_with_a_subprocess_route() {
8792        assert_eq!(
8793            aggregate_bounding_warning(false, true, false),
8794            Some((W_SEC_010, NO_AGGREGATE_BOUNDING_WARNING))
8795        );
8796        assert_eq!(
8797            aggregate_bounding_warning(false, false, false),
8798            None,
8799            "a capsule that cannot spawn a subprocess has nothing to bound"
8800        );
8801        assert_eq!(
8802            aggregate_bounding_warning(true, true, false),
8803            None,
8804            "on Linux this case is a refused launch, not a warning"
8805        );
8806        assert_eq!(
8807            aggregate_bounding_warning(true, true, true),
8808            None,
8809            "a scope exists, so there is no gap"
8810        );
8811    }
8812
8813    /// The warning must name the per-uid nature of `RLIMIT_NPROC` explicitly: "rlimits are still
8814    /// applied" reads as reassurance, and the whole point is that the one rlimit an operator
8815    /// would expect to stop a fork bomb does not.
8816    #[test]
8817    fn aggregate_bounding_warning_states_why_rlimit_nproc_is_insufficient() {
8818        let (_, message) = aggregate_bounding_warning(false, true, false).unwrap();
8819        assert!(message.contains("per-uid"), "message was: {message}");
8820        assert!(message.contains("fork bomb"), "message was: {message}");
8821        assert!(message.contains("pids.max"), "message was: {message}");
8822    }
8823
8824    #[test]
8825    fn warn_for_missing_aggregate_bounding_writes_the_code_and_link_to_bootstrap_log() {
8826        let temp = tempfile::tempdir().unwrap();
8827        warn_for_missing_aggregate_bounding(
8828            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8829            true,
8830            false,
8831        );
8832        let log = bootstrap_log_contents(temp.path());
8833
8834        if cfg!(target_os = "linux") {
8835            assert!(
8836                log.is_empty(),
8837                "Linux refuses the launch instead of warning: {log}"
8838            );
8839        } else {
8840            assert!(log.contains(W_SEC_010), "log was: {log}");
8841            assert!(
8842                log.contains(&security_warning_link(W_SEC_010)),
8843                "log was: {log}"
8844            );
8845        }
8846    }
8847
8848    fn warning_test_policy() -> CapabilityPolicy {
8849        CapabilityPolicy {
8850            shell_allow: vec!["bash".to_string()],
8851            network_allow: vec!["https://api.example.com".to_string()],
8852            ..CapabilityPolicy::default()
8853        }
8854    }
8855
8856    #[test]
8857    fn warn_for_enforcement_tier_kernel_full_logs_scope_when_shell_declared() {
8858        let temp = tempfile::tempdir().unwrap();
8859        warn_for_enforcement_tier(
8860            EnforcementTier::KernelFull,
8861            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8862            &warning_test_policy(),
8863        );
8864        let log = bootstrap_log_contents(temp.path());
8865        assert!(
8866            log.contains(W_SEC_005),
8867            "KernelFull must not be silent — it must state what the tier does and does not cover: {log}"
8868        );
8869        assert!(
8870            log.contains("denied, not absent"),
8871            "must state the scope's documented limit: paths outside the workdir are denied, not \
8872             absent: {log}"
8873        );
8874        assert!(
8875            log.contains("read+execute") && log.contains("outside the workdir"),
8876            "must describe what is now actually granted (narrow, derived read+execute outside \
8877             the workdir), not an unfixed grant bug: {log}"
8878        );
8879        assert!(
8880            log.contains(&security_warning_link(W_SEC_005)),
8881            "must link to the diagnostics doc page: {log}"
8882        );
8883    }
8884
8885    #[test]
8886    fn warn_for_enforcement_tier_kernel_full_silent_without_shell_allow() {
8887        let temp = tempfile::tempdir().unwrap();
8888        warn_for_enforcement_tier(
8889            EnforcementTier::KernelFull,
8890            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8891            &CapabilityPolicy::default(),
8892        );
8893        assert_eq!(
8894            bootstrap_log_contents(temp.path()),
8895            "",
8896            "with no shell declared there is no subprocess to enforce on — stay silent"
8897        );
8898    }
8899
8900    #[test]
8901    fn warn_for_enforcement_tier_seccomp_only_logs_the_landlock_gap() {
8902        let temp = tempfile::tempdir().unwrap();
8903        warn_for_enforcement_tier(
8904            EnforcementTier::KernelSeccompOnly,
8905            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8906            &warning_test_policy(),
8907        );
8908        let log = bootstrap_log_contents(temp.path());
8909        assert!(
8910            log.contains("Landlock"),
8911            "must name the missing primitive: {log}"
8912        );
8913        assert!(
8914            log.contains("filesystem"),
8915            "must name what is not enforced: {log}"
8916        );
8917        assert!(
8918            log.contains(W_SEC_002),
8919            "must carry its warning code: {log}"
8920        );
8921        assert!(
8922            log.contains(&security_warning_link(W_SEC_002)),
8923            "must link to the diagnostics doc page: {log}"
8924        );
8925    }
8926
8927    #[test]
8928    fn warn_for_enforcement_tier_environment_only_logs_permanent_and_bypass_warnings() {
8929        let temp = tempfile::tempdir().unwrap();
8930        warn_for_enforcement_tier(
8931            EnforcementTier::EnvironmentOnly,
8932            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8933            &warning_test_policy(),
8934        );
8935        let log = bootstrap_log_contents(temp.path());
8936        assert!(
8937            log.contains("permanent"),
8938            "must state the tier is permanent, not pending: {log}"
8939        );
8940        assert!(
8941            log.contains("bash"),
8942            "the still-accurate bash/network bypass warning must also fire: {log}"
8943        );
8944        assert!(
8945            log.contains(W_SEC_001),
8946            "must carry the tier warning code: {log}"
8947        );
8948        assert!(
8949            log.contains(W_SEC_003),
8950            "must carry the bash/network bypass warning code: {log}"
8951        );
8952    }
8953
8954    // ---- fail-closed (finding #2: forced setup failure must prevent spawn) ----
8955
8956    struct ForcePrepareFailureGuard;
8957
8958    impl ForcePrepareFailureGuard {
8959        fn new() -> Self {
8960            FORCE_PREPARE_FAILURE.with(|flag| flag.set(true));
8961            Self
8962        }
8963    }
8964
8965    impl Drop for ForcePrepareFailureGuard {
8966        fn drop(&mut self) {
8967            FORCE_PREPARE_FAILURE.with(|flag| flag.set(false));
8968        }
8969    }
8970
8971    #[test]
8972    fn execute_shell_fails_closed_when_enforcement_setup_fails() {
8973        let temp = tempfile::tempdir().unwrap();
8974        let policy = CapabilityPolicy {
8975            shell_allow: vec!["bash".to_string()],
8976            ..CapabilityPolicy::default()
8977        };
8978        let marker = temp.path().join("spawned-anyway.marker");
8979        let script = format!("echo ran > '{}'", marker.display());
8980
8981        {
8982            let _guard = ForcePrepareFailureGuard::new();
8983            let error = crate::shell::execute_shell(
8984                "bash",
8985                &["-c", &script],
8986                &[],
8987                &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8988                &policy,
8989                &ShellEnforcement::environment_only().with_test_session_dirs(),
8990            )
8991            .unwrap_err();
8992            assert!(
8993                error.to_string().contains("sandbox"),
8994                "the error must name what failed to initialize: {error}"
8995            );
8996            assert!(
8997                !marker.exists(),
8998                "no subprocess may be spawned at all when enforcement setup fails"
8999            );
9000        }
9001
9002        // Control run without the forced failure: the identical call spawns and writes the
9003        // marker — proving the marker really would have caught a spawn above.
9004        let result = crate::shell::execute_shell(
9005            "bash",
9006            &["-c", &script],
9007            &[],
9008            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9009            &policy,
9010            &ShellEnforcement::environment_only().with_test_session_dirs(),
9011        )
9012        .unwrap();
9013        assert_eq!(result.exit_code, 0);
9014        assert!(marker.exists());
9015    }
9016}
9017
9018#[cfg(target_os = "linux")]
9019#[cfg(test)]
9020#[allow(clippy::print_stdout, clippy::print_stderr)]
9021mod landlock_probe_isolation {
9022    /// The Landlock probe must leave the calling process exactly as it found it.
9023    ///
9024    /// Stated as "the sealed probe gives the same answer before and after" rather than by
9025    /// inspecting the process's Landlock domain, because there is no API to ask whether a task
9026    /// is landlocked — and because that framing is what actually broke: the probe restricted
9027    /// itself, and every later attempt to build a composed root was refused, on every host, in
9028    /// a way that looked like the host's fault.
9029    ///
9030    /// Host-independent by construction. On a machine that cannot do `sealed` at all, both
9031    /// halves report the same failure and this passes trivially; on a capable one, an
9032    /// in-process `restrict_self` turns the second answer into a mount denial and fails it.
9033    #[test]
9034    fn the_landlock_probe_does_not_restrict_the_calling_process() {
9035        let before = crate::namespace_probe::probe_namespaces().sealed_root;
9036        let _ = super::linux_enforce::probe_landlock_full_access();
9037        let after = crate::namespace_probe::probe_namespaces().sealed_root;
9038
9039        assert_eq!(
9040            before, after,
9041            "the Landlock probe changed what the sealed probe reports ({before:?} -> {after:?}) — \
9042             it has restricted the calling process instead of a forked child, which permanently \
9043             forbids the mount family to this process and everything it spawns",
9044        );
9045    }
9046}
9047
9048#[cfg(all(test, target_os = "linux"))]
9049#[allow(clippy::print_stdout, clippy::print_stderr)]
9050mod linux_integration_tests {
9051    use std::path::Path;
9052
9053    use super::*;
9054
9055    /// Spread-base supplying the three *host-bounding* fields (`resource_limits`,
9056    /// `cgroup_scope`, `workdir_guard`) to the enforcement literals below, which spell out the
9057    /// policy half by hand because they need to pin `tier` explicitly.
9058    ///
9059    /// These tests exercise seccomp, Landlock and the network allowlist — not rlimits or
9060    /// cgroups — so they take exactly what `ShellEnforcement::resolve` produces before
9061    /// `with_host_bounding` attaches a live session's handles: the real default ceilings (not
9062    /// "unbounded" — zeroing them would misrepresent what a real host does, and the rlimit half
9063    /// of the child `pre_exec` runs on every tier), and no cgroup scope or workdir guard, so each
9064    /// spawn gets `RLIMIT_NPROC` headroom over the uid's count at that spawn. The `tier` it
9065    /// carries is always overridden by the literal that spreads it; only the host-bounding tail
9066    /// is ever consumed from here.
9067    fn host_bounding_base() -> ShellEnforcement {
9068        // Every session holds a runtime directory, and a sealed one cannot compose a root
9069        // without it. Kept past the test: a clone may outlive the value.
9070        let base = tempfile::tempdir().unwrap().keep();
9071        ShellEnforcement::environment_only().with_runtime_dir(test_runtime_dir(&base), None)
9072    }
9073
9074    /// Pure content check on the workdir access-right set: no kernel call, no fork, no spawn.
9075    /// It pins *which* rights of [`LANDLOCK_TARGET_ABI`] `apply_landlock_scope` hands the workdir,
9076    /// so re-adding device-node creation, or losing the move and truncate rights, becomes a test
9077    /// failure instead of a silent regression.
9078    ///
9079    /// It proves **nothing** about whether the kernel enforces that set — that is the manual
9080    /// acceptance procedure in
9081    /// `docs/content/reference/workdir-device-node-manual-verification.md` and the
9082    /// `kernel_tier_*` tests below, not this test.
9083    /// It lives in this `#[cfg(target_os = "linux")]` module rather than the cross-platform
9084    /// `tests` module only because `landlock::AccessFs` is a Linux-only dependency.
9085    #[test]
9086    fn workdir_landlock_grant_withholds_device_and_socket_creation() {
9087        use landlock::{Access, AccessFs, ABI};
9088
9089        assert_eq!(
9090            LANDLOCK_TARGET_ABI, 3,
9091            "this test pins the ABI v3 set; raising the cap needs this test revisited with it"
9092        );
9093        let all_v3 = AccessFs::from_all(ABI::V3);
9094        // The `workdir_exec: true` set — the widest the workdir can ever be — so the three
9095        // unconditionally-withheld rights below are checked against the permissive case rather
9096        // than passing trivially because `Execute` happened to be absent too.
9097        let granted = linux_enforce::workdir_access_rights(true);
9098
9099        for withheld in [AccessFs::MakeChar, AccessFs::MakeBlock, AccessFs::MakeSock] {
9100            assert!(
9101                all_v3.contains(withheld),
9102                "{withheld:?} is expected to be part of Landlock ABI v3 — if it is not, this \
9103                 test's premise (and the workdir grant's comment) needs revisiting"
9104            );
9105            assert!(
9106                !granted.contains(withheld),
9107                "the workdir grant must withhold {withheld:?}: with the full ABI set declared \
9108                 by handle_access, withholding it here is what makes it denied rather than merely \
9109                 un-granted"
9110            );
9111        }
9112
9113        for added in [AccessFs::Refer, AccessFs::Truncate] {
9114            assert!(
9115                granted.contains(added),
9116                "the workdir grant must carry {added:?}: without Refer a move between workdir \
9117                 folders is EXDEV, and without Truncate a v3 ruleset refuses truncating the \
9118                 capsule's own files"
9119            );
9120        }
9121
9122        assert!(
9123            granted.contains(AccessFs::MakeFifo),
9124            "MakeFifo stays granted — real build tooling creates named pipes in its working tree"
9125        );
9126
9127        let expected = all_v3 & !(AccessFs::MakeChar | AccessFs::MakeBlock | AccessFs::MakeSock);
9128        assert_eq!(
9129            granted, expected,
9130            "the workdir_exec grant must be exactly Landlock ABI v3 minus \
9131             MakeChar/MakeBlock/MakeSock — nothing else may be added or dropped without updating \
9132             this test and the constant's comment together"
9133        );
9134    }
9135
9136    /// The workdir right set as a ruleset built at ABI v1 sees it, after `apply_landlock_scope`
9137    /// masks it to the handled set: exactly ABI v1 minus the three `Make*` rights, with neither
9138    /// `Refer` nor `Truncate`, so a 5.13–5.18 kernel is handed no right it does not know.
9139    #[test]
9140    fn workdir_rights_masked_to_abi_v1_equal_the_pre_refer_set() {
9141        use landlock::{Access, AccessFs, ABI};
9142
9143        let all_v1 = AccessFs::from_all(ABI::V1);
9144        let masked = linux_enforce::workdir_access_rights(true) & all_v1;
9145
9146        assert_eq!(
9147            masked,
9148            all_v1 & !(AccessFs::MakeChar | AccessFs::MakeBlock | AccessFs::MakeSock),
9149            "masked to ABI v1, the workdir grant must be the v1 set minus the three Make* rights"
9150        );
9151        assert!(!masked.contains(AccessFs::Refer));
9152        assert!(!masked.contains(AccessFs::Truncate));
9153        assert_eq!(
9154            linux_enforce::workdir_access_rights(false) & all_v1,
9155            masked & !AccessFs::Execute,
9156            "masking must not move the workdir_exec axis"
9157        );
9158    }
9159
9160    /// The `workdir_exec` bit, pinned in both directions. A *content* check on the right set
9161    /// only: it proves nothing about whether a kernel refuses an exec, which is the manual
9162    /// procedure in
9163    /// `docs/content/reference/workdir-exec-landlock-manual-verification.md`. It catches the one
9164    /// regression that matters here: flipping the default back — which would silently reopen the
9165    /// rename-to-an-allowlisted-basename bypass — cannot happen without this test failing.
9166    #[test]
9167    fn workdir_execute_right_is_granted_only_when_workdir_exec_is_declared() {
9168        use landlock::AccessFs;
9169
9170        assert!(
9171            !linux_enforce::workdir_access_rights(false).contains(AccessFs::Execute),
9172            "the default must withhold Execute from the workdir — that withholding is the whole \
9173             of what enforces capabilities.shell.allow now that the exec supervisor is gone"
9174        );
9175        assert!(
9176            linux_enforce::workdir_access_rights(true).contains(AccessFs::Execute),
9177            "capabilities.filesystem.workdir_exec: true must actually grant Execute, or the \
9178             compile-and-run workflows it exists for silently stop working"
9179        );
9180
9181        // Nothing else moves with the flag: the two sets differ by exactly `Execute`.
9182        assert_eq!(
9183            linux_enforce::workdir_access_rights(true) & !AccessFs::Execute,
9184            linux_enforce::workdir_access_rights(false),
9185            "workdir_exec must be a one-bit axis — no other right may ride along with it"
9186        );
9187    }
9188
9189    /// A sealed enforcement over a fresh runtime directory beside `accessible`.
9190    fn sealed_with_runtime_dir(base: &Path) -> ShellEnforcement {
9191        ShellEnforcement {
9192            tier: EnforcementTier::KernelSealed,
9193            ..ShellEnforcement::environment_only().with_test_session_dirs()
9194        }
9195        .with_runtime_dir(test_runtime_dir(base), None)
9196    }
9197
9198    /// The session's temporary folder's descriptor, as `prepare_enforcement` takes it from the
9199    /// held root.
9200    fn temp_dir_fd_of(enforcement: &ShellEnforcement) -> Option<Arc<std::os::fd::OwnedFd>> {
9201        enforcement
9202            .runtime_dir
9203            .as_ref()
9204            .and_then(|roots| roots.temp.as_ref())
9205            .map(|temp| Arc::new(temp.path_fd().unwrap()))
9206    }
9207
9208    /// The session's home folder's descriptor, as `prepare_enforcement` takes it from the held
9209    /// root.
9210    fn home_dir_fd_of(enforcement: &ShellEnforcement) -> Option<Arc<std::os::fd::OwnedFd>> {
9211        enforcement
9212            .home_root()
9213            .map(|home| Arc::new(home.path_fd().unwrap()))
9214    }
9215
9216    /// The sealed sources take the session's temporary folder from the runtime directory roots,
9217    /// bind it from its own path, and add nothing to either workdir; the Landlock fds carry the
9218    /// folder's rule whenever one is handed in. Opening an fd is an ordinary `open()` — no
9219    /// Landlock call, no namespace, no privilege — so this runs on any Linux host.
9220    ///
9221    /// It proves **nothing** about whether the kernel then honors the rule built from that fd;
9222    /// the live tests below and the hand-run procedure in
9223    /// `docs/content/reference/sealed-containment-manual-verification.md` do.
9224    #[test]
9225    fn the_temp_folder_and_runtime_dir_are_opened_for_landlock_and_the_sealed_bind() {
9226        let accessible = tempfile::tempdir().unwrap();
9227        let runtime = tempfile::tempdir().unwrap();
9228        let enforcement = sealed_with_runtime_dir(runtime.path());
9229        let temp_dir_fd = temp_dir_fd_of(&enforcement);
9230        let sources = SealedSourceFds::open(
9231            &enforcement,
9232            temp_dir_fd.as_ref(),
9233            home_dir_fd_of(&enforcement).as_ref(),
9234            None,
9235        )
9236        .expect("a real, writable workdir must yield the sealed sources");
9237        assert_eq!(
9238            sources.tmp_store_path,
9239            enforcement.temp_dir_path().unwrap(),
9240            "the composed root binds the session's temporary folder at /tmp"
9241        );
9242        assert_eq!(
9243            sources.home_dir.1,
9244            enforcement.home_dir_path().unwrap(),
9245            "the composed root binds the session's home folder at its own path"
9246        );
9247        let fds = linux_enforce::open_landlock_fds(linux_enforce::LandlockFdRequest {
9248            sealed: Some(&sources),
9249            temp_dir_fd,
9250            home_dir_fd: Some(Arc::clone(&sources.home_dir.0)),
9251            runtime_dir_fd: Some(Arc::clone(&sources.runtime_dir)),
9252            records_dir_fd: sources.records_dir.as_ref().map(|(fd, _)| Arc::clone(fd)),
9253            ..linux_enforce::LandlockFdRequest::workdir_only(accessible.path())
9254        })
9255        .expect("a real, writable workdir must yield sealed Landlock fds");
9256
9257        assert!(fds.temp_dir_fd().is_some());
9258        assert!(
9259            fds.home_dir_fd().is_some(),
9260            "the home folder takes the temporary folder's rule"
9261        );
9262        assert!(fds.runtime_dir_fd().is_some());
9263        assert!(
9264            fds.records_dir_fd().is_some(),
9265            "the records directory takes the runtime directory's read-only rule"
9266        );
9267        assert_eq!(
9268            std::fs::read_dir(accessible.path()).unwrap().count(),
9269            0,
9270            "enabling sealed must add nothing to the work folder"
9271        );
9272
9273        let fds = linux_enforce::open_landlock_fds(linux_enforce::LandlockFdRequest::workdir_only(
9274            accessible.path(),
9275        ))
9276        .expect("a real, writable workdir must yield Landlock fds on any tier");
9277        assert!(
9278            fds.temp_dir_fd().is_none(),
9279            "an enforcement holding no temporary folder grants none"
9280        );
9281        assert!(fds.home_dir_fd().is_none());
9282    }
9283
9284    /// A native tool's store is opened with no link followed at its own name, carried into the
9285    /// sealed sources at its own path and into the Landlock fds, and absent from both for a spawn
9286    /// with no store.
9287    #[test]
9288    fn a_native_tools_store_is_opened_for_landlock_and_the_sealed_bind() {
9289        let accessible = tempfile::tempdir().unwrap();
9290        let runtime = tempfile::tempdir().unwrap();
9291        let enforcement = sealed_with_runtime_dir(runtime.path());
9292        let store = runtime.path().join("state").join("config-capsule");
9293        std::fs::create_dir_all(&store).unwrap();
9294        let store_fd = Arc::new(linux_enforce::open_state_dir(&store).unwrap());
9295        let sources = SealedSourceFds::open(
9296            &enforcement,
9297            temp_dir_fd_of(&enforcement).as_ref(),
9298            home_dir_fd_of(&enforcement).as_ref(),
9299            Some((Arc::clone(&store_fd), store.clone())),
9300        )
9301        .unwrap();
9302        assert_eq!(
9303            sources.state_dir.as_ref().map(|(_, path)| path.as_path()),
9304            Some(store.as_path()),
9305            "the composed root binds the store at its own path"
9306        );
9307        let fds = linux_enforce::open_landlock_fds(linux_enforce::LandlockFdRequest {
9308            sealed: Some(&sources),
9309            state_dir_fd: Some(Arc::clone(&store_fd)),
9310            ..linux_enforce::LandlockFdRequest::workdir_only(accessible.path())
9311        })
9312        .unwrap();
9313        assert!(fds.state_dir_fd().is_some());
9314        let fds = linux_enforce::open_landlock_fds(linux_enforce::LandlockFdRequest::workdir_only(
9315            accessible.path(),
9316        ))
9317        .unwrap();
9318        assert!(fds.state_dir_fd().is_none());
9319
9320        let link = runtime.path().join("state").join("linked");
9321        std::os::unix::fs::symlink(&store, &link).unwrap();
9322        let error = linux_enforce::open_state_dir(&link).expect_err("refused");
9323        assert!(error.contains("state store"), "{error}");
9324        let file = runtime.path().join("state").join("file");
9325        std::fs::write(&file, "x").unwrap();
9326        assert!(linux_enforce::open_state_dir(&file).is_err());
9327    }
9328
9329    /// A sealed root cannot be composed without the session's temporary folder: the refusal is
9330    /// the parent's, names the folder, and comes before anything is spawned.
9331    #[test]
9332    fn a_sealed_root_needs_the_temp_folder() {
9333        let runtime = tempfile::tempdir().unwrap();
9334        let mut roots = test_runtime_dir(runtime.path());
9335        roots.temp = None;
9336        let enforcement = ShellEnforcement {
9337            tier: EnforcementTier::KernelSealed,
9338            ..ShellEnforcement::environment_only().with_test_session_dirs()
9339        }
9340        .with_runtime_dir(roots, None);
9341        let error = SealedSourceFds::open(
9342            &enforcement,
9343            None,
9344            home_dir_fd_of(&enforcement).as_ref(),
9345            None,
9346        )
9347        .err()
9348        .expect("refused");
9349        assert!(error.contains("temporary folder"), "{error}");
9350    }
9351
9352    /// Nor without its home folder: the refusal names the missing home folder, and comes before
9353    /// anything is spawned or written.
9354    #[test]
9355    fn no_home_folder_refuses_a_sealed_root() {
9356        let runtime = tempfile::tempdir().unwrap();
9357        let roots = crate::runtime_dir::RuntimeDirRoots {
9358            home: None,
9359            ..test_runtime_dir(runtime.path())
9360        };
9361        let enforcement = ShellEnforcement {
9362            tier: EnforcementTier::KernelSealed,
9363            ..ShellEnforcement::environment_only().with_test_session_dirs()
9364        }
9365        .with_runtime_dir(roots, None);
9366        let error = SealedSourceFds::open(
9367            &enforcement,
9368            temp_dir_fd_of(&enforcement).as_ref(),
9369            None,
9370            None,
9371        )
9372        .err()
9373        .expect("refused");
9374        assert!(error.contains("no home folder"), "{error}");
9375        assert!(!runtime
9376            .path()
9377            .join(crate::runtime_dir::SEALED_ETC_SUBDIR)
9378            .join("passwd")
9379            .exists());
9380    }
9381
9382    /// The synthetic account databases, written into `sealed-etc/` beside the runtime directory
9383    /// and opened by the same parent-side call.
9384    ///
9385    /// Like the temporary-folder test above it proves nothing about kernel enforcement — that is
9386    /// the hand-run in `docs/content/reference/sealed-containment-manual-verification.md`. It
9387    /// catches that the files exist before `build_sealed_root` binds them, that there is an fd to
9388    /// build each rule from, and that nothing is written into either workdir for them.
9389    #[test]
9390    fn the_synthetic_etc_files_are_written_into_sealed_etc_and_opened() {
9391        let accessible = tempfile::tempdir().unwrap();
9392        let runtime = tempfile::tempdir().unwrap();
9393        let enforcement = sealed_with_runtime_dir(runtime.path());
9394        let sources = SealedSourceFds::open(
9395            &enforcement,
9396            temp_dir_fd_of(&enforcement).as_ref(),
9397            home_dir_fd_of(&enforcement).as_ref(),
9398            None,
9399        )
9400        .unwrap();
9401        let fds = linux_enforce::open_landlock_fds(linux_enforce::LandlockFdRequest {
9402            sealed: Some(&sources),
9403            ..linux_enforce::LandlockFdRequest::workdir_only(accessible.path())
9404        })
9405        .expect("a real, writable workdir must yield sealed Landlock fds");
9406
9407        let synthetic: Vec<&crate::sealed::SealedEtcPath> = crate::sealed::SEALED_ETC_PATHS
9408            .iter()
9409            .filter(|entry| entry.synthetic.is_some())
9410            .collect();
9411        assert_eq!(
9412            fds.sealed_identity_fd_count(),
9413            synthetic.len(),
9414            "every synthetic /etc entry the composed root binds needs a rule of its own: {synthetic:?}",
9415        );
9416        let etc_dir = runtime.path().join(crate::runtime_dir::SEALED_ETC_SUBDIR);
9417        for entry in &synthetic {
9418            let path = crate::sealed::synthetic_etc_source(&etc_dir, entry.synthetic.unwrap());
9419            assert!(
9420                path.is_file(),
9421                "{} must be written into sealed-etc/",
9422                entry.path
9423            );
9424        }
9425        assert!(
9426            !accessible.path().join(".mur-etc").exists(),
9427            "the work folder gained .mur-etc"
9428        );
9429
9430        let fds = linux_enforce::open_landlock_fds(linux_enforce::LandlockFdRequest::workdir_only(
9431            accessible.path(),
9432        ))
9433        .unwrap();
9434        assert_eq!(fds.sealed_identity_fd_count(), 0);
9435    }
9436
9437    /// A sealed root cannot be composed without the session's runtime directory: the refusal is
9438    /// the parent's, before anything is spawned.
9439    #[test]
9440    fn a_sealed_root_needs_the_runtime_directory() {
9441        let enforcement = ShellEnforcement {
9442            tier: EnforcementTier::KernelSealed,
9443            ..ShellEnforcement::environment_only()
9444        };
9445        let error = SealedSourceFds::open(&enforcement, None, None, None)
9446            .err()
9447            .expect("refused");
9448        assert!(error.contains("runtime directory"), "{error}");
9449    }
9450
9451    /// The one field that has to agree across two files: the synthetic `pw_dir` and `$HOME`.
9452    ///
9453    /// Both are the session's home folder's absolute path, `<records>/home`, and this asserts the
9454    /// end result — a capsule sees `pwd.getpwuid(os.getuid()).pw_dir` and `os.environ["HOME"]`,
9455    /// and a disagreement between them is silent until a tool picks the wrong one. The uid/gid
9456    /// are checked against the home folder's own owner, which is where
9457    /// `ensure_sealed_identity_files` reads the capsule's identity from.
9458    #[test]
9459    fn home_folder_is_the_synthetic_passwd_entrys_home_and_home() {
9460        use std::os::unix::fs::MetadataExt;
9461
9462        let runtime = tempfile::tempdir().unwrap();
9463        let roots = test_runtime_dir(runtime.path());
9464        let home = roots.home.clone().unwrap();
9465        let written = ensure_sealed_identity_files(&roots.sealed_etc, &home).unwrap();
9466        let env = crate::shell::build_shell_env(
9467            &crate::types::CapabilityPolicy::default(),
9468            &[],
9469            Some(home.path()),
9470            None,
9471            None,
9472            None,
9473        )
9474        .unwrap();
9475
9476        let passwd = std::fs::read_to_string(&written[0]).unwrap();
9477        let owner = std::fs::metadata(home.path()).unwrap();
9478        let entry: Vec<&str> = passwd
9479            .lines()
9480            .last()
9481            .expect("the capsule's own entry is the last line")
9482            .split(':')
9483            .collect();
9484
9485        assert_eq!(entry[2], owner.uid().to_string(), "uid field");
9486        assert_eq!(entry[3], owner.gid().to_string(), "gid field");
9487        assert_eq!(
9488            Some(&entry[5].to_string()),
9489            env.get("HOME"),
9490            "pw_dir and $HOME are the same string or the entry is worse than useless",
9491        );
9492        assert_eq!(
9493            entry[5],
9494            runtime
9495                .path()
9496                .join(crate::records_dir::RECORDS_DIR_NAME)
9497                .join(crate::records_dir::HOME_SUBDIR)
9498                .to_str()
9499                .unwrap()
9500        );
9501
9502        let group = std::fs::read_to_string(&written[1]).unwrap();
9503        assert_eq!(
9504            group.lines().last().unwrap().split(':').nth(2),
9505            Some(owner.gid().to_string().as_str()),
9506            "gid field of the capsule's own group",
9507        );
9508    }
9509
9510    /// A workdir root over `work/` beside an `outside/` directory holding one sentinel file.
9511    fn sealed_planted() -> (
9512        tempfile::TempDir,
9513        PathBuf,
9514        PathBuf,
9515        crate::workdir_fs::WorkdirRoot,
9516    ) {
9517        let dir = tempfile::tempdir().unwrap();
9518        let accessible = dir.path().join("work");
9519        let outside = dir.path().join("outside");
9520        std::fs::create_dir_all(&accessible).unwrap();
9521        std::fs::create_dir_all(&outside).unwrap();
9522        std::fs::write(outside.join("sentinel.txt"), b"operator data").unwrap();
9523        let root = crate::workdir_fs::WorkdirRoot::open(&accessible).unwrap();
9524        (dir, root.path().to_path_buf(), outside, root)
9525    }
9526
9527    /// Staging twice rewrites `sealed-etc/passwd` in place: the inode a Landlock rule was bound to
9528    /// on the first call is the file the second call leaves.
9529    #[test]
9530    fn sealed_identity_files_keep_their_inode() {
9531        use std::os::unix::fs::MetadataExt;
9532
9533        let (_dir, _session, _outside, root) = sealed_planted();
9534        let runtime = tempfile::tempdir().unwrap();
9535        let roots = test_runtime_dir(runtime.path());
9536        let passwd = roots.sealed_etc.path().join("passwd");
9537        ensure_sealed_identity_files(&roots.sealed_etc, &root).unwrap();
9538        let first = std::fs::metadata(&passwd).unwrap().ino();
9539        ensure_sealed_identity_files(&roots.sealed_etc, &root).unwrap();
9540        assert_eq!(std::fs::metadata(&passwd).unwrap().ino(), first);
9541        assert!(root.take_refusals().is_empty());
9542    }
9543
9544    /// Pure content check on the right-set the composed root's `/tmp` rule carries: no kernel call,
9545    /// no fork, no spawn.
9546    ///
9547    /// `apply_landlock_scope` resolves `workdir_access_rights(fds.workdir_exec)` into a single
9548    /// local and adds it to *both* the workdir rule and the `/tmp` rule, so the two sets are equal
9549    /// by construction; what this test pins is the content of that shared set as `/tmp`'s users
9550    /// need it — a compiler writing intermediates, `mktemp` creating and removing files, a package
9551    /// manager creating build directories — and the `Execute` axis, which must track
9552    /// `workdir_exec` on `/tmp` exactly as it does on the workdir, because both paths resolve to
9553    /// the same storage and a binary staged in one is the same bytes as a binary staged in the
9554    /// other.
9555    #[test]
9556    fn the_composed_root_tmp_rule_carries_exactly_the_workdir_right_set() {
9557        use landlock::AccessFs;
9558
9559        for workdir_exec in [false, true] {
9560            let rights = linux_enforce::workdir_access_rights(workdir_exec);
9561
9562            for needed in [
9563                AccessFs::WriteFile,
9564                AccessFs::ReadFile,
9565                AccessFs::ReadDir,
9566                AccessFs::MakeReg,
9567                AccessFs::MakeDir,
9568                AccessFs::RemoveFile,
9569                AccessFs::RemoveDir,
9570            ] {
9571                assert!(
9572                    rights.contains(needed),
9573                    "/tmp carries the workdir's rights, so it must carry {needed:?}: without it \
9574                     `cc`, `mktemp` and `pip` fail on a mount that is genuinely writable",
9575                );
9576            }
9577
9578            assert_eq!(
9579                rights.contains(AccessFs::Execute),
9580                workdir_exec,
9581                "/tmp is backed by a directory inside the workdir, so Execute there must follow \
9582                 capabilities.filesystem.workdir_exec exactly — granting it unconditionally would \
9583                 make /tmp a hole through which capsule-written bytes run",
9584            );
9585        }
9586
9587        assert_eq!(
9588            linux_enforce::workdir_access_rights(true) & !AccessFs::Execute,
9589            linux_enforce::workdir_access_rights(false),
9590            "the /tmp rule and the workdir rule share one right-set binding in \
9591             apply_landlock_scope; if that set ever grows a second conditional axis, this test and \
9592             that binding must be revisited together",
9593        );
9594    }
9595
9596    /// An enforcement on the unconfined tier, holding test session directories, with `declared`
9597    /// as its `capabilities.resources` block.
9598    fn unconfined_enforcement(declared: murmur_artifact::ResourceCapabilities) -> ShellEnforcement {
9599        ShellEnforcement {
9600            tier: EnforcementTier::Unconfined,
9601            declared_resources: declared,
9602            temp_mapping: crate::temp_dir::tier_temp_mapping(EnforcementTier::Unconfined),
9603            ..ShellEnforcement::environment_only()
9604        }
9605        .with_test_session_dirs()
9606    }
9607
9608    /// The `/proc/self/status` line starting `field`, of this process or of what `command`
9609    /// printed.
9610    fn status_line(status: &str, field: &str) -> String {
9611        status
9612            .lines()
9613            .find(|line| line.starts_with(field))
9614            .unwrap_or_else(|| panic!("no {field} line in {status}"))
9615            .to_string()
9616    }
9617
9618    /// What `script` prints when spawned through `prepare_enforcement` on `enforcement`.
9619    fn spawn_unconfined(enforcement: &ShellEnforcement, script: &str) -> String {
9620        let mut command = std::process::Command::new("bash");
9621        command.args(["-c", script]);
9622        let supervisor = prepare_enforcement(&mut command, enforcement, Path::new("/tmp"))
9623            .expect("the unconfined tier installs nothing that can fail");
9624        assert!(matches!(supervisor, SupervisorHandle::Noop));
9625        let output = command.output().expect("bash runs");
9626        assert!(output.status.success(), "{output:?}");
9627        String::from_utf8(output.stdout).unwrap()
9628    }
9629
9630    /// A child spawned through `prepare_enforcement` on the unconfined tier keeps this process's
9631    /// `NoNewPrivs`, effective capabilities and network namespace: nothing is installed, so `sudo`
9632    /// works wherever the operator's sudoers allows it.
9633    #[test]
9634    fn an_unconfined_child_keeps_no_new_privs_capabilities_and_network_namespace() {
9635        let enforcement = unconfined_enforcement(Default::default());
9636        let child = spawn_unconfined(
9637            &enforcement,
9638            "cat /proc/self/status; echo \"netns=$(readlink /proc/self/ns/net)\"",
9639        );
9640        let own = std::fs::read_to_string("/proc/self/status").unwrap();
9641        for field in ["NoNewPrivs:", "CapEff:"] {
9642            assert_eq!(
9643                status_line(&child, field),
9644                status_line(&own, field),
9645                "{child}"
9646            );
9647        }
9648        let own_netns = std::fs::read_link("/proc/self/ns/net").unwrap();
9649        assert!(
9650            child.contains(&format!("netns={}", own_netns.display())),
9651            "{child}"
9652        );
9653        assert_eq!(
9654            enforcement.session_netns.creations(),
9655            0,
9656            "the unconfined tier creates no network namespace"
9657        );
9658    }
9659
9660    /// The unconfined tier sets no rlimit a manifest does not declare — the child keeps this
9661    /// process's `RLIMIT_NOFILE` and `RLIMIT_CORE` — and sets exactly what one declares.
9662    #[test]
9663    fn an_unconfined_child_gets_only_the_declared_rlimits() {
9664        let read = |resource| {
9665            let limit = rustix::process::getrlimit(resource);
9666            let show = |value: Option<u64>| {
9667                value.map_or_else(|| "unlimited".to_string(), |value| value.to_string())
9668            };
9669            format!("{} {}", show(limit.current), show(limit.maximum))
9670        };
9671        let undeclared = spawn_unconfined(
9672            &unconfined_enforcement(Default::default()),
9673            "echo \"$(ulimit -Sn) $(ulimit -Hn)\"; echo \"$(ulimit -Sc) $(ulimit -Hc)\"",
9674        );
9675        assert_eq!(
9676            undeclared,
9677            format!(
9678                "{}\n{}\n",
9679                read(rustix::process::Resource::Nofile),
9680                read(rustix::process::Resource::Core)
9681            )
9682        );
9683        let declared = spawn_unconfined(
9684            &unconfined_enforcement(murmur_artifact::ResourceCapabilities {
9685                max_open_files: Some(256),
9686                ..Default::default()
9687            }),
9688            "echo \"$(ulimit -Sn) $(ulimit -Hn)\"; echo \"$(ulimit -Sc) $(ulimit -Hc)\"",
9689        );
9690        assert_eq!(
9691            declared,
9692            format!("256 256\n{}\n", read(rustix::process::Resource::Core))
9693        );
9694    }
9695
9696    #[test]
9697    fn prepare_enforcement_is_noop_for_environment_only_tier_even_on_linux() {
9698        let mut command = std::process::Command::new("true");
9699        let enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
9700        let supervisor = prepare_enforcement(&mut command, &enforcement, Path::new("/tmp"))
9701            .expect("environment_only must never fail");
9702        assert!(matches!(supervisor, SupervisorHandle::Noop));
9703        assert_eq!(
9704            enforcement.session_netns.creations(),
9705            0,
9706            "a tier with no kernel sandbox must not create a network namespace"
9707        );
9708    }
9709
9710    // Exec denial outside the allowlist is deliberately not asserted here. Exec is a Landlock
9711    // right, so the claim holds only on `KernelFull`/`KernelSealed`; on any weaker tier such a
9712    // test passes while exercising a path that enforces nothing. The two tests below assert the
9713    // opposite direction — that allowlisted binaries still run — which any tier does evidence.
9714
9715    #[test]
9716    fn kernel_tier_allows_exec_within_shell_allowlist() {
9717        if crate::network_namespace::skip_without_egress_namespace(
9718            "kernel_tier_allows_exec_within_shell_allowlist",
9719        ) {
9720            return;
9721        }
9722        let tier = detect_enforcement_tier();
9723        if tier == EnforcementTier::EnvironmentOnly {
9724            eprintln!(
9725                "skipping kernel_tier_allows_exec_within_shell_allowlist: resolved to \
9726                 EnvironmentOnly on a Linux host — degrading gracefully"
9727            );
9728            return;
9729        }
9730
9731        let temp = tempfile::tempdir().unwrap();
9732        let policy = CapabilityPolicy {
9733            shell_allow: vec!["bash".to_string()],
9734            ..CapabilityPolicy::default()
9735        };
9736        let enforcement = ShellEnforcement {
9737            tier,
9738            network_allow_ips: Vec::new(),
9739            unix_sockets_allowed: policy.unix_sockets_allowed,
9740            landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
9741                &resolve_exec_allowlist(&policy.shell_allow),
9742            )),
9743            ..host_bounding_base()
9744        };
9745
9746        let result = crate::shell::execute_shell(
9747            "bash",
9748            &["-c", "exit 7"],
9749            &[],
9750            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9751            &policy,
9752            &enforcement,
9753        )
9754        .unwrap();
9755
9756        assert_eq!(
9757            result.exit_code, 7,
9758            "bash itself is allowlisted, so kernel enforcement must permit its own execve — \
9759             proving the allowlist isn't a blanket deny"
9760        );
9761    }
9762
9763    #[test]
9764    fn kernel_tier_allows_nested_exec_of_second_allowlisted_binary() {
9765        if crate::network_namespace::skip_without_egress_namespace(
9766            "kernel_tier_allows_nested_exec_of_second_allowlisted_binary",
9767        ) {
9768            return;
9769        }
9770        let tier = detect_enforcement_tier();
9771        if tier == EnforcementTier::EnvironmentOnly {
9772            eprintln!(
9773                "skipping kernel_tier_allows_nested_exec_of_second_allowlisted_binary: \
9774                 resolved to EnvironmentOnly on a Linux host — degrading gracefully"
9775            );
9776            return;
9777        }
9778
9779        let temp = tempfile::tempdir().unwrap();
9780        let policy = CapabilityPolicy {
9781            shell_allow: vec!["bash".to_string(), "ls".to_string()],
9782            ..CapabilityPolicy::default()
9783        };
9784        let enforcement = ShellEnforcement {
9785            tier,
9786            network_allow_ips: Vec::new(),
9787            unix_sockets_allowed: policy.unix_sockets_allowed,
9788            landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
9789                &resolve_exec_allowlist(&policy.shell_allow),
9790            )),
9791            ..host_bounding_base()
9792        };
9793
9794        // Unlike `exit 7` (a builtin), `ls` forces a genuinely *nested* execve from inside
9795        // bash — the case the no-regression scenario ("allowlisted binary still functions")
9796        // actually cares about.
9797        let result = crate::shell::execute_shell(
9798            "bash",
9799            &["-c", "ls"],
9800            &[],
9801            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9802            &policy,
9803            &enforcement,
9804        )
9805        .unwrap();
9806
9807        assert_eq!(
9808            result.exit_code, 0,
9809            "a second allowlisted binary exec'd from inside allowlisted bash must succeed \
9810             (stderr: {})",
9811            result.stderr
9812        );
9813    }
9814
9815    #[test]
9816    fn kernel_tier_denies_network_connect_outside_allowlist() {
9817        if crate::network_namespace::skip_without_egress_namespace(
9818            "kernel_tier_denies_network_connect_outside_allowlist",
9819        ) {
9820            return;
9821        }
9822        let tier = detect_enforcement_tier();
9823        if tier == EnforcementTier::EnvironmentOnly {
9824            eprintln!(
9825                "skipping kernel_tier_denies_network_connect_outside_allowlist: resolved to \
9826                 EnvironmentOnly on a Linux host — degrading gracefully"
9827            );
9828            return;
9829        }
9830
9831        // A real listener on the *host*, so the destination port is genuinely open. The failure
9832        // below is therefore about reachability, not about a closed port: the subprocess runs in
9833        // its own network namespace, where `127.0.0.1` is that namespace's own loopback and this
9834        // listener does not exist at all.
9835        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
9836        let port = listener.local_addr().unwrap().port();
9837
9838        let temp = tempfile::tempdir().unwrap();
9839        let policy = CapabilityPolicy {
9840            shell_allow: vec!["bash".to_string()],
9841            ..CapabilityPolicy::default()
9842        };
9843        let enforcement = ShellEnforcement {
9844            tier,
9845            // Empty allowlist: nothing is reachable, and the proxy would refuse it even if the
9846            // capsule found its way to the endpoint.
9847            network_allow_rules: Vec::new(),
9848            network_allow_ips: Vec::new(),
9849            unix_sockets_allowed: policy.unix_sockets_allowed,
9850            landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
9851                &resolve_exec_allowlist(&policy.shell_allow),
9852            )),
9853            ..host_bounding_base()
9854        };
9855
9856        // bash's /dev/tcp is a builtin socket+connect — no extra binary needed, no DNS.
9857        let script = format!("exec 3<>/dev/tcp/127.0.0.1/{port}");
9858        let result = crate::shell::execute_shell(
9859            "bash",
9860            &["-c", &script],
9861            &[],
9862            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9863            &policy,
9864            &enforcement,
9865        )
9866        .unwrap();
9867
9868        assert_ne!(
9869            result.exit_code, 0,
9870            "a direct connect() to a destination outside capabilities.network.allow must fail \
9871             even though the port is really open on the host — the subprocess is in its own \
9872             network namespace with no route to it"
9873        );
9874    }
9875
9876    /// The no-regression counterpart of the test above: enforcement must add denials for
9877    /// out-of-policy destinations without breaking permitted ones.
9878    ///
9879    /// The allowlist has to be declared rather than only pre-resolved, because it decides two
9880    /// things: which addresses are permitted *and* which ports the namespace binds a listener on.
9881    /// A port no allow entry implies has nothing listening and is refused by the namespace itself.
9882    ///
9883    /// Deliberately *not* a test of the security property — a green suite proves nothing about
9884    /// the kernel-level claim, and the real check is the hand-run procedure in
9885    /// `docs/content/reference/network-namespace-egress-proxy-manual-verification.md`. This
9886    /// asserts only that the permitted path still functions.
9887    #[test]
9888    fn kernel_tier_reaches_an_allowlisted_destination_through_the_egress_proxy() {
9889        if crate::network_namespace::skip_without_egress_namespace(
9890            "kernel_tier_reaches_an_allowlisted_destination_through_the_egress_proxy",
9891        ) {
9892            return;
9893        }
9894        let tier = detect_enforcement_tier();
9895        if tier == EnforcementTier::EnvironmentOnly {
9896            eprintln!(
9897                "skipping kernel_tier_reaches_an_allowlisted_destination_through_the_egress_proxy: \
9898                 resolved to EnvironmentOnly on a Linux host — degrading gracefully"
9899            );
9900            return;
9901        }
9902
9903        // A real listener on the host, answering one connection, so a success below means bytes
9904        // really crossed the namespace boundary through the proxy rather than merely connecting.
9905        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
9906        let port = listener.local_addr().unwrap().port();
9907        let server = std::thread::spawn(move || {
9908            if let Ok((mut stream, _)) = listener.accept() {
9909                use std::io::Write;
9910                let _ = stream.write_all(b"pong\n");
9911            }
9912        });
9913
9914        let temp = tempfile::tempdir().unwrap();
9915        // Naming the port is required, not incidental: it is what puts a listener on that port
9916        // inside the namespace. See `egress_proxy::egress_listen_ports`.
9917        let allow = vec![format!("http://127.0.0.1:{port}")];
9918        let policy = CapabilityPolicy {
9919            shell_allow: vec!["bash".to_string()],
9920            network_allow: allow.clone(),
9921            ..CapabilityPolicy::default()
9922        };
9923        let rules = crate::network_policy::parse_network_allow_rules(&allow).unwrap();
9924        let enforcement = ShellEnforcement {
9925            tier,
9926            egress_tcp_ports: crate::egress_proxy::egress_listen_ports(&rules),
9927            network_allow_rules: rules,
9928            network_allow_ips: resolve_network_allowlist_ips(&allow).unwrap(),
9929            unix_sockets_allowed: policy.unix_sockets_allowed,
9930            landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
9931                &resolve_exec_allowlist(&policy.shell_allow),
9932            )),
9933            ..host_bounding_base()
9934        };
9935
9936        // Builtins only — `/dev/tcp` and `read` are bash itself — so no second binary is exec'd
9937        // and this measures the network path alone.
9938        let script =
9939            format!("exec 3<>/dev/tcp/127.0.0.1/{port} && read -r reply <&3 && echo \"$reply\"");
9940        let result = crate::shell::execute_shell(
9941            "bash",
9942            &["-c", &script],
9943            &[],
9944            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9945            &policy,
9946            &enforcement,
9947        )
9948        .unwrap();
9949        let _ = server.join();
9950
9951        assert_eq!(
9952            result.exit_code, 0,
9953            "a destination inside capabilities.network.allow must stay reachable — enforcement \
9954             adds denials for out-of-policy destinations, it does not degrade permitted ones \
9955             (stderr: {})",
9956            result.stderr
9957        );
9958        assert!(
9959            result.stdout.contains("pong"),
9960            "the host listener's bytes must come back through the proxy unmodified \
9961             (stdout: {:?}, stderr: {})",
9962            result.stdout,
9963            result.stderr
9964        );
9965    }
9966
9967    #[test]
9968    fn kernel_full_denies_filesystem_access_outside_workdir() {
9969        if crate::network_namespace::skip_without_egress_namespace(
9970            "kernel_full_denies_filesystem_access_outside_workdir",
9971        ) {
9972            return;
9973        }
9974        let tier = detect_enforcement_tier();
9975        if tier != EnforcementTier::KernelFull {
9976            eprintln!(
9977                "skipping kernel_full_denies_filesystem_access_outside_workdir: requires \
9978                 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?} \
9979                 instead"
9980            );
9981            return;
9982        }
9983
9984        let temp = tempfile::tempdir().unwrap();
9985        let policy = CapabilityPolicy {
9986            shell_allow: vec!["bash".to_string()],
9987            ..CapabilityPolicy::default()
9988        };
9989        let enforcement = ShellEnforcement {
9990            tier,
9991            network_allow_ips: Vec::new(),
9992            unix_sockets_allowed: policy.unix_sockets_allowed,
9993            landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
9994                &resolve_exec_allowlist(&policy.shell_allow),
9995            )),
9996            ..host_bounding_base()
9997        };
9998
9999        let result = crate::shell::execute_shell(
10000            "bash",
10001            &["-c", "cat /etc/hostname"],
10002            &[],
10003            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
10004            &policy,
10005            &enforcement,
10006        )
10007        .unwrap();
10008
10009        assert_ne!(
10010            result.exit_code, 0,
10011            "reading a file outside the Landlock-scoped workdir must fail under KernelFull"
10012        );
10013    }
10014
10015    // ---- moves, links and truncation under the ruleset ABI ----
10016    //
10017    // The tests below resolve their enforcement the way a session does — `ShellEnforcement::resolve`
10018    // over a live `HostProbe` at `scoped` — so the ruleset ABI they run under is the one the probe
10019    // fills. Each one that needs a newer ABI than this host offers prints a skip line and returns.
10020
10021    /// A `scoped` session's enforcement for `policy`, from a live probe, or `None` (after a skip
10022    /// line naming `test`) when this host cannot install a Landlock domain at `min_abi` or above.
10023    fn scoped_landlock_enforcement(
10024        test: &str,
10025        policy: &CapabilityPolicy,
10026        min_abi: u8,
10027    ) -> Option<ShellEnforcement> {
10028        if crate::network_namespace::skip_without_egress_namespace(test) {
10029            return None;
10030        }
10031        let probe = HostProbe::probe();
10032        let enforcement = ShellEnforcement::resolve_declared(
10033            policy,
10034            murmur_artifact::ContainmentClass::Scoped,
10035            probe.clone(),
10036        )
10037        .unwrap()
10038        .with_test_session_dirs();
10039        if enforcement.tier != EnforcementTier::KernelFull
10040            || enforcement.landlock_abi < Some(min_abi)
10041        {
10042            eprintln!(
10043                "skipping {test}: needs EnforcementTier::KernelFull with a Landlock ruleset at ABI \
10044                 {min_abi} or later; resolved {:?} at ABI {:?} (kernel reports {:?})",
10045                enforcement.tier,
10046                enforcement.landlock_abi,
10047                probe.landlock_kernel_abi()
10048            );
10049            return None;
10050        }
10051        Some(enforcement)
10052    }
10053
10054    fn coreutils_policy(extra: &[&str]) -> CapabilityPolicy {
10055        let mut shell_allow: Vec<String> = ["bash", "mkdir", "mv", "ln", "stat", "cat"]
10056            .iter()
10057            .map(|name| name.to_string())
10058            .collect();
10059        shell_allow.extend(extra.iter().map(|name| name.to_string()));
10060        CapabilityPolicy {
10061            shell_allow,
10062            ..CapabilityPolicy::default()
10063        }
10064    }
10065
10066    fn run_bash_in(
10067        workdir: &Path,
10068        script: &str,
10069        policy: &CapabilityPolicy,
10070        enforcement: &ShellEnforcement,
10071    ) -> crate::shell::ShellResult {
10072        let result = crate::shell::execute_shell(
10073            "bash",
10074            &["-c", script],
10075            &[],
10076            &crate::workdir_fs::WorkdirRoot::open(workdir).unwrap(),
10077            policy,
10078            enforcement,
10079        )
10080        .unwrap();
10081        println!(
10082            "exit {}\n--- stdout\n{}--- stderr\n{}---",
10083            result.exit_code, result.stdout, result.stderr
10084        );
10085        result
10086    }
10087
10088    /// A `<base>/work` workdir with a sibling `<base>/outside` on the same filesystem, so a
10089    /// refusal between them is Landlock's and never the VFS's cross-mount `EXDEV`.
10090    fn work_and_outside() -> (tempfile::TempDir, PathBuf, PathBuf) {
10091        let base = tempfile::tempdir().unwrap();
10092        let work = base.path().join("work");
10093        let outside = base.path().join("outside");
10094        std::fs::create_dir(&work).unwrap();
10095        std::fs::create_dir(&outside).unwrap();
10096        (base, work, outside)
10097    }
10098
10099    /// With `Refer` granted on the workdir, `mv` between two of its folders is a real `rename(2)`
10100    /// (the inode survives), and `ln` and a directory reparent across folders both succeed.
10101    #[test]
10102    fn kernel_tier_moves_files_between_workdir_folders() {
10103        let test = "kernel_tier_moves_files_between_workdir_folders";
10104        let policy = coreutils_policy(&[]);
10105        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 3) else {
10106            return;
10107        };
10108        assert_eq!(enforcement.tier, EnforcementTier::KernelFull);
10109        assert_eq!(enforcement.landlock_abi, Some(3));
10110
10111        let temp = tempfile::tempdir().unwrap();
10112        let result = run_bash_in(
10113            temp.path(),
10114            r#"set -e
10115mkdir a b
10116echo payload > a/x
10117before=$(stat -c %i a/x)
10118mv a/x b/x
10119after=$(stat -c %i b/x)
10120echo "mv a/x b/x: inode $before -> $after"
10121[ "$before" = "$after" ] || { echo "inode changed, mv copied instead of renaming" >&2; exit 10; }
10122ln b/x a/y
10123[ "$(cat a/y)" = payload ] || exit 11
10124echo "ln b/x a/y: ok"
10125mkdir a/d
10126mv a/d b/d
10127[ -d b/d ] && [ ! -e a/d ] || exit 12
10128echo "mv a/d b/d: ok"
10129"#,
10130            &policy,
10131            &enforcement,
10132        );
10133
10134        assert_eq!(
10135            result.exit_code, 0,
10136            "a move, a hard link and a directory reparent between workdir folders must all \
10137             succeed under Refer"
10138        );
10139    }
10140
10141    /// On a Landlock tier the session's home folder is `$HOME` and carries the work folder's
10142    /// rights: a command writes, renames within and removes files under it. The records directory
10143    /// around it stays read-only: a command cannot write `out/result.txt`, create `children/x`, or
10144    /// move the home folder itself aside. Both directories sit under the host's `/tmp` here, so
10145    /// where `/tmp` is mapped this also proves the home folder is re-exposed writable over the
10146    /// read-only records directory.
10147    #[test]
10148    fn home_folder_is_writable_like_tmp_and_the_records_around_it_are_not() {
10149        let test = "home_folder_is_writable_like_tmp_and_the_records_around_it_are_not";
10150        let policy = coreutils_policy(&["rm", "rmdir"]);
10151        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
10152            return;
10153        };
10154        eprintln!(
10155            "{test}: tier {:?}, Landlock ruleset ABI {:?} (kernel {:?}), /tmp {:?}",
10156            enforcement.tier,
10157            enforcement.landlock_abi,
10158            HostProbe::probe().landlock_kernel_abi(),
10159            enforcement.installed_temp_mapping()
10160        );
10161        let records = enforcement.records_root().unwrap().clone();
10162        records
10163            .create_dir_all(crate::records_dir::RESULTS_SUBDIR, 0o700)
10164            .unwrap();
10165        let home = enforcement.home_dir_path().unwrap().to_path_buf();
10166
10167        let workdir = tempfile::tempdir().unwrap();
10168        let result = run_bash_in(
10169            workdir.path(),
10170            &format!(
10171                r#"[ "$HOME" = '{home}' ] && echo home-is-the-folder
10172echo a > "$HOME/a" && mv "$HOME/a" "$HOME/b" && mkdir "$HOME/d" && mv "$HOME/b" "$HOME/d/c" \
10173  && rm "$HOME/d/c" && rmdir "$HOME/d" && echo home-writable
10174if echo x > "$MURMUR_RECORDS_DIR/out/result.txt"; then echo result-written; else echo result-refused; fi
10175if mkdir -p "$MURMUR_RECORDS_DIR/children/x"; then echo child-created; else echo child-refused; fi
10176if mv "$HOME" "$HOME.moved"; then echo home-moved; else echo home-move-refused; fi
10177"#,
10178                home = home.display()
10179            ),
10180            &policy,
10181            &enforcement,
10182        );
10183
10184        assert_eq!(
10185            result.stdout,
10186            "home-is-the-folder\nhome-writable\nresult-refused\nchild-refused\nhome-move-refused\n",
10187            "stderr: {}",
10188            result.stderr
10189        );
10190        assert!(home.is_dir());
10191        assert_eq!(std::fs::read_dir(&home).unwrap().count(), 0);
10192        assert!(!records.host_path("out/result.txt").exists());
10193        assert!(!records.host_path("children").exists());
10194        assert!(!home.with_extension("moved").exists());
10195        assert_eq!(std::fs::read_dir(workdir.path()).unwrap().count(), 0);
10196    }
10197
10198    /// A delegated child's records, laid out where staging puts them — its records group,
10199    /// `records_group(<records dir>/children)`, beside the session's own directories — are out of
10200    /// reach of a parent's command: it can neither list the child's records directory nor read its
10201    /// result, while the runtime directory reads in the same command. The parent's runtime reads
10202    /// the child's answer and hands it over instead.
10203    ///
10204    /// The refusal is `Permission denied` where Landlock refuses the path, and `No such file or
10205    /// directory` where the command's private `/tmp` does not re-expose it.
10206    #[test]
10207    fn children_records_are_not_reachable_by_a_parents_commands() {
10208        let test = "children_records_are_not_reachable_by_a_parents_commands";
10209        let policy = coreutils_policy(&["ls"]);
10210        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
10211            return;
10212        };
10213        let runtime_dir = enforcement.runtime_dir_path().unwrap().to_path_buf();
10214        std::fs::write(runtime_dir.join("MURMUR.md"), "RUNTIME-DIR-CONTROL\n").unwrap();
10215        let base = tempfile::tempdir().unwrap();
10216        let child_records = base
10217            .path()
10218            .join("records/children/worker-0123456789abcdef/ses_child");
10219        let result_file = child_records.join("out/result.txt");
10220        std::fs::create_dir_all(result_file.parent().unwrap()).unwrap();
10221        std::fs::write(&result_file, "CHILD-ANSWER-7Q2W").unwrap();
10222
10223        let workdir = tempfile::tempdir().unwrap();
10224        let result = run_bash_in(
10225            workdir.path(),
10226            &format!(
10227                r#"cat "$MURMUR_RUNTIME_DIR/MURMUR.md"
10228if ls '{dir}'; then echo listed; else echo list-refused; fi
10229if cat '{file}'; then echo read; else echo read-refused; fi
10230"#,
10231                dir = child_records.display(),
10232                file = result_file.display(),
10233            ),
10234            &policy,
10235            &enforcement,
10236        );
10237
10238        assert_eq!(
10239            result.stdout, "RUNTIME-DIR-CONTROL\nlist-refused\nread-refused\n",
10240            "stderr: {}",
10241            result.stderr
10242        );
10243        assert!(
10244            !result.stdout.contains("CHILD-ANSWER") && !result.stderr.contains("CHILD-ANSWER"),
10245            "the child's answer reached the parent's command: {result:?}"
10246        );
10247        for line in result.stderr.lines() {
10248            assert!(
10249                line.contains("Permission denied") || line.contains("No such file or directory"),
10250                "an unexpected refusal: {}",
10251                result.stderr
10252            );
10253        }
10254        assert_eq!(result.stderr.lines().count(), 2, "{}", result.stderr);
10255    }
10256
10257    /// The ruleset a 5.13–5.18 kernel gets, forced through the same `landlock_abi` field the probe
10258    /// fills: the capsule still launches, `ln` across folders is `EXDEV`, and `mv` survives only
10259    /// by copying.
10260    #[test]
10261    fn kernel_tier_at_abi_v1_refuses_cross_folder_links_with_exdev() {
10262        let test = "kernel_tier_at_abi_v1_refuses_cross_folder_links_with_exdev";
10263        let policy = coreutils_policy(&[]);
10264        let Some(mut enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
10265            return;
10266        };
10267        enforcement.landlock_abi = Some(1);
10268
10269        let temp = tempfile::tempdir().unwrap();
10270        let result = run_bash_in(
10271            temp.path(),
10272            r#"mkdir a b || exit 20
10273echo payload > a/x || exit 21
10274before=$(stat -c %i a/x)
10275mv a/x b/x || exit 22
10276[ -f b/x ] && [ ! -e a/x ] || exit 23
10277after=$(stat -c %i b/x)
10278echo "mv a/x b/x: inode $before -> $after"
10279[ "$before" != "$after" ] || exit 24
10280if ln b/x a/y; then exit 25; fi
10281exit 42
10282"#,
10283            &policy,
10284            &enforcement,
10285        );
10286
10287        assert_eq!(
10288            result.exit_code, 42,
10289            "the capsule must launch at ABI v1 and run the script to its own exit code: mv falls \
10290             back to copy-and-delete (a new inode) and ln fails"
10291        );
10292        assert!(
10293            result.stderr.contains("Invalid cross-device link"),
10294            "ln across workdir folders at ABI v1 must fail with EXDEV; stderr: {}",
10295            result.stderr
10296        );
10297    }
10298
10299    /// Nothing is moved or linked out of the workdir into a sibling folder on the same filesystem.
10300    #[test]
10301    fn kernel_tier_refuses_rename_out_of_the_workdir() {
10302        let test = "kernel_tier_refuses_rename_out_of_the_workdir";
10303        let policy = coreutils_policy(&[]);
10304        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 2) else {
10305            return;
10306        };
10307
10308        let (_base, work, outside) = work_and_outside();
10309        let script = format!(
10310            r#"echo payload > x || exit 20
10311if mv x '{outside}/x'; then exit 21; fi
10312if ln x '{outside}/x2'; then exit 22; fi
10313exit 0
10314"#,
10315            outside = outside.display()
10316        );
10317        let result = run_bash_in(&work, &script, &policy, &enforcement);
10318
10319        assert_eq!(
10320            result.exit_code, 0,
10321            "mv and ln out of the workdir must both fail"
10322        );
10323        assert!(
10324            work.join("x").exists(),
10325            "the source must stay in the workdir"
10326        );
10327        assert!(!outside.join("x").exists(), "nothing may be moved out");
10328        assert!(!outside.join("x2").exists(), "nothing may be linked out");
10329    }
10330
10331    /// Nothing is moved or linked into the workdir from a sibling folder on the same filesystem.
10332    #[test]
10333    fn kernel_tier_refuses_rename_into_the_workdir() {
10334        let test = "kernel_tier_refuses_rename_into_the_workdir";
10335        let policy = coreutils_policy(&[]);
10336        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 2) else {
10337            return;
10338        };
10339
10340        let (_base, work, outside) = work_and_outside();
10341        std::fs::write(outside.join("y"), "original\n").unwrap();
10342        let script = format!(
10343            r#"if mv '{outside}/y' ./y; then exit 21; fi
10344if ln '{outside}/y' ./y2; then exit 22; fi
10345exit 0
10346"#,
10347            outside = outside.display()
10348        );
10349        let result = run_bash_in(&work, &script, &policy, &enforcement);
10350
10351        assert_eq!(
10352            result.exit_code, 0,
10353            "mv and ln into the workdir must both fail"
10354        );
10355        assert_eq!(
10356            std::fs::read_to_string(outside.join("y")).unwrap(),
10357            "original\n",
10358            "the outside file must be untouched"
10359        );
10360        assert!(!work.join("y").exists(), "nothing may be moved in");
10361        assert!(!work.join("y2").exists(), "nothing may be linked in");
10362    }
10363
10364    /// A by-path `truncate(2)` on a host file outside the workdir: refused once the ruleset
10365    /// handles `Truncate` (ABI v3), and not mediated at all at ABI v1 — the gap v3 closes.
10366    #[test]
10367    fn kernel_tier_truncate_outside_the_workdir_is_refused() {
10368        use std::os::unix::fs::PermissionsExt;
10369
10370        let test = "kernel_tier_truncate_outside_the_workdir_is_refused";
10371        let policy = coreutils_policy(&["perl"]);
10372        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 3) else {
10373            return;
10374        };
10375
10376        let (_base, work, outside) = work_and_outside();
10377        let target = outside.join("t");
10378        std::fs::write(&target, "keep\n").unwrap();
10379        std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644)).unwrap();
10380        let script = format!(
10381            r#"perl -e 'truncate($ARGV[0], 0) or die "$!\n"' '{}'"#,
10382            target.display()
10383        );
10384
10385        let refused = run_bash_in(&work, &script, &policy, &enforcement);
10386        assert_ne!(
10387            refused.exit_code, 0,
10388            "truncate(2) outside the workdir must be refused at ABI 3"
10389        );
10390        assert!(
10391            refused.stderr.contains("Permission denied"),
10392            "the refusal must be EACCES; stderr: {}",
10393            refused.stderr
10394        );
10395        assert_eq!(std::fs::read_to_string(&target).unwrap(), "keep\n");
10396
10397        let mut at_v1 = enforcement.clone();
10398        at_v1.landlock_abi = Some(1);
10399        let unmediated = run_bash_in(&work, &script, &policy, &at_v1);
10400        assert_eq!(
10401            unmediated.exit_code, 0,
10402            "at ABI v1 truncate(2) is not mediated, which is the gap this test pins as closed above"
10403        );
10404        assert_eq!(std::fs::read_to_string(&target).unwrap(), "");
10405    }
10406
10407    /// Handling `Truncate` must not refuse what a capsule truncates legitimately: its own files,
10408    /// with `truncate` and with a `>` redirect over an existing file, and `/dev/null`.
10409    #[test]
10410    fn kernel_tier_truncate_inside_the_workdir_and_dev_null_still_work() {
10411        let test = "kernel_tier_truncate_inside_the_workdir_and_dev_null_still_work";
10412        let policy = coreutils_policy(&["truncate"]);
10413        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 3) else {
10414            return;
10415        };
10416
10417        let temp = tempfile::tempdir().unwrap();
10418        let result = run_bash_in(
10419            temp.path(),
10420            r#"set -e
10421echo hello > f && truncate -s 0 f && [ ! -s f ]
10422echo again > f && echo over > f && [ "$(cat f)" = over ]
10423: > g
10424echo discard > /dev/null
10425echo ok
10426"#,
10427            &policy,
10428            &enforcement,
10429        );
10430
10431        assert_eq!(
10432            result.exit_code, 0,
10433            "truncating inside the workdir and writing to /dev/null must keep working"
10434        );
10435    }
10436
10437    /// `uv venv` then `uv pip install <wheel>` in the workdir, offline: uv unpacks the wheel into
10438    /// a temporary folder in its cache and renames it into place. Passes at the probed ABI, and
10439    /// fails with `EXDEV` at ABI v1, so the pass is `Refer`'s.
10440    ///
10441    /// Ignored because it needs two host-side inputs a stock host does not have:
10442    /// `MURMUR_TEST_UV`, an absolute path to a `uv` binary, and `MURMUR_TEST_WHEEL`, an absolute
10443    /// path to `six-1.16.0-py2.py3-none-any.whl`, whose module and version the script checks after
10444    /// installing it. Run without them it
10445    /// panics naming the missing variable. `uv`, `python3` and its stdlib are granted by this
10446    /// test's policy only.
10447    #[test]
10448    #[ignore = "needs MURMUR_TEST_UV and MURMUR_TEST_WHEEL; see the doc comment"]
10449    fn kernel_tier_uv_installs_a_wheel_in_the_workdir() {
10450        let test = "kernel_tier_uv_installs_a_wheel_in_the_workdir";
10451        let input = |name: &str| {
10452            let value = std::env::var(name)
10453                .unwrap_or_else(|_| panic!("{test} needs {name} set to an absolute path"));
10454            let path = PathBuf::from(value);
10455            assert!(
10456                path.is_absolute() && path.is_file(),
10457                "{name}={} is not a file",
10458                path.display()
10459            );
10460            path
10461        };
10462        let uv = input("MURMUR_TEST_UV");
10463        let wheel = input("MURMUR_TEST_WHEEL");
10464        let python = std::fs::canonicalize("/usr/bin/python3").expect("host python3");
10465        let stdlib = std::process::Command::new(&python)
10466            .args([
10467                "-I",
10468                "-c",
10469                "import sysconfig; print(sysconfig.get_paths()['stdlib'])",
10470            ])
10471            .output()
10472            .expect("host python3 runs");
10473        let stdlib = String::from_utf8(stdlib.stdout).unwrap().trim().to_string();
10474
10475        let policy = CapabilityPolicy {
10476            shell_allow: vec![
10477                "bash".to_string(),
10478                uv.display().to_string(),
10479                python.display().to_string(),
10480            ],
10481            shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
10482                binary: "python3".to_string(),
10483                dirs: vec![murmur_artifact::InterpreterRuntimeDir {
10484                    path: stdlib,
10485                    list_dir: true,
10486                }],
10487            }],
10488            ..CapabilityPolicy::default()
10489        };
10490        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 2) else {
10491            return;
10492        };
10493
10494        let wheel_name = wheel.file_name().unwrap().to_str().unwrap().to_string();
10495        let script = format!(
10496            r#"set -e
10497export UV_CACHE_DIR="$PWD/.uv-cache" UV_PYTHON_DOWNLOADS=never
10498'{uv}' venv --python '{python}' .venv
10499'{uv}' pip install --offline --no-index --python .venv/bin/python './{wheel_name}'
10500.venv/bin/python -c 'import six; print(six.__version__)'
10501"#,
10502            uv = uv.display(),
10503            python = python.display(),
10504        );
10505        let install = |enforcement: &ShellEnforcement| {
10506            let temp = tempfile::tempdir().unwrap();
10507            std::fs::copy(&wheel, temp.path().join(&wheel_name)).unwrap();
10508            run_bash_in(temp.path(), &script, &policy, enforcement)
10509        };
10510
10511        let at_probed = install(&enforcement);
10512        assert_eq!(
10513            at_probed.exit_code, 0,
10514            "uv must install the wheel at ABI {:?}",
10515            enforcement.landlock_abi
10516        );
10517        assert!(at_probed.stdout.contains("1.16.0"), "{}", at_probed.stdout);
10518
10519        let mut at_v1 = enforcement.clone();
10520        at_v1.landlock_abi = Some(1);
10521        let refused = install(&at_v1);
10522        let output = format!("{}{}", refused.stdout, refused.stderr);
10523        assert_ne!(refused.exit_code, 0, "at ABI v1 the install must fail");
10524        assert!(
10525            output.contains("os error 18") || output.contains("Invalid cross-device link"),
10526            "at ABI v1 the install must fail with EXDEV: {output}"
10527        );
10528    }
10529
10530    // The two tests below exercise the derived Landlock grants. A green run is not evidence they
10531    // work: below `KernelFull` the weaker `KernelSeccompOnly` path runs instead, and on non-Linux
10532    // no Landlock domain is installed at all. Both tests print a skip line and return when the
10533    // host is not `KernelFull`.
10534
10535    #[test]
10536    fn kernel_full_runs_nontrivial_shell_allowlist_but_a_pass_here_does_not_prove_the_landlock_fix()
10537    {
10538        if crate::network_namespace::skip_without_egress_namespace("kernel_full_runs_nontrivial_shell_allowlist_but_a_pass_here_does_not_prove_the_landlock_fix") {
10539            return;
10540        }
10541        let tier = detect_enforcement_tier();
10542        if tier != EnforcementTier::KernelFull {
10543            eprintln!(
10544                "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
10545                 kernel_full_runs_nontrivial_shell_allowlist_... requires \
10546                 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
10547                 This run does NOT execute the derived-grant Landlock code path at all, so a \
10548                 green result here is not evidence the workdir-scope exec bug is fixed — only a \
10549                 real KernelFull Linux host can prove that."
10550            );
10551            return;
10552        }
10553
10554        let temp = tempfile::tempdir().unwrap();
10555        // A non-trivial allowlist: an interpreter (`bash`) plus a second, genuinely nested,
10556        // dynamically-linked binary (`cat`). Both are guaranteed present on the target dev/CI
10557        // Linux hosts and both live outside the workdir, so this only succeeds if the derived
10558        // Landlock grants cover each binary, its ELF interpreter, and its shared libraries.
10559        let policy = CapabilityPolicy {
10560            shell_allow: vec!["bash".to_string(), "cat".to_string()],
10561            ..CapabilityPolicy::default()
10562        };
10563        let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
10564        let enforcement = ShellEnforcement {
10565            tier,
10566            network_allow_ips: Vec::new(),
10567            unix_sockets_allowed: policy.unix_sockets_allowed,
10568            landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
10569                &exec_allow_paths,
10570            )),
10571            ..host_bounding_base()
10572        };
10573
10574        // `echo` is a bash builtin; piping it into `cat` forces bash to fork+exec `cat`, a real
10575        // nested, dynamically-linked exec — the case the whole fix exists for.
10576        let result = crate::shell::execute_shell(
10577            "bash",
10578            &["-c", "echo landlock-grant-ok | cat"],
10579            &[],
10580            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
10581            &policy,
10582            &enforcement,
10583        )
10584        .expect("execute_shell should return Ok, not Err");
10585
10586        assert_eq!(
10587            result.exit_code, 0,
10588            "bash + cat are both allowlisted and dynamically linked; the derived Landlock grants \
10589             must let each exec and dynamic-link outside the workdir (stderr: {})",
10590            result.stderr
10591        );
10592        assert!(
10593            result.stdout.contains("landlock-grant-ok"),
10594            "the nested `cat` must actually run and pass its stdin through (stdout: {}, stderr: {})",
10595            result.stdout,
10596            result.stderr
10597        );
10598    }
10599
10600    #[test]
10601    fn kernel_full_denies_write_outside_workdir_but_a_pass_here_does_not_prove_the_landlock_fix() {
10602        if crate::network_namespace::skip_without_egress_namespace("kernel_full_denies_write_outside_workdir_but_a_pass_here_does_not_prove_the_landlock_fix") {
10603            return;
10604        }
10605        let tier = detect_enforcement_tier();
10606        if tier != EnforcementTier::KernelFull {
10607            eprintln!(
10608                "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
10609                 kernel_full_denies_write_outside_workdir_... requires \
10610                 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
10611                 This run does NOT execute the Landlock code path, so a green result here is not \
10612                 evidence writes outside the workdir are actually denied — only a real KernelFull \
10613                 Linux host can prove that."
10614            );
10615            return;
10616        }
10617
10618        let workdir = tempfile::tempdir().unwrap();
10619        // A target OUTSIDE the workdir (a separate temp dir). Its parent exists, so the only
10620        // reason a write can fail is the Landlock scope — not a missing directory.
10621        let outside = tempfile::tempdir().unwrap();
10622        let target = outside.path().join("should-not-be-written.txt");
10623
10624        let policy = CapabilityPolicy {
10625            shell_allow: vec!["bash".to_string()],
10626            ..CapabilityPolicy::default()
10627        };
10628        let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
10629        let enforcement = ShellEnforcement {
10630            tier,
10631            network_allow_ips: Vec::new(),
10632            unix_sockets_allowed: policy.unix_sockets_allowed,
10633            landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
10634                &exec_allow_paths,
10635            )),
10636            ..host_bounding_base()
10637        };
10638
10639        // `echo >file` uses only a bash builtin + an open-for-write of an outside path — the
10640        // write itself is what Landlock must refuse. The derived read+execute grants never widen
10641        // to write, so nothing outside the workdir (grant paths included) is writable.
10642        let script = format!("echo pwned > '{}'", target.display());
10643        let result = crate::shell::execute_shell(
10644            "bash",
10645            &["-c", &script],
10646            &[],
10647            &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
10648            &policy,
10649            &enforcement,
10650        )
10651        .expect("execute_shell should return Ok with a nonzero exit code, not Err");
10652
10653        assert!(
10654            !target.exists(),
10655            "a write to a path outside the Landlock-scoped workdir must be denied under KernelFull"
10656        );
10657        assert_ne!(
10658            result.exit_code, 0,
10659            "bash's redirection to an outside path must fail (Landlock denies the open-for-write)"
10660        );
10661    }
10662
10663    /// Builds a KernelFull enforcement for `policy`, combining the derived `DT_NEEDED`-closure
10664    /// file grants with the policy's `interpreter_runtime` directory grants — exactly what
10665    /// `ShellEnforcement::resolve` does, spelled out so the test controls the tier explicitly.
10666    fn kernel_full_enforcement(
10667        tier: EnforcementTier,
10668        policy: &CapabilityPolicy,
10669    ) -> ShellEnforcement {
10670        let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
10671        let mut landlock_grants =
10672            LandlockGrant::non_listable_files(resolve_landlock_grants(&exec_allow_paths));
10673        landlock_grants.extend(resolve_interpreter_runtime_grants(
10674            &policy.shell_interpreter_runtime,
10675        ));
10676        ShellEnforcement {
10677            tier,
10678            network_allow_ips: Vec::new(),
10679            unix_sockets_allowed: policy.unix_sockets_allowed,
10680            landlock_grants,
10681            ..host_bounding_base()
10682        }
10683    }
10684
10685    /// A real sealed run: the composed root's `/tmp` is the session's temporary folder in its
10686    /// records directory, `$HOME` and the synthetic `/etc/passwd`'s home are both its home folder,
10687    /// writable inside the root (`cd ~ && touch f`), the work folder gains nothing, and a delegated
10688    /// child's result beside the session's directories is not in the root at all.
10689    ///
10690    /// This drives a composed root end to end, so it needs a host that can actually reach
10691    /// `KernelSealed` and skips loudly on one that cannot; the parent-side halves are covered
10692    /// unconditionally by the `open_landlock_fds` tests above. `mur doctor` reports the same host
10693    /// answer this checks.
10694    #[test]
10695    fn home_folder_and_tmp_in_the_records_dir_on_a_sealed_root() {
10696        if crate::network_namespace::skip_without_egress_namespace(
10697            "home_folder_and_tmp_in_the_records_dir_on_a_sealed_root",
10698        ) {
10699            return;
10700        }
10701        let probe = HostProbe::probe();
10702        if probe.tier() != EnforcementTier::KernelSealed {
10703            eprintln!(
10704                "SKIP — PROVES NOTHING ABOUT THE SEALED ROOT ON THIS HOST: \
10705                 home_folder_and_tmp_in_the_records_dir_on_a_sealed_root needs a host that reaches \
10706                 KernelSealed (unprivileged user namespaces plus a usable Landlock ABI); detected \
10707                 {:?}. No composed root is built in this run.",
10708                probe.tier()
10709            );
10710            return;
10711        }
10712
10713        let accessible = tempfile::tempdir().unwrap();
10714        let policy = CapabilityPolicy {
10715            shell_allow: vec![
10716                "bash".to_string(),
10717                "cat".to_string(),
10718                "id".to_string(),
10719                "touch".to_string(),
10720                "getent".to_string(),
10721                "cut".to_string(),
10722            ],
10723            ..CapabilityPolicy::default()
10724        };
10725        let runtime = tempfile::tempdir().unwrap();
10726        let enforcement = ShellEnforcement::resolve_declared(
10727            &policy,
10728            murmur_artifact::ContainmentClass::Sealed,
10729            probe.clone(),
10730        )
10731        .expect("a sealed-capable host resolves sealed enforcement")
10732        .with_runtime_dir(test_runtime_dir(runtime.path()), None);
10733        // A delegated child's result, beside the session's own directories as a child that has
10734        // staged leaves it, and out of the composed root's reach.
10735        let child_result = runtime
10736            .path()
10737            .join("children/worker-0123456789abcdef/out/result.txt");
10738        std::fs::create_dir_all(child_result.parent().unwrap()).unwrap();
10739        std::fs::write(&child_result, "the child's answer").unwrap();
10740        assert_eq!(enforcement.tier, EnforcementTier::KernelSealed);
10741        eprintln!(
10742            "home_folder_and_tmp_in_the_records_dir_on_a_sealed_root: tier {:?}, Landlock \
10743             ruleset ABI {:?} (kernel {:?})",
10744            enforcement.tier,
10745            enforcement.landlock_abi,
10746            probe.landlock_kernel_abi()
10747        );
10748        let probe_name = format!("sealed-probe-{}", std::process::id());
10749
10750        let result = crate::shell::execute_shell(
10751            "bash",
10752            &[
10753                "-c",
10754                &format!(
10755                    "echo t > /tmp/{probe_name} && cat /tmp/{probe_name} && id -un && \
10756                     printf '%s\\n' \"$TMPDIR\" && printf '%s\\n' \"$HOME\" && \
10757                     getent passwd \"$(id -u)\" | cut -d: -f6 && cd ~ && touch f && echo touched && \
10758                     {{ cat '{child}' 2>&1 || true; }}",
10759                    child = child_result.display()
10760                ),
10761            ],
10762            &[],
10763            &crate::workdir_fs::WorkdirRoot::open(accessible.path()).unwrap(),
10764            &policy,
10765            &enforcement,
10766        )
10767        .expect("execute_shell returns Ok even when the command itself fails");
10768
10769        assert_eq!(
10770            result.exit_code, 0,
10771            "stdout: {} stderr: {}",
10772            result.stdout, result.stderr
10773        );
10774        let mut lines = result.stdout.lines();
10775        assert_eq!(
10776            lines.next(),
10777            Some("t"),
10778            "the capsule's /tmp must be writable"
10779        );
10780        assert!(
10781            lines.next().is_some_and(|name| !name.is_empty()),
10782            "`id -un` resolves only through the synthetic /etc/passwd the composed root binds: \
10783             {}",
10784            result.stdout
10785        );
10786        assert_eq!(
10787            lines.next(),
10788            Some("/tmp"),
10789            "TMPDIR names /tmp inside the root"
10790        );
10791        let home = enforcement.home_dir_path().unwrap().to_str().unwrap();
10792        assert_eq!(lines.next(), Some(home), "$HOME is the home folder");
10793        assert_eq!(lines.next(), Some(home), "passwd's home is the home folder");
10794        assert_eq!(
10795            lines.next(),
10796            Some("touched"),
10797            "~ is writable inside the root"
10798        );
10799        let child_read = lines.next().unwrap_or_default();
10800        assert!(
10801            child_read.contains("No such file or directory"),
10802            "a child's result is not in the composed root: {}",
10803            result.stdout
10804        );
10805        assert!(
10806            !result.stdout.contains("the child's answer"),
10807            "{}",
10808            result.stdout
10809        );
10810        assert!(Path::new(home).join("f").is_file());
10811
10812        let folder = enforcement.temp_dir_path().unwrap();
10813        assert_eq!(
10814            std::fs::read_to_string(folder.join(&probe_name)).unwrap(),
10815            "t\n",
10816            "the bytes written to /tmp land in the session's temporary folder"
10817        );
10818        assert!(
10819            !Path::new("/tmp").join(&probe_name).exists(),
10820            "nothing reaches the host's /tmp"
10821        );
10822        assert!(runtime
10823            .path()
10824            .join(crate::runtime_dir::SEALED_ETC_SUBDIR)
10825            .join("passwd")
10826            .is_file());
10827        assert_eq!(
10828            std::fs::read_dir(accessible.path()).unwrap().count(),
10829            0,
10830            "enabling sealed must add nothing to the work folder"
10831        );
10832    }
10833
10834    // As above: a green run proves nothing below `KernelFull`. Both tests print a skip line and
10835    // return when the host is not `KernelFull`.
10836
10837    #[test]
10838    fn kernel_full_interpreter_runtime_list_dir_false_opens_file_but_denies_listing() {
10839        if crate::network_namespace::skip_without_egress_namespace(
10840            "kernel_full_interpreter_runtime_list_dir_false_opens_file_but_denies_listing",
10841        ) {
10842            return;
10843        }
10844        let tier = detect_enforcement_tier();
10845        if tier != EnforcementTier::KernelFull {
10846            eprintln!(
10847                "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
10848                 kernel_full_interpreter_runtime_list_dir_false_... requires \
10849                 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
10850                 This run does NOT execute the interpreter_runtime Landlock code path, so a green \
10851                 result here is not evidence list_dir:false behaves correctly — only a real \
10852                 KernelFull Linux host can prove that."
10853            );
10854            return;
10855        }
10856
10857        let workdir = tempfile::tempdir().unwrap();
10858        // A "stdlib"-shaped directory OUTSIDE the workdir, holding one known file.
10859        let stdlib = tempfile::tempdir().unwrap();
10860        let module = stdlib.path().join("mod.py");
10861        std::fs::write(&module, "x = 1\n").unwrap();
10862
10863        let policy = CapabilityPolicy {
10864            shell_allow: vec!["bash".to_string(), "cat".to_string(), "ls".to_string()],
10865            shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
10866                binary: "bash".to_string(),
10867                dirs: vec![murmur_artifact::InterpreterRuntimeDir {
10868                    path: stdlib.path().to_string_lossy().into_owned(),
10869                    list_dir: false,
10870                }],
10871            }],
10872            ..CapabilityPolicy::default()
10873        };
10874        let enforcement = kernel_full_enforcement(tier, &policy);
10875
10876        // Opening the file by its exact known name must succeed — Landlock's read rights apply to
10877        // the subtree beneath the granted directory.
10878        let read = crate::shell::execute_shell(
10879            "bash",
10880            &["-c", &format!("cat '{}'", module.display())],
10881            &[],
10882            &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
10883            &policy,
10884            &enforcement,
10885        )
10886        .expect("execute_shell should return Ok");
10887        assert_eq!(
10888            read.exit_code, 0,
10889            "list_dir:false must still permit opening a known file inside the grant (stderr: {})",
10890            read.stderr
10891        );
10892        assert!(read.stdout.contains("x = 1"), "stdout: {}", read.stdout);
10893
10894        // Listing the directory's own entries must fail — ReadDir was not granted.
10895        let list = crate::shell::execute_shell(
10896            "bash",
10897            &["-c", &format!("ls '{}'", stdlib.path().display())],
10898            &[],
10899            &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
10900            &policy,
10901            &enforcement,
10902        )
10903        .expect("execute_shell should return Ok with a nonzero exit code");
10904        assert_ne!(
10905            list.exit_code, 0,
10906            "list_dir:false must deny enumerating the granted directory's own entries"
10907        );
10908    }
10909
10910    #[test]
10911    fn kernel_full_interpreter_runtime_list_dir_true_lists_dir_but_not_its_parent() {
10912        if crate::network_namespace::skip_without_egress_namespace(
10913            "kernel_full_interpreter_runtime_list_dir_true_lists_dir_but_not_its_parent",
10914        ) {
10915            return;
10916        }
10917        let tier = detect_enforcement_tier();
10918        if tier != EnforcementTier::KernelFull {
10919            eprintln!(
10920                "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
10921                 kernel_full_interpreter_runtime_list_dir_true_... requires \
10922                 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
10923                 This run does NOT execute the interpreter_runtime Landlock code path, so a green \
10924                 result here is not evidence list_dir:true (and its non-widening to the parent) \
10925                 behaves correctly — only a real KernelFull Linux host can prove that."
10926            );
10927            return;
10928        }
10929
10930        let workdir = tempfile::tempdir().unwrap();
10931        // parent/ (NOT granted) contains stdlib/ (granted list_dir:true) contains one file.
10932        let parent = tempfile::tempdir().unwrap();
10933        let stdlib = parent.path().join("stdlib");
10934        std::fs::create_dir(&stdlib).unwrap();
10935        std::fs::write(stdlib.join("mod.py"), "x = 1\n").unwrap();
10936
10937        let policy = CapabilityPolicy {
10938            shell_allow: vec!["bash".to_string(), "ls".to_string()],
10939            shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
10940                binary: "bash".to_string(),
10941                dirs: vec![murmur_artifact::InterpreterRuntimeDir {
10942                    path: stdlib.to_string_lossy().into_owned(),
10943                    list_dir: true,
10944                }],
10945            }],
10946            ..CapabilityPolicy::default()
10947        };
10948        let enforcement = kernel_full_enforcement(tier, &policy);
10949
10950        // Listing the granted directory must succeed.
10951        let list = crate::shell::execute_shell(
10952            "bash",
10953            &["-c", &format!("ls '{}'", stdlib.display())],
10954            &[],
10955            &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
10956            &policy,
10957            &enforcement,
10958        )
10959        .expect("execute_shell should return Ok");
10960        assert_eq!(
10961            list.exit_code, 0,
10962            "list_dir:true must permit enumerating the granted directory (stderr: {})",
10963            list.stderr
10964        );
10965        assert!(list.stdout.contains("mod.py"), "stdout: {}", list.stdout);
10966
10967        // Listing the PARENT (never named in any grant) must fail — naming one subdirectory does
10968        // not widen enumerability to its ancestors. This is the `/usr/lib` ceiling case.
10969        let list_parent = crate::shell::execute_shell(
10970            "bash",
10971            &["-c", &format!("ls '{}'", parent.path().display())],
10972            &[],
10973            &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
10974            &policy,
10975            &enforcement,
10976        )
10977        .expect("execute_shell should return Ok with a nonzero exit code");
10978        assert_ne!(
10979            list_parent.exit_code, 0,
10980            "a grant on stdlib/ must not make its (ungranted) parent enumerable"
10981        );
10982    }
10983
10984    /// The `executable` axis, asserted against a real Landlock kernel.
10985    ///
10986    /// This is the property the `sealed` runtime-tree grant depends on: a whole tree can be made
10987    /// readable and enumerable without becoming a place to run programs from. It is asserted here
10988    /// on the plain Landlock path (`KernelFull`) because the access shape is tier-independent —
10989    /// `apply_landlock_scope` builds the same rule either way — and because a `KernelSealed` host
10990    /// would have to be driven through a composed root, which no unit test can reach.
10991    ///
10992    /// Runs on any host with a usable Landlock ABI, `KernelSealed` ones included: the tier is
10993    /// pinned in the enforcement literal, so only the ABI has to be real. Skips loudly elsewhere.
10994    #[test]
10995    fn kernel_full_non_executable_grant_lists_its_tree_but_refuses_to_run_from_it() {
10996        if crate::network_namespace::skip_without_egress_namespace(
10997            "kernel_full_non_executable_grant_lists_its_tree_but_refuses_to_run_from_it",
10998        ) {
10999            return;
11000        }
11001        let host_tier = detect_enforcement_tier();
11002        if !matches!(
11003            host_tier,
11004            EnforcementTier::KernelFull | EnforcementTier::KernelSealed
11005        ) {
11006            eprintln!(
11007                "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
11008                 kernel_full_non_executable_grant_... needs a usable Landlock ABI (kernel 5.13+); \
11009                 detected {host_tier:?}. This run does NOT install a Landlock domain, so a green \
11010                 result is not evidence that withholding Execute denies execve."
11011            );
11012            return;
11013        }
11014
11015        let workdir = tempfile::tempdir().unwrap();
11016        // A runtime-tree stand-in: one readable file plus one runnable binary, outside the workdir.
11017        let tree = tempfile::tempdir().unwrap();
11018        std::fs::write(tree.path().join("entry.txt"), "x\n").unwrap();
11019        let runme = tree.path().join("runme");
11020        std::fs::copy("/bin/echo", &runme).unwrap();
11021
11022        let policy = CapabilityPolicy {
11023            shell_allow: vec!["bash".to_string(), "ls".to_string()],
11024            ..CapabilityPolicy::default()
11025        };
11026        let with_tree_grant = |executable: bool| {
11027            let mut enforcement = kernel_full_enforcement(EnforcementTier::KernelFull, &policy);
11028            enforcement.landlock_grants.push(LandlockGrant {
11029                path: tree.path().to_path_buf(),
11030                list_dir: true,
11031                executable,
11032                pinned: None,
11033            });
11034            enforcement
11035        };
11036        let run = |enforcement: &ShellEnforcement, command: String| {
11037            crate::shell::execute_shell(
11038                "bash",
11039                &["-c", &command],
11040                &[],
11041                &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
11042                &policy,
11043                enforcement,
11044            )
11045            .expect("execute_shell should return Ok even when the command itself fails")
11046        };
11047
11048        let denied = with_tree_grant(false);
11049        let list = run(&denied, format!("ls '{}'", tree.path().display()));
11050        assert_eq!(
11051            list.exit_code, 0,
11052            "a non-executable grant must still be enumerable — that is its entire purpose \
11053             (stderr: {})",
11054            list.stderr
11055        );
11056        assert!(list.stdout.contains("entry.txt"), "stdout: {}", list.stdout);
11057
11058        let exec_denied = run(&denied, format!("'{}' ran", runme.display()));
11059        assert_ne!(
11060            exec_denied.exit_code, 0,
11061            "withholding Execute must deny execve inside the granted tree; a program ran instead \
11062             (stdout: {})",
11063            exec_denied.stdout
11064        );
11065        assert!(
11066            !exec_denied.stdout.contains("ran"),
11067            "the binary produced output, so it executed: {}",
11068            exec_denied.stdout
11069        );
11070
11071        // The control: the identical grant with `executable: true` does run it. Without this the
11072        // assertion above could be passing for an unrelated reason (a bad path, a missing library).
11073        let allowed = with_tree_grant(true);
11074        let exec_allowed = run(&allowed, format!("'{}' ran", runme.display()));
11075        assert_eq!(
11076            exec_allowed.exit_code, 0,
11077            "the same tree granted Execute must run (stderr: {})",
11078            exec_allowed.stderr
11079        );
11080        assert!(
11081            exec_allowed.stdout.contains("ran"),
11082            "stdout: {}",
11083            exec_allowed.stdout
11084        );
11085    }
11086
11087    /// The access shape most of `SEALED_ETC_PATHS` needs: a non-executable grant on a *regular
11088    /// file* (`/etc/ssl/certs/ca-certificates.crt`'s stand-in) must install and be readable.
11089    ///
11090    /// Two kernel facts are pinned here. First, `ReadFile` alone —
11091    /// `apply_landlock_scope`'s `(false, false)` arm — is enough to `open()` the file. Second,
11092    /// `landlock_append_fs_rule` rejects `ReadDir` on a non-directory with `EINVAL`, and the rule
11093    /// loop is fail-closed, so a `list_dir: true` *claim* on a file would refuse the whole launch
11094    /// if `open_landlock_fds` did not narrow it against the fd it opened. The second half of this
11095    /// test is that narrowing: the same grant asserted `list_dir: true` must still launch and still
11096    /// read, not die in `pre_exec`.
11097    ///
11098    /// Runs on any host with a usable Landlock ABI; skips loudly elsewhere.
11099    #[test]
11100    fn kernel_full_non_executable_file_grant_is_readable_and_survives_a_bad_list_dir_claim() {
11101        if crate::network_namespace::skip_without_egress_namespace(
11102            "kernel_full_non_executable_file_grant_is_readable_and_survives_a_bad_list_dir_claim",
11103        ) {
11104            return;
11105        }
11106        let host_tier = detect_enforcement_tier();
11107        if !matches!(
11108            host_tier,
11109            EnforcementTier::KernelFull | EnforcementTier::KernelSealed
11110        ) {
11111            eprintln!(
11112                "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
11113                 kernel_full_non_executable_file_grant_... needs a usable Landlock ABI (kernel \
11114                 5.13+); detected {host_tier:?}. This run does NOT install a Landlock domain, so a \
11115                 green result is not evidence that a file grant reads, nor that a ReadDir claim on \
11116                 a file is narrowed instead of refusing the launch."
11117            );
11118            return;
11119        }
11120
11121        let workdir = tempfile::tempdir().unwrap();
11122        // A trust-store stand-in: a regular file outside the workdir, granted on its own.
11123        let store = tempfile::tempdir().unwrap();
11124        let bundle = store.path().join("ca-certificates.crt");
11125        std::fs::write(&bundle, "-----BEGIN CERTIFICATE-----\n").unwrap();
11126
11127        let policy = CapabilityPolicy {
11128            shell_allow: vec!["bash".to_string(), "cat".to_string()],
11129            ..CapabilityPolicy::default()
11130        };
11131        let with_file_grant = |list_dir: bool| {
11132            let mut enforcement = kernel_full_enforcement(EnforcementTier::KernelFull, &policy);
11133            enforcement.landlock_grants.push(LandlockGrant {
11134                path: bundle.clone(),
11135                list_dir,
11136                executable: false,
11137                pinned: None,
11138            });
11139            enforcement
11140        };
11141        let read = |enforcement: &ShellEnforcement| {
11142            crate::shell::execute_shell(
11143                "bash",
11144                &["-c", &format!("cat '{}'", bundle.display())],
11145                &[],
11146                &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
11147                &policy,
11148                enforcement,
11149            )
11150            .expect("execute_shell should return Ok even when the command itself fails")
11151        };
11152
11153        let honest = read(&with_file_grant(false));
11154        assert_eq!(
11155            honest.exit_code, 0,
11156            "ReadFile alone must open a granted regular file — this is what the /etc allowlist's \
11157             file entries rely on (stderr: {})",
11158            honest.stderr
11159        );
11160        assert!(
11161            honest.stdout.contains("BEGIN CERTIFICATE"),
11162            "stdout: {}",
11163            honest.stdout
11164        );
11165
11166        let overclaimed = read(&with_file_grant(true));
11167        assert_eq!(
11168            overclaimed.exit_code, 0,
11169            "a list_dir:true claim on a regular file must be narrowed to ReadFile, not turned \
11170             into an EINVAL that refuses the launch (stderr: {})",
11171            overclaimed.stderr
11172        );
11173        assert!(
11174            overclaimed.stdout.contains("BEGIN CERTIFICATE"),
11175            "stdout: {}",
11176            overclaimed.stdout
11177        );
11178    }
11179
11180    // ---- every distinct pre_exec setup failure is legible + fail-closed ----
11181    //
11182    // These force each of the three distinct sandbox setup failures and assert each produces its
11183    // OWN legible message (not the undifferentiated bare EINVAL), and that the target binary never
11184    // executes. One failure is real (an unresolvable workdir, now caught before fork()); two use
11185    // the child-side (`pre_exec`) test seams below. All are safe on any Linux host: the workdir
11186    // and no_new_privs failures happen before any real kernel enforcement, and the Landlock seam
11187    // short-circuits before any real Landlock syscall.
11188
11189    struct ForceNoNewPrivsFailureGuard;
11190
11191    impl ForceNoNewPrivsFailureGuard {
11192        fn new() -> Self {
11193            FORCE_NO_NEW_PRIVS_FAILURE.with(|flag| flag.set(true));
11194            Self
11195        }
11196    }
11197
11198    impl Drop for ForceNoNewPrivsFailureGuard {
11199        fn drop(&mut self) {
11200            FORCE_NO_NEW_PRIVS_FAILURE.with(|flag| flag.set(false));
11201        }
11202    }
11203
11204    struct ForceLandlockFailureGuard;
11205
11206    impl ForceLandlockFailureGuard {
11207        fn new() -> Self {
11208            FORCE_LANDLOCK_FAILURE.with(|flag| flag.set(true));
11209            Self
11210        }
11211    }
11212
11213    impl Drop for ForceLandlockFailureGuard {
11214        fn drop(&mut self) {
11215            FORCE_LANDLOCK_FAILURE.with(|flag| flag.set(false));
11216        }
11217    }
11218
11219    /// A `KernelFull` enforcement over an empty Landlock grant set. The forced-failure tests do not
11220    /// depend on any specific grant — they force the failure directly — so this exercises the
11221    /// child-side `pre_exec` steps on any Linux host regardless of its real Landlock support.
11222    fn kernel_full_empty_grants() -> ShellEnforcement {
11223        ShellEnforcement {
11224            tier: EnforcementTier::KernelFull,
11225            network_allow_ips: Vec::new(),
11226            unix_sockets_allowed: false,
11227            landlock_grants: Vec::new(),
11228            ..host_bounding_base()
11229        }
11230    }
11231
11232    /// Scenario 1 (real): `prepare_enforcement` with a `KernelFull` tier and a workdir that does
11233    /// not exist. Because the workdir's Landlock fd is opened in the PARENT, this fails
11234    /// synchronously before fork() — `.spawn()` is never called. Returns the error string.
11235    fn workdir_resolution_error() -> String {
11236        let temp = tempfile::tempdir().unwrap();
11237        let missing = temp.path().join("does-not-exist-workdir");
11238        let marker = temp.path().join("never-spawned.marker");
11239        let mut command = std::process::Command::new("bash");
11240        command
11241            .arg("-c")
11242            .arg(format!("echo ran > '{}'", marker.display()));
11243
11244        let error = prepare_enforcement(&mut command, &kernel_full_empty_grants(), &missing)
11245            .expect_err("an unresolvable workdir must fail before fork()");
11246        assert!(
11247            !marker.exists(),
11248            "prepare_enforcement must never spawn a subprocess: {error}"
11249        );
11250        error
11251    }
11252
11253    /// Runs `execute_shell` for `bash -c 'echo ran > marker'` under `KernelFull` with `arm`'s
11254    /// child-side failure active, asserts the target script never ran (marker absent), and returns
11255    /// the resulting error string. The RAII guard `arm` returns stays alive across `.spawn()`.
11256    fn child_setup_failure_error<G>(arm: impl FnOnce() -> G) -> String {
11257        child_setup_failure_typed(&kernel_full_empty_grants(), arm).to_string()
11258    }
11259
11260    /// [`child_setup_failure_error`] without the lossy `.to_string()`, and over a caller-chosen
11261    /// enforcement. Tests that care which *kind* of failure happened — not merely what it reads
11262    /// like — use this: the whole point of the sealed composed-root failure is that it keeps its
11263    /// identity out to the CLI instead of arriving as one more error string.
11264    fn child_setup_failure_typed<G>(
11265        enforcement: &ShellEnforcement,
11266        arm: impl FnOnce() -> G,
11267    ) -> crate::shell::ShellExecError {
11268        let temp = tempfile::tempdir().unwrap();
11269        let policy = CapabilityPolicy {
11270            shell_allow: vec!["bash".to_string()],
11271            ..CapabilityPolicy::default()
11272        };
11273        let marker = temp.path().join("spawned-anyway.marker");
11274        let script = format!("echo ran > '{}'", marker.display());
11275
11276        let _guard = arm();
11277        let error = crate::shell::execute_shell(
11278            "bash",
11279            &["-c", &script],
11280            &[],
11281            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
11282            &policy,
11283            enforcement,
11284        )
11285        .expect_err("a forced child-side setup failure must make execute_shell return Err");
11286        assert!(
11287            !marker.exists(),
11288            "no subprocess may run when pre_exec setup fails: {error}"
11289        );
11290        error
11291    }
11292
11293    #[test]
11294    fn pre_exec_workdir_resolution_failure_is_caught_before_fork() {
11295        let error = workdir_resolution_error();
11296        assert!(
11297            error.contains("workdir") && error.contains("does-not-exist-workdir"),
11298            "the error must name the workdir path that failed to resolve: {error}"
11299        );
11300        assert!(
11301            !error.contains("os error 22"),
11302            "must not collapse to the bare EINVAL string: {error}"
11303        );
11304    }
11305
11306    #[test]
11307    fn pre_exec_no_new_privs_failure_is_distinct_and_fails_closed() {
11308        if crate::network_namespace::skip_without_egress_namespace(
11309            "pre_exec_no_new_privs_failure_is_distinct_and_fails_closed",
11310        ) {
11311            return;
11312        }
11313        let error = child_setup_failure_error(ForceNoNewPrivsFailureGuard::new);
11314        assert!(
11315            error.contains("no_new_privs"),
11316            "the error must name the no_new_privs step specifically: {error}"
11317        );
11318        assert!(
11319            !error.contains("os error 22"),
11320            "must not collapse to the bare EINVAL string: {error}"
11321        );
11322    }
11323
11324    #[test]
11325    fn pre_exec_landlock_failure_is_distinct_and_fails_closed() {
11326        if crate::network_namespace::skip_without_egress_namespace(
11327            "pre_exec_landlock_failure_is_distinct_and_fails_closed",
11328        ) {
11329            return;
11330        }
11331        let error = child_setup_failure_error(ForceLandlockFailureGuard::new);
11332        assert!(
11333            error.contains("landlock"),
11334            "the error must name the Landlock construction step specifically: {error}"
11335        );
11336        assert!(
11337            !error.contains("os error 22"),
11338            "must not collapse to the bare EINVAL string: {error}"
11339        );
11340    }
11341
11342    #[test]
11343    fn pre_exec_setup_failures_produce_pairwise_distinct_messages() {
11344        if crate::network_namespace::skip_without_egress_namespace(
11345            "pre_exec_setup_failures_produce_pairwise_distinct_messages",
11346        ) {
11347            return;
11348        }
11349        let workdir_msg = workdir_resolution_error();
11350        let no_new_privs_msg = child_setup_failure_error(ForceNoNewPrivsFailureGuard::new);
11351        let landlock_msg = child_setup_failure_error(ForceLandlockFailureGuard::new);
11352
11353        let messages = [
11354            ("workdir", &workdir_msg),
11355            ("no_new_privs", &no_new_privs_msg),
11356            ("landlock", &landlock_msg),
11357        ];
11358        for (i, (left_name, left)) in messages.iter().enumerate() {
11359            for (right_name, right) in &messages[i + 1..] {
11360                assert_ne!(left, right, "{left_name} vs {right_name} must differ");
11361            }
11362        }
11363
11364        for message in [&workdir_msg, &no_new_privs_msg, &landlock_msg] {
11365            assert!(!message.is_empty(), "a distinct message must not be empty");
11366            assert!(
11367                !message.contains("os error 22"),
11368                "no failure may read as a bare EINVAL: {message}"
11369            );
11370        }
11371    }
11372
11373    /// The first half of the composed-root failure path: a `pre_exec`
11374    /// composed-root failure comes back out of `execute_shell` as the *typed*
11375    /// `SealedRootConstructionFailed`, carrying the child's diagnostic, and reports itself as
11376    /// session-fatal. `runtime::tests` covers the second half (what the dispatch layer then
11377    /// does with it) and `murmur-cli`'s error test covers the third (`E-RUN-014`).
11378    #[test]
11379    fn a_composed_root_failure_is_typed_and_session_fatal_not_just_another_message() {
11380        if crate::network_namespace::skip_without_egress_namespace(
11381            "a_composed_root_failure_is_typed_and_session_fatal_not_just_another_message",
11382        ) {
11383            return;
11384        }
11385        let error =
11386            child_setup_failure_typed(&sealed_test_enforcement(), ForceSealedRootFailureGuard::new);
11387
11388        assert!(
11389            matches!(
11390                error,
11391                crate::shell::ShellExecError::SealedRootConstructionFailed { .. }
11392            ),
11393            "a sealed-root diagnostic must survive as its own variant, not collapse into \
11394             ShellExecError::Failed: {error}"
11395        );
11396        let fatal = error
11397            .session_fatal()
11398            .expect("a composed-root failure must end the session, not just the tool call");
11399        assert!(
11400            matches!(
11401                fatal,
11402                crate::errors::RuntimeError::SealedRootConstructionFailed { .. }
11403            ),
11404            "must map to the RuntimeError the CLI renders as E-RUN-014: {fatal}"
11405        );
11406        let rendered = error.to_string();
11407        assert!(
11408            rendered.contains("composed root") && rendered.contains("unshare"),
11409            "the message must name both the mechanism and the step that failed: {rendered}"
11410        );
11411        assert!(
11412            !rendered.contains("os error 22"),
11413            "must not collapse to the bare EINVAL string: {rendered}"
11414        );
11415    }
11416
11417    /// The contrast that makes the assertion above mean something: an equally fatal-looking
11418    /// `pre_exec` failure that is *not* the composed root stays an ordinary failure, so nothing
11419    /// in this path ends a session for a Landlock or `no_new_privs` problem.
11420    #[test]
11421    fn an_ordinary_pre_exec_failure_is_not_session_fatal() {
11422        let landlock =
11423            child_setup_failure_typed(&kernel_full_empty_grants(), ForceLandlockFailureGuard::new);
11424        assert!(
11425            landlock.session_fatal().is_none(),
11426            "a Landlock setup failure is the tool call's problem, not the session's: {landlock}"
11427        );
11428
11429        let no_new_privs = child_setup_failure_typed(
11430            &kernel_full_empty_grants(),
11431            ForceNoNewPrivsFailureGuard::new,
11432        );
11433        assert!(
11434            no_new_privs.session_fatal().is_none(),
11435            "a no_new_privs failure is the tool call's problem, not the session's: {no_new_privs}"
11436        );
11437    }
11438
11439    // ---- one network namespace per session -------------------------------------------------
11440    //
11441    // Every test below drives real commands through `execute_shell`/`run_shell` against an
11442    // enforcement resolved the way `stage_session` resolves one, and reads what a later command of
11443    // the same session — or of another session — can reach.
11444
11445    /// `bash`, `nc` and `sleep`, plus `extra`: what a model starting and probing a server uses.
11446    fn server_policy(extra: &[&str]) -> CapabilityPolicy {
11447        CapabilityPolicy {
11448            shell_allow: ["bash", "nc", "sleep"]
11449                .iter()
11450                .chain(extra)
11451                .map(|binary| binary.to_string())
11452                .collect(),
11453            ..CapabilityPolicy::default()
11454        }
11455    }
11456
11457    /// A session's enforcement, resolved as `stage_session` resolves a `scoped` capsule's.
11458    fn session_enforcement(policy: &CapabilityPolicy) -> ShellEnforcement {
11459        let enforcement = ShellEnforcement::resolve_declared(
11460            policy,
11461            murmur_artifact::ContainmentClass::Scoped,
11462            HostProbe::probe(),
11463        )
11464        .expect("the policy resolves")
11465        .with_test_session_dirs();
11466        assert_eq!(
11467            enforcement.tier,
11468            EnforcementTier::KernelFull,
11469            "a scoped capsule on this host runs on KernelFull"
11470        );
11471        enforcement
11472    }
11473
11474    /// A one-shot server on `127.0.0.1:<port>` that answers its one connection with `reply`,
11475    /// backgrounded with its output redirected so the command that starts it returns at once.
11476    fn one_shot_server(reply: &str, port: u16) -> String {
11477        format!(
11478            "(printf \"{reply}\\n\" | nc -N -l 127.0.0.1 {port}) >/dev/null 2>&1 & echo $! > server.pid"
11479        )
11480    }
11481
11482    /// Connects to `127.0.0.1:<port>`, retrying for up to five seconds while the server comes up,
11483    /// and prints the first line it reads.
11484    fn retrying_client(port: u16) -> String {
11485        format!(
11486            "for i in {{1..50}}; do exec 3<>/dev/tcp/127.0.0.1/{port} 2>/dev/null && break; \
11487             sleep 0.1; done; read -r reply <&3; echo \"$reply\""
11488        )
11489    }
11490
11491    fn bash(
11492        script: &str,
11493        workdir: &Path,
11494        policy: &CapabilityPolicy,
11495        enforcement: &ShellEnforcement,
11496    ) -> Result<crate::shell::ShellResult, crate::shell::ShellExecError> {
11497        crate::shell::execute_shell(
11498            "bash",
11499            &["-c", script],
11500            &[],
11501            &crate::workdir_fs::WorkdirRoot::open(workdir).unwrap(),
11502            policy,
11503            enforcement,
11504        )
11505    }
11506
11507    fn pid_in(workdir: &Path, file: &str) -> i32 {
11508        std::fs::read_to_string(workdir.join(file))
11509            .unwrap_or_else(|error| panic!("{file} was not written: {error}"))
11510            .trim()
11511            .parse()
11512            .unwrap_or_else(|error| panic!("{file} holds no pid: {error}"))
11513    }
11514
11515    fn process_exists(pid: i32) -> bool {
11516        Path::new(&format!("/proc/{pid}")).exists()
11517    }
11518
11519    /// Whether `/proc/<pid>` is gone within `timeout`. A killed process stays visible until its
11520    /// parent — `init` for an orphan — reaps it, which takes a moment after the kill itself.
11521    fn process_gone_within(pid: i32, timeout: std::time::Duration) -> bool {
11522        let deadline = std::time::Instant::now() + timeout;
11523        while process_exists(pid) {
11524            if std::time::Instant::now() >= deadline {
11525                return false;
11526            }
11527            std::thread::sleep(std::time::Duration::from_millis(20));
11528        }
11529        true
11530    }
11531
11532    /// A server one command backgrounds answers a later, separate command of the same session.
11533    #[test]
11534    #[allow(clippy::print_stdout)]
11535    fn a_listener_started_by_one_command_answers_the_next() {
11536        if crate::network_namespace::skip_without_egress_namespace(
11537            "a_listener_started_by_one_command_answers_the_next",
11538        ) {
11539            return;
11540        }
11541        let temp = tempfile::tempdir().unwrap();
11542        let policy = server_policy(&[]);
11543        let enforcement = session_enforcement(&policy);
11544
11545        let started = std::time::Instant::now();
11546        let server = bash(
11547            &one_shot_server("pong", 8080),
11548            temp.path(),
11549            &policy,
11550            &enforcement,
11551        )
11552        .expect("the server command runs");
11553        let server_ms = started.elapsed().as_millis();
11554        assert_eq!(server.exit_code, 0, "stderr: {}", server.stderr);
11555        assert!(
11556            started.elapsed() < std::time::Duration::from_secs(5),
11557            "a backgrounded server with its output redirected must not hold the command open"
11558        );
11559
11560        let client = bash(&retrying_client(8080), temp.path(), &policy, &enforcement)
11561            .expect("the client command runs");
11562        println!(
11563            "tier {:?}; command 1 exit {} in {server_ms} ms; command 2 exit {} stdout {:?}; \
11564             namespaces created: {}",
11565            enforcement.tier,
11566            server.exit_code,
11567            client.exit_code,
11568            client.stdout.trim(),
11569            enforcement.session_netns.creations()
11570        );
11571        assert_eq!(client.exit_code, 0, "stderr: {}", client.stderr);
11572        assert_eq!(client.stdout.trim(), "pong");
11573        assert_eq!(enforcement.session_netns.creations(), 1);
11574
11575        enforcement.end_session();
11576    }
11577
11578    /// A foreground server demoted after its grace period keeps listening, and a later command
11579    /// reaches it; the demoted command then completes with what it received.
11580    #[test]
11581    #[allow(clippy::print_stdout)]
11582    fn a_demoted_server_stays_reachable_from_later_commands() {
11583        use crate::detached::{DetachPolicy, DetachedRegistry, DetachedReport};
11584
11585        if crate::network_namespace::skip_without_egress_namespace(
11586            "a_demoted_server_stays_reachable_from_later_commands",
11587        ) {
11588            return;
11589        }
11590        let temp = tempfile::tempdir().unwrap();
11591        let base = tempfile::tempdir().unwrap();
11592        let policy = server_policy(&[]);
11593        let enforcement =
11594            session_enforcement(&policy).with_runtime_dir(test_runtime_dir(base.path()), None);
11595        let (registry, mut reports) = DetachedRegistry::new();
11596
11597        let outcome = crate::shell::run_shell(
11598            "nc",
11599            &["-l", "127.0.0.1", "8080"],
11600            &[],
11601            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
11602            &policy,
11603            &enforcement,
11604            Some(DetachPolicy {
11605                grace: Some(std::time::Duration::ZERO),
11606                delivered: true,
11607                registry: Arc::clone(&registry),
11608                command: "-l 127.0.0.1 8080".to_string(),
11609                context_id: "ctx_demoted_server".to_string(),
11610                provenance: None,
11611                abandoned: None,
11612                canceled: None,
11613            }),
11614        )
11615        .expect("the server command runs");
11616        let crate::shell::ShellOutcome::Detached(info) = outcome else {
11617            panic!("a zero grace period demotes the server, got {outcome:?}");
11618        };
11619
11620        let client = bash(
11621            "for i in {1..50}; do exec 3<>/dev/tcp/127.0.0.1/8080 2>/dev/null && break; \
11622             sleep 0.1; done; echo ping >&3",
11623            temp.path(),
11624            &policy,
11625            &enforcement,
11626        )
11627        .expect("the client command runs");
11628        assert_eq!(client.exit_code, 0, "stderr: {}", client.stderr);
11629
11630        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
11631        let completion = loop {
11632            match reports.try_recv() {
11633                Ok(DetachedReport::Completed(completion)) => break completion,
11634                Ok(DetachedReport::Lost(_)) => {}
11635                Err(_) if std::time::Instant::now() < deadline => {
11636                    std::thread::sleep(std::time::Duration::from_millis(20));
11637                }
11638                Err(error) => panic!("the demoted server never completed: {error}"),
11639            }
11640        };
11641        let log = std::fs::read_to_string(&completion.output_path)
11642            .expect("the demoted command's log is written");
11643        println!(
11644            "demoted as {}; client exit {}; completion {} exit {}; log has ping: {}",
11645            info.work_id,
11646            client.exit_code,
11647            completion.work_id,
11648            completion.exit_code,
11649            log.contains("ping")
11650        );
11651        assert_eq!(completion.work_id, info.work_id);
11652        assert_eq!(completion.exit_code, 0, "log: {log}");
11653        assert!(log.contains("ping"), "log: {log}");
11654
11655        enforcement.end_session();
11656    }
11657
11658    /// A truncated command's log, named by its absolute path in the session's records directory,
11659    /// reads back under the session's own enforcement: through the shell interpreter and through
11660    /// a binary allowed on its own that gets the path as an argument. A write to it is refused,
11661    /// on any tier that installs Landlock.
11662    #[test]
11663    #[allow(clippy::print_stdout)]
11664    fn a_truncated_log_reads_back_through_the_shell_tool_and_refuses_a_write() {
11665        // A host that cannot give the session its network namespace refuses the session at
11666        // staging, so there is no session enforcement to run under.
11667        if crate::network_namespace::skip_without_egress_namespace(
11668            "a_truncated_log_reads_back_through_the_shell_tool_and_refuses_a_write",
11669        ) {
11670            return;
11671        }
11672        let base = home_tempdir();
11673        let workdir = home_tempdir();
11674        let policy = CapabilityPolicy {
11675            shell_allow: vec!["bash".to_string(), "grep".to_string()],
11676            ..CapabilityPolicy::default()
11677        };
11678        let enforcement = ShellEnforcement::resolve_declared(
11679            &policy,
11680            murmur_artifact::ContainmentClass::Scoped,
11681            HostProbe::probe(),
11682        )
11683        .expect("the policy resolves")
11684        .with_runtime_dir(test_runtime_dir(base.path()), None);
11685        let landlock = matches!(
11686            enforcement.tier,
11687            EnforcementTier::KernelFull | EnforcementTier::KernelSealed
11688        );
11689        println!(
11690            "tier {:?}; landlock ABI {:?}",
11691            enforcement.tier,
11692            detect_landlock_abi()
11693        );
11694
11695        // A builtin loop: only `bash` and `grep` are executable under Landlock.
11696        let printed = run_bash_in(
11697            workdir.path(),
11698            "for ((i = 1; i <= 20000; i++)); do echo $i; done",
11699            &policy,
11700            &enforcement,
11701        );
11702        assert_eq!(printed.exit_code, 0, "{}", printed.stderr);
11703        assert!(printed.truncated);
11704        let path = printed
11705            .full_output_path
11706            .clone()
11707            .expect("a truncated command names its log");
11708        assert!(path.starts_with(enforcement.records_dir_path().unwrap().to_str().unwrap()));
11709        let before = std::fs::read(&path).unwrap();
11710
11711        let through_bash = run_bash_in(
11712            workdir.path(),
11713            &format!("grep -x 20000 {path}"),
11714            &policy,
11715            &enforcement,
11716        );
11717        assert_eq!(through_bash.exit_code, 0, "{}", through_bash.stderr);
11718        assert_eq!(through_bash.stdout, "20000\n");
11719
11720        let through_grep = crate::shell::execute_shell(
11721            "grep",
11722            &["-x", "20000", &path],
11723            &[],
11724            &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
11725            &policy,
11726            &enforcement,
11727        )
11728        .unwrap();
11729        assert_eq!(through_grep.exit_code, 0, "{}", through_grep.stderr);
11730        assert_eq!(through_grep.stdout, "20000\n");
11731
11732        if landlock {
11733            let write = run_bash_in(
11734                workdir.path(),
11735                &format!("echo x >> {path}"),
11736                &policy,
11737                &enforcement,
11738            );
11739            assert_ne!(write.exit_code, 0, "a command appended to its own log");
11740            assert_eq!(std::fs::read(&path).unwrap(), before);
11741        } else {
11742            println!(
11743                "[SKIP-HOST] a_truncated_log_reads_back_through_the_shell_tool_and_refuses_a_write: \
11744                 the write half needs Landlock; detected {:?}",
11745                enforcement.tier
11746            );
11747        }
11748        assert!(!workdir.path().join("logs").exists());
11749        enforcement.end_session();
11750    }
11751
11752    /// Two sessions each bind the same port on their own loopback, each reaches only its own
11753    /// server, and the host reaches neither.
11754    #[test]
11755    #[allow(clippy::print_stdout)]
11756    fn two_sessions_do_not_share_loopback() {
11757        if crate::network_namespace::skip_without_egress_namespace(
11758            "two_sessions_do_not_share_loopback",
11759        ) {
11760            return;
11761        }
11762        let port = {
11763            let probe = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
11764            probe.local_addr().unwrap().port()
11765        };
11766        let policy = server_policy(&[]);
11767        let (dir_a, dir_b) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
11768        let session_a = session_enforcement(&policy);
11769        let session_b = session_enforcement(&policy);
11770
11771        for (dir, session, reply) in [
11772            (&dir_a, &session_a, "pong-a"),
11773            (&dir_b, &session_b, "pong-b"),
11774        ] {
11775            let server = bash(&one_shot_server(reply, port), dir.path(), &policy, session)
11776                .expect("the server command runs");
11777            assert_eq!(server.exit_code, 0, "stderr: {}", server.stderr);
11778        }
11779        // Both servers bound the same port: each is still running, waiting for its one client.
11780        std::thread::sleep(std::time::Duration::from_millis(300));
11781        for dir in [&dir_a, &dir_b] {
11782            let pid = pid_in(dir.path(), "server.pid");
11783            assert!(process_exists(pid), "a server's bind on {port} failed");
11784        }
11785
11786        let host = std::net::TcpStream::connect(("127.0.0.1", port));
11787        let host_error = host.expect_err("the host must not reach a session's loopback server");
11788        assert_eq!(host_error.kind(), std::io::ErrorKind::ConnectionRefused);
11789
11790        let client_b = bash(&retrying_client(port), dir_b.path(), &policy, &session_b)
11791            .expect("B's client runs");
11792        assert_eq!(client_b.exit_code, 0, "stderr: {}", client_b.stderr);
11793
11794        // B's one-shot server is spent; A's still listens on the same port in A's namespace.
11795        let second_b = bash(
11796            &format!("exec 3<>/dev/tcp/127.0.0.1/{port}"),
11797            dir_b.path(),
11798            &policy,
11799            &session_b,
11800        )
11801        .expect("B's second client runs");
11802
11803        let client_a = bash(&retrying_client(port), dir_a.path(), &policy, &session_a)
11804            .expect("A's client runs");
11805        println!(
11806            "port {port}; host connect: {host_error}; B read {:?}; B's second client exit {} \
11807             stderr {:?}; A read {:?}",
11808            client_b.stdout.trim(),
11809            second_b.exit_code,
11810            second_b.stderr.trim(),
11811            client_a.stdout.trim()
11812        );
11813        assert_eq!(client_b.stdout.trim(), "pong-b");
11814        assert_ne!(second_b.exit_code, 0);
11815        assert!(
11816            second_b.stderr.contains("Connection refused"),
11817            "stderr: {}",
11818            second_b.stderr
11819        );
11820        assert_eq!(client_a.exit_code, 0, "stderr: {}", client_a.stderr);
11821        assert_eq!(client_a.stdout.trim(), "pong-a");
11822
11823        session_a.end_session();
11824        session_b.end_session();
11825    }
11826
11827    /// The session's one egress proxy serves every command, refuses what is not allowlisted,
11828    /// and is created once.
11829    #[test]
11830    #[allow(clippy::print_stdout)]
11831    fn the_session_proxy_serves_every_command() {
11832        if crate::network_namespace::skip_without_egress_namespace(
11833            "the_session_proxy_serves_every_command",
11834        ) {
11835            return;
11836        }
11837        // Answers every connection, so the first and third commands both get a reply.
11838        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
11839        let port = listener.local_addr().unwrap().port();
11840        std::thread::spawn(move || {
11841            for stream in listener.incoming() {
11842                use std::io::Write;
11843                if let Ok(mut stream) = stream {
11844                    let _ = stream.write_all(b"pong\n");
11845                }
11846            }
11847        });
11848
11849        let temp = tempfile::tempdir().unwrap();
11850        let policy = CapabilityPolicy {
11851            network_allow: vec![format!("http://127.0.0.1:{port}")],
11852            ..server_policy(&[])
11853        };
11854        let enforcement = session_enforcement(&policy);
11855        let through_proxy =
11856            format!("exec 3<>/dev/tcp/127.0.0.1/{port} && read -r reply <&3 && echo \"$reply\"");
11857
11858        let first = bash(&through_proxy, temp.path(), &policy, &enforcement).unwrap();
11859        let unlisted = bash(
11860            "exec 3<>/dev/tcp/10.255.255.1/9",
11861            temp.path(),
11862            &policy,
11863            &enforcement,
11864        )
11865        .unwrap();
11866        let third = bash(&through_proxy, temp.path(), &policy, &enforcement).unwrap();
11867        println!(
11868            "first {:?} exit {}; unlisted exit {} stderr {:?}; third {:?} exit {}; namespaces \
11869             created: {}",
11870            first.stdout.trim(),
11871            first.exit_code,
11872            unlisted.exit_code,
11873            unlisted.stderr.trim(),
11874            third.stdout.trim(),
11875            third.exit_code,
11876            enforcement.session_netns.creations()
11877        );
11878        assert_eq!(first.exit_code, 0, "stderr: {}", first.stderr);
11879        assert_eq!(first.stdout.trim(), "pong");
11880        assert_ne!(unlisted.exit_code, 0, "an unlisted destination must fail");
11881        assert_eq!(third.exit_code, 0, "stderr: {}", third.stderr);
11882        assert_eq!(third.stdout.trim(), "pong");
11883        assert_eq!(enforcement.session_netns.creations(), 1);
11884
11885        enforcement.end_session();
11886    }
11887
11888    /// Commands whose first spawns race each other still create exactly one namespace, and
11889    /// all of them land in it.
11890    #[test]
11891    #[allow(clippy::print_stdout)]
11892    fn concurrent_first_commands_share_one_namespace() {
11893        if crate::network_namespace::skip_without_egress_namespace(
11894            "concurrent_first_commands_share_one_namespace",
11895        ) {
11896            return;
11897        }
11898        let temp = tempfile::tempdir().unwrap();
11899        let policy = server_policy(&[]);
11900        let enforcement = session_enforcement(&policy);
11901        // One one-shot server per client, on its own port: a second client queued on a one-shot
11902        // `nc -l` would sit in its listen backlog and be closed unanswered when it exits.
11903        let servers = (8081..=8083)
11904            .map(|port| one_shot_server("pong", port).replace("server.pid", &format!("{port}.pid")))
11905            .collect::<Vec<_>>()
11906            .join("; ");
11907        let scripts = [
11908            servers,
11909            retrying_client(8081),
11910            retrying_client(8082),
11911            retrying_client(8083),
11912        ];
11913
11914        let barrier = std::sync::Barrier::new(scripts.len());
11915        let results: Vec<crate::shell::ShellResult> = std::thread::scope(|scope| {
11916            let handles: Vec<_> = scripts
11917                .iter()
11918                .map(|script| {
11919                    let enforcement = enforcement.clone();
11920                    let (barrier, policy, workdir) = (&barrier, &policy, temp.path());
11921                    scope.spawn(move || {
11922                        barrier.wait();
11923                        bash(script, workdir, policy, &enforcement).expect("the command runs")
11924                    })
11925                })
11926                .collect();
11927            handles
11928                .into_iter()
11929                .map(|handle| handle.join().unwrap())
11930                .collect()
11931        });
11932
11933        for (index, result) in results.iter().enumerate() {
11934            println!(
11935                "command {index}: exit {} stdout {:?}",
11936                result.exit_code,
11937                result.stdout.trim()
11938            );
11939            assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr);
11940        }
11941        for client in &results[1..] {
11942            assert_eq!(client.stdout.trim(), "pong");
11943        }
11944        println!(
11945            "namespaces created: {}",
11946            enforcement.session_netns.creations()
11947        );
11948        assert_eq!(enforcement.session_netns.creations(), 1);
11949
11950        enforcement.end_session();
11951    }
11952
11953    /// Ending the session kills everything it started — a `setsid` process and a background
11954    /// server included — releases the namespace, and refuses every command after.
11955    #[test]
11956    #[allow(clippy::print_stdout)]
11957    fn ending_the_session_kills_everything_it_started() {
11958        if crate::cgroup::skip_without_host_support(
11959            "ending_the_session_kills_everything_it_started",
11960        ) {
11961            return;
11962        }
11963        let temp = tempfile::tempdir().unwrap();
11964        let policy = server_policy(&["setsid"]);
11965        let scope =
11966            crate::cgroup::prepare_scope(true, &policy.resources, "ses_end_kills", temp.path())
11967                .expect("a delegated scope")
11968                .scope
11969                .expect("a required scope on Linux is never None");
11970        let enforcement =
11971            session_enforcement(&policy).with_host_bounding(Some(Arc::clone(&scope)), None);
11972
11973        let started = bash(
11974            "setsid sleep 300 >/dev/null 2>&1 & echo $! > sleeper.pid; \
11975             nc -l 127.0.0.1 8080 >/dev/null 2>&1 & echo $! > server.pid",
11976            temp.path(),
11977            &policy,
11978            &enforcement,
11979        )
11980        .expect("the command runs");
11981        assert_eq!(started.exit_code, 0, "stderr: {}", started.stderr);
11982        let sleeper = pid_in(temp.path(), "sleeper.pid");
11983        let server = pid_in(temp.path(), "server.pid");
11984        assert!(process_exists(sleeper), "the setsid sleeper is running");
11985        assert!(process_exists(server), "the background server is running");
11986
11987        let ending = std::time::Instant::now();
11988        enforcement.end_session();
11989        let took = ending.elapsed();
11990
11991        let sleeper_gone = process_gone_within(sleeper, std::time::Duration::from_secs(2));
11992        let server_gone = process_gone_within(server, std::time::Duration::from_secs(2));
11993        let procs = std::fs::read_to_string(scope.path().join("cgroup.procs")).unwrap();
11994        let after = bash("true", temp.path(), &policy, &enforcement);
11995        println!(
11996            "end_session took {} ms; sleeper {sleeper} gone: {sleeper_gone}; server {server} \
11997             gone: {server_gone}; cgroup.procs after: {:?}; next command: {:?}",
11998            took.as_millis(),
11999            procs.trim(),
12000            after.as_ref().map(|result| result.exit_code)
12001        );
12002        assert!(took < std::time::Duration::from_secs(3), "took {took:?}");
12003        assert!(sleeper_gone, "the setsid sleeper outlived its session");
12004        assert!(server_gone, "the background server outlived its session");
12005        assert!(procs.trim().is_empty(), "cgroup.procs: {procs}");
12006        let refusal = after
12007            .expect_err("a command after the session ended must be refused")
12008            .to_string();
12009        assert!(refusal.contains("session has ended"), "{refusal}");
12010    }
12011
12012    // ---- the session's temporary folder ----
12013    //
12014    // Each test below resolves its enforcement the way a session does, over a live `HostProbe`,
12015    // and maps `/tmp` with the answer staging would give. One that needs a private mount
12016    // namespace prints a `[SKIP-HOST]` line and returns on a host that cannot give one.
12017
12018    /// A directory under `HOME`, outside `/tmp`, removed when the value drops.
12019    fn home_tempdir() -> tempfile::TempDir {
12020        tempfile::tempdir_in(std::env::var_os("HOME").expect("HOME is set")).unwrap()
12021    }
12022
12023    /// A session's enforcement at `class` from a live probe, holding a runtime directory, records
12024    /// directory and temporary folder under `base`, with the `/tmp` mapping staging decides for
12025    /// `workdir`; `None` after a skip line naming `test` when that mapping is not
12026    /// [`crate::temp_dir::TempMapping::MountNamespace`].
12027    fn mapped_enforcement(
12028        test: &str,
12029        policy: &CapabilityPolicy,
12030        class: murmur_artifact::ContainmentClass,
12031        base: &Path,
12032        workdir: &Path,
12033    ) -> Option<ShellEnforcement> {
12034        if crate::network_namespace::skip_without_egress_namespace(test) {
12035            return None;
12036        }
12037        let probe = HostProbe::probe();
12038        let mount_namespace_usable = probe.mount_namespace_usable();
12039        let enforcement = ShellEnforcement::resolve_declared(policy, class, probe)
12040            .unwrap()
12041            .with_runtime_dir(test_runtime_dir(base), None);
12042        let mapping =
12043            crate::temp_dir::temp_mapping(enforcement.tier, mount_namespace_usable, workdir);
12044        if mapping != crate::temp_dir::TempMapping::MountNamespace {
12045            crate::runtime_err!(
12046                "[SKIP-HOST] {test}: this host maps no private /tmp at {class:?} ({mapping:?} on \
12047                 {:?})",
12048                enforcement.tier
12049            );
12050            return None;
12051        }
12052        Some(enforcement.with_temp_mapping(mapping))
12053    }
12054
12055    /// The same session enforcement with the mount namespace probed as unusable: `KernelFull`,
12056    /// `/tmp` not mapped. `None` after a skip line on a host that has no `KernelFull` at all.
12057    fn unmapped_kernel_full_enforcement(
12058        test: &str,
12059        policy: &CapabilityPolicy,
12060        base: &Path,
12061        workdir: &Path,
12062    ) -> Option<ShellEnforcement> {
12063        if crate::network_namespace::skip_without_egress_namespace(test) {
12064            return None;
12065        }
12066        let live = HostProbe::probe();
12067        if !matches!(
12068            live.tier(),
12069            EnforcementTier::KernelFull | EnforcementTier::KernelSealed
12070        ) {
12071            crate::runtime_err!(
12072                "[SKIP-HOST] {test}: needs Landlock; detected {:?}",
12073                live.tier()
12074            );
12075            return None;
12076        }
12077        let refused = crate::namespace_probe::MountAttempt::Failed(
12078            crate::namespace_probe::MountAttemptError::Step {
12079                step: crate::namespace_probe::MountStep::UnshareMount,
12080                errno: libc::EPERM,
12081            },
12082        );
12083        let probe = HostProbe::from_parts(
12084            Some(true),
12085            crate::namespace_probe::NamespaceReading {
12086                mount: refused.clone(),
12087                sealed_root: refused,
12088                ..live.namespaces().clone()
12089            },
12090        )
12091        .with_landlock_kernel_abi(live.landlock_kernel_abi());
12092        assert!(!probe.mount_namespace_usable());
12093        let enforcement = ShellEnforcement::resolve_declared(
12094            policy,
12095            murmur_artifact::ContainmentClass::Scoped,
12096            probe,
12097        )
12098        .unwrap()
12099        .with_runtime_dir(test_runtime_dir(base), None);
12100        assert_eq!(enforcement.tier, EnforcementTier::KernelFull);
12101        let mapping = crate::temp_dir::temp_mapping(enforcement.tier, false, workdir);
12102        assert_eq!(
12103            mapping,
12104            crate::temp_dir::TempMapping::NotMapped(
12105                crate::temp_dir::NotMappedReason::NoMountNamespace
12106            )
12107        );
12108        Some(enforcement.with_temp_mapping(mapping))
12109    }
12110
12111    /// A name no other test or run uses beneath `/tmp`.
12112    fn unique_tmp_name(label: &str) -> String {
12113        static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
12114        format!(
12115            "murmur-probe-{label}-{}-{}",
12116            std::process::id(),
12117            NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
12118        )
12119    }
12120
12121    /// The parent-side spec re-exposes the work folder and the home folder read-write and the
12122    /// runtime and records directories and every grant path read-only, each at its exact path
12123    /// beneath `/tmp`, the home folder over the records directory it lies in.
12124    #[test]
12125    fn the_tmp_map_spec_reexposes_exact_paths_beneath_tmp_read_only_but_the_workdir() {
12126        let base = tempfile::tempdir().unwrap();
12127        let workdir = tempfile::tempdir().unwrap();
12128        let grant_dir = tempfile::tempdir().unwrap();
12129        let grant = grant_dir.path().join("bin");
12130        std::fs::write(&grant, b"#!/bin/sh\n").unwrap();
12131        let mut enforcement = ShellEnforcement {
12132            tier: EnforcementTier::KernelFull,
12133            ..ShellEnforcement::environment_only().with_test_session_dirs()
12134        }
12135        .with_runtime_dir(test_runtime_dir(base.path()), None)
12136        .with_temp_mapping(crate::temp_dir::TempMapping::MountNamespace);
12137        enforcement.landlock_grants =
12138            LandlockGrant::non_listable_files(vec![grant.clone(), PathBuf::from("/usr/bin/env")]);
12139        let folder = enforcement
12140            .runtime_dir
12141            .as_ref()
12142            .and_then(|roots| roots.temp.as_ref())
12143            .unwrap()
12144            .path_fd()
12145            .unwrap();
12146
12147        let spec = build_tmp_map_spec(&enforcement, &folder, workdir.path()).unwrap();
12148
12149        let canonical = |path: &Path| {
12150            std::fs::canonicalize(path)
12151                .unwrap()
12152                .to_string_lossy()
12153                .into_owned()
12154        };
12155        let mut expected = vec![
12156            (canonical(workdir.path()), false),
12157            (
12158                canonical(&base.path().join(crate::runtime_dir::RUNTIME_SUBDIR)),
12159                true,
12160            ),
12161            (
12162                canonical(&base.path().join(crate::records_dir::RECORDS_DIR_NAME)),
12163                true,
12164            ),
12165            (
12166                canonical(
12167                    &base
12168                        .path()
12169                        .join(crate::records_dir::RECORDS_DIR_NAME)
12170                        .join(crate::records_dir::HOME_SUBDIR),
12171                ),
12172                false,
12173            ),
12174            (canonical(&grant), true),
12175        ];
12176        expected.sort();
12177        assert_eq!(spec.reexposed_sources(), expected);
12178    }
12179
12180    /// A native tool's store beneath `/tmp` is re-exposed writable behind the private `/tmp` for
12181    /// that tool's spawn, and for no spawn without it.
12182    #[test]
12183    fn a_native_tools_store_under_tmp_is_reexposed_writable() {
12184        let base = tempfile::tempdir_in(crate::temp_dir::canonical_tmp()).unwrap();
12185        let workdir = tempfile::tempdir().unwrap();
12186        let store = base.path().join("state").join("config-capsule");
12187        std::fs::create_dir_all(&store).unwrap();
12188        let session = ShellEnforcement {
12189            tier: EnforcementTier::KernelFull,
12190            ..ShellEnforcement::environment_only().with_test_session_dirs()
12191        }
12192        .with_runtime_dir(test_runtime_dir(base.path()), None)
12193        .with_temp_mapping(crate::temp_dir::TempMapping::MountNamespace);
12194        let folder = session
12195            .runtime_dir
12196            .as_ref()
12197            .and_then(|roots| roots.temp.as_ref())
12198            .unwrap()
12199            .path_fd()
12200            .unwrap();
12201        let canonical_store = std::fs::canonicalize(&store)
12202            .unwrap()
12203            .to_string_lossy()
12204            .into_owned();
12205
12206        let native = ShellEnforcement {
12207            native_state_dir: Some(store.clone()),
12208            ..session.clone()
12209        };
12210        let spec = build_tmp_map_spec(&native, &folder, workdir.path()).unwrap();
12211        assert!(
12212            spec.reexposed_sources()
12213                .contains(&(canonical_store.clone(), false)),
12214            "{:?}",
12215            spec.reexposed_sources()
12216        );
12217
12218        let spec = build_tmp_map_spec(&session, &folder, workdir.path()).unwrap();
12219        assert!(
12220            spec.reexposed_sources()
12221                .iter()
12222                .all(|(source, _)| source != &canonical_store),
12223            "{:?}",
12224            spec.reexposed_sources()
12225        );
12226    }
12227
12228    /// At `scoped` and at `advisory`, a literal `/tmp` path lands in the session's temporary
12229    /// folder, `TMPDIR` is `/tmp`, and the host's `/tmp` gains nothing.
12230    #[test]
12231    fn a_literal_tmp_path_reaches_the_session_temp_folder() {
12232        const TEST: &str = "a_literal_tmp_path_reaches_the_session_temp_folder";
12233        for class in [
12234            murmur_artifact::ContainmentClass::Scoped,
12235            murmur_artifact::ContainmentClass::Advisory,
12236        ] {
12237            let base = home_tempdir();
12238            let workdir = home_tempdir();
12239            let policy = coreutils_policy(&[]);
12240            let Some(enforcement) =
12241                mapped_enforcement(TEST, &policy, class, base.path(), workdir.path())
12242            else {
12243                return;
12244            };
12245            let name = unique_tmp_name("literal");
12246            let result = run_bash_in(
12247                workdir.path(),
12248                &format!("echo x > /tmp/{name} && cat /tmp/{name} && printf '%s\\n' \"$TMPDIR\""),
12249                &policy,
12250                &enforcement,
12251            );
12252            assert_eq!(result.exit_code, 0, "{class:?}: {}", result.stderr);
12253            assert_eq!(result.stdout, "x\n/tmp\n", "{class:?}");
12254            let folder = enforcement.temp_dir_path().unwrap();
12255            assert_eq!(std::fs::read_to_string(folder.join(&name)).unwrap(), "x\n");
12256            assert!(
12257                !Path::new("/tmp").join(&name).exists(),
12258                "{class:?}: the host's /tmp gained {name}"
12259            );
12260        }
12261    }
12262
12263    /// `KernelSeccompOnly` maps `/tmp` through the same per-command mount namespace: the session
12264    /// enforcement with its tier set to seccomp-only, so no Landlock domain is installed.
12265    #[test]
12266    fn a_literal_tmp_path_reaches_the_session_temp_folder_on_the_seccomp_only_tier() {
12267        const TEST: &str =
12268            "a_literal_tmp_path_reaches_the_session_temp_folder_on_the_seccomp_only_tier";
12269        let base = home_tempdir();
12270        let workdir = home_tempdir();
12271        let policy = coreutils_policy(&[]);
12272        let Some(enforcement) = mapped_enforcement(
12273            TEST,
12274            &policy,
12275            murmur_artifact::ContainmentClass::Scoped,
12276            base.path(),
12277            workdir.path(),
12278        ) else {
12279            return;
12280        };
12281        let enforcement = ShellEnforcement {
12282            tier: EnforcementTier::KernelSeccompOnly,
12283            ..enforcement
12284        };
12285        assert_eq!(
12286            enforcement.installed_temp_mapping(),
12287            crate::temp_dir::TempMapping::MountNamespace
12288        );
12289        let name = unique_tmp_name("seccomp-only");
12290        let result = run_bash_in(
12291            workdir.path(),
12292            &format!("echo x > /tmp/{name} && cat /tmp/{name} && printf '%s\\n' \"$TMPDIR\""),
12293            &policy,
12294            &enforcement,
12295        );
12296        assert_eq!(result.exit_code, 0, "{}", result.stderr);
12297        assert_eq!(result.stdout, "x\n/tmp\n");
12298        let folder = enforcement.temp_dir_path().unwrap();
12299        assert_eq!(std::fs::read_to_string(folder.join(&name)).unwrap(), "x\n");
12300        assert!(
12301            !Path::new("/tmp").join(&name).exists(),
12302            "the host's /tmp gained {name}"
12303        );
12304    }
12305
12306    /// Two sessions' commands each see their own folder at `/tmp`.
12307    #[test]
12308    fn two_sessions_do_not_share_tmp() {
12309        const TEST: &str = "two_sessions_do_not_share_tmp";
12310        let policy = coreutils_policy(&[]);
12311        let (base_a, base_b) = (home_tempdir(), home_tempdir());
12312        let (work_a, work_b) = (home_tempdir(), home_tempdir());
12313        let class = murmur_artifact::ContainmentClass::Scoped;
12314        let Some(a) = mapped_enforcement(TEST, &policy, class, base_a.path(), work_a.path()) else {
12315            return;
12316        };
12317        let Some(b) = mapped_enforcement(TEST, &policy, class, base_b.path(), work_b.path()) else {
12318            return;
12319        };
12320        let name = unique_tmp_name("shared");
12321
12322        let first = run_bash_in(work_a.path(), &format!("echo a > /tmp/{name}"), &policy, &a);
12323        assert_eq!(first.exit_code, 0, "{}", first.stderr);
12324        let second = run_bash_in(
12325            work_b.path(),
12326            &format!("test ! -e /tmp/{name} && echo b > /tmp/{name} && cat /tmp/{name}"),
12327            &policy,
12328            &b,
12329        );
12330        assert_eq!(second.exit_code, 0, "{}", second.stderr);
12331        assert_eq!(second.stdout, "b\n");
12332        let again = run_bash_in(work_a.path(), &format!("cat /tmp/{name}"), &policy, &a);
12333        assert_eq!(again.stdout, "a\n", "{}", again.stderr);
12334        assert_ne!(a.temp_dir_path(), b.temp_dir_path());
12335        assert!(!Path::new("/tmp").join(&name).exists());
12336    }
12337
12338    /// With the workdir, the runtime and records directories and `HOME` all beneath the host's
12339    /// `/tmp`, every one of them is still reached at its own path while `/tmp` is the folder.
12340    #[test]
12341    fn a_workdir_under_tmp_stays_reachable_when_tmp_is_mapped() {
12342        const TEST: &str = "a_workdir_under_tmp_stays_reachable_when_tmp_is_mapped";
12343        let base = tempfile::tempdir().unwrap();
12344        let workdir = tempfile::tempdir().unwrap();
12345        assert!(workdir.path().starts_with(crate::temp_dir::canonical_tmp()));
12346        let policy = coreutils_policy(&[]);
12347        let Some(enforcement) = mapped_enforcement(
12348            TEST,
12349            &policy,
12350            murmur_artifact::ContainmentClass::Scoped,
12351            base.path(),
12352            workdir.path(),
12353        ) else {
12354            return;
12355        };
12356        std::fs::write(
12357            enforcement.runtime_dir_path().unwrap().join("probe"),
12358            b"runtime\n",
12359        )
12360        .unwrap();
12361        let name = unique_tmp_name("under");
12362        let result = run_bash_in(
12363            workdir.path(),
12364            &format!(
12365                "echo a > \"$(pwd)/f\" && cat \"$(pwd)/f\" && cat \"$MURMUR_RUNTIME_DIR/probe\" && \
12366                 echo h > \"$HOME/h\" && cat \"$HOME/h\" && echo t > /tmp/{name} && \
12367                 cat /tmp/{name}"
12368            ),
12369            &policy,
12370            &enforcement,
12371        );
12372        assert_eq!(result.exit_code, 0, "{}", result.stderr);
12373        assert_eq!(result.stdout, "a\nruntime\nh\nt\n");
12374        assert_eq!(
12375            std::fs::read_to_string(workdir.path().join("f")).unwrap(),
12376            "a\n"
12377        );
12378
12379        // Only the paths the command needs are re-exposed, and only the workdir writably: a
12380        // sibling of the records directory is absent, and the records directory refuses a write
12381        // although it now lies beneath the folder's writable rule.
12382        let sibling = base.path().join("sibling");
12383        std::fs::write(&sibling, b"host\n").unwrap();
12384        let record = enforcement.records_dir_path().unwrap().join("trace.jsonl");
12385        std::fs::write(&record, b"{}\n").unwrap();
12386        let refused = run_bash_in(
12387            workdir.path(),
12388            &format!(
12389                "test ! -e {sibling} && echo absent; echo z >> \"$MURMUR_RECORDS_DIR/trace.jsonl\"",
12390                sibling = sibling.display()
12391            ),
12392            &policy,
12393            &enforcement,
12394        );
12395        assert!(refused.stdout.starts_with("absent\n"), "{}", refused.stderr);
12396        assert_ne!(refused.exit_code, 0);
12397        assert!(
12398            refused.stderr.contains("Read-only file system"),
12399            "{}",
12400            refused.stderr
12401        );
12402        assert_eq!(std::fs::read(&record).unwrap(), b"{}\n");
12403        let folder = enforcement.temp_dir_path().unwrap();
12404        assert_eq!(std::fs::read_to_string(folder.join(&name)).unwrap(), "t\n");
12405        assert!(!Path::new("/tmp").join(&name).exists());
12406    }
12407
12408    /// Without a usable mount namespace, `KernelFull` maps nothing: a literal `/tmp` write is
12409    /// refused by Landlock, and `$TMPDIR` is the folder's absolute path and works.
12410    #[test]
12411    fn without_a_mount_namespace_tmp_is_refused_and_tmpdir_works() {
12412        const TEST: &str = "without_a_mount_namespace_tmp_is_refused_and_tmpdir_works";
12413        let base = home_tempdir();
12414        let workdir = home_tempdir();
12415        let policy = coreutils_policy(&[]);
12416        let Some(enforcement) =
12417            unmapped_kernel_full_enforcement(TEST, &policy, base.path(), workdir.path())
12418        else {
12419            return;
12420        };
12421        let name = unique_tmp_name("refused");
12422        let refused = run_bash_in(
12423            workdir.path(),
12424            &format!("echo y > /tmp/{name}"),
12425            &policy,
12426            &enforcement,
12427        );
12428        assert_ne!(refused.exit_code, 0);
12429        assert!(
12430            refused.stderr.contains("Permission denied"),
12431            "{}",
12432            refused.stderr
12433        );
12434        assert!(!Path::new("/tmp").join(&name).exists());
12435
12436        let folder = enforcement.temp_dir_path().unwrap().to_path_buf();
12437        let works = run_bash_in(
12438            workdir.path(),
12439            "echo z > \"$TMPDIR/m\" && cat \"$TMPDIR/m\" && printf '%s\\n' \"$TMPDIR\" \"$TMP\" \
12440             \"$TEMP\"",
12441            &policy,
12442            &enforcement,
12443        );
12444        assert_eq!(works.exit_code, 0, "{}", works.stderr);
12445        let folder_text = folder.to_string_lossy();
12446        assert_eq!(
12447            works.stdout,
12448            format!("z\n{folder_text}\n{folder_text}\n{folder_text}\n")
12449        );
12450        assert_eq!(std::fs::read_to_string(folder.join("m")).unwrap(), "z\n");
12451    }
12452
12453    /// The records directory stays read-only apart from `tmp/`: no record can be linked, moved
12454    /// or appended into the folder, and the folder itself cannot be renamed — mapped and not.
12455    #[test]
12456    fn a_record_cannot_be_moved_or_linked_into_tmp() {
12457        const TEST: &str = "a_record_cannot_be_moved_or_linked_into_tmp";
12458        let policy = coreutils_policy(&[]);
12459        for mapped in [true, false] {
12460            let base = home_tempdir();
12461            let workdir = home_tempdir();
12462            let enforcement = if mapped {
12463                mapped_enforcement(
12464                    TEST,
12465                    &policy,
12466                    murmur_artifact::ContainmentClass::Scoped,
12467                    base.path(),
12468                    workdir.path(),
12469                )
12470            } else {
12471                unmapped_kernel_full_enforcement(TEST, &policy, base.path(), workdir.path())
12472            };
12473            let Some(enforcement) = enforcement else {
12474                return;
12475            };
12476            let records = enforcement.records_dir_path().unwrap().to_path_buf();
12477            let record = records.join("trace.jsonl");
12478            std::fs::write(&record, b"{\"event\":\"session_start\"}\n").unwrap();
12479            let before = std::fs::read(&record).unwrap();
12480            for script in [
12481                "ln \"$MURMUR_RECORDS_DIR/trace.jsonl\" /tmp/l",
12482                "ln \"$MURMUR_RECORDS_DIR/trace.jsonl\" \"$TMPDIR/l2\"",
12483                "ln \"$MURMUR_RECORDS_DIR/trace.jsonl\" \"$MURMUR_RECORDS_DIR/tmp/l3\"",
12484                "mv \"$MURMUR_RECORDS_DIR/trace.jsonl\" \"$TMPDIR/m\"",
12485                "mv \"$MURMUR_RECORDS_DIR/trace.jsonl\" \"$MURMUR_RECORDS_DIR/tmp/m2\"",
12486                "echo z >> \"$MURMUR_RECORDS_DIR/trace.jsonl\"",
12487                "mv \"$MURMUR_RECORDS_DIR/tmp\" \"$MURMUR_RECORDS_DIR/tmp-moved\"",
12488            ] {
12489                let result = run_bash_in(workdir.path(), script, &policy, &enforcement);
12490                assert_ne!(
12491                    result.exit_code, 0,
12492                    "mapped: {mapped}: `{script}` succeeded"
12493                );
12494            }
12495            assert_eq!(std::fs::read(&record).unwrap(), before, "mapped: {mapped}");
12496            assert!(records
12497                .join(crate::temp_dir::SESSION_TEMP_DIR_NAME)
12498                .is_dir());
12499            assert!(!records.join("tmp-moved").exists());
12500        }
12501    }
12502
12503    /// The folder carries the workdir's right-set, `Execute` axis included: a binary copied into
12504    /// `/tmp` runs only with `workdir_exec: true`.
12505    #[test]
12506    fn tmp_follows_workdir_exec() {
12507        const TEST: &str = "tmp_follows_workdir_exec";
12508        for (workdir_exec, expected) in [(false, 126), (true, 0)] {
12509            let base = home_tempdir();
12510            let workdir = home_tempdir();
12511            let policy = CapabilityPolicy {
12512                workdir_exec_allowed: workdir_exec,
12513                ..coreutils_policy(&["cp", "true"])
12514            };
12515            let Some(enforcement) = mapped_enforcement(
12516                TEST,
12517                &policy,
12518                murmur_artifact::ContainmentClass::Advisory,
12519                base.path(),
12520                workdir.path(),
12521            ) else {
12522                return;
12523            };
12524            let result = run_bash_in(
12525                workdir.path(),
12526                "cp /bin/true /tmp/t && /tmp/t",
12527                &policy,
12528                &enforcement,
12529            );
12530            assert_eq!(
12531                result.exit_code, expected,
12532                "workdir_exec: {workdir_exec}: {}",
12533                result.stderr
12534            );
12535            if !workdir_exec {
12536                assert!(
12537                    result.stderr.contains("Permission denied"),
12538                    "{}",
12539                    result.stderr
12540                );
12541            }
12542        }
12543    }
12544
12545    /// A mapping that cannot be built fails the spawn with its own error and runs nothing.
12546    #[test]
12547    fn a_failed_tmp_mapping_spawns_nothing() {
12548        const TEST: &str = "a_failed_tmp_mapping_spawns_nothing";
12549        let base = home_tempdir();
12550        let workdir = home_tempdir();
12551        let policy = coreutils_policy(&[]);
12552        let Some(enforcement) = mapped_enforcement(
12553            TEST,
12554            &policy,
12555            murmur_artifact::ContainmentClass::Scoped,
12556            base.path(),
12557            workdir.path(),
12558        ) else {
12559            return;
12560        };
12561        let error = {
12562            let _forced = ForceTmpMapFailureGuard::new();
12563            crate::shell::execute_shell(
12564                "bash",
12565                &["-c", "echo ran > \"$(pwd)/marker\""],
12566                &[],
12567                &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
12568                &policy,
12569                &enforcement,
12570            )
12571            .expect_err("a failed mapping fails the spawn")
12572        };
12573        let message = error.to_string();
12574        println!("{message}");
12575        assert!(
12576            message.contains("could not map /tmp onto this session's temporary folder"),
12577            "{message}"
12578        );
12579        assert!(
12580            matches!(error, crate::shell::ShellExecError::Failed(_)),
12581            "an ordinary spawn failure, not a session-fatal one: {error:?}"
12582        );
12583        assert!(!workdir.path().join("marker").exists(), "the command ran");
12584    }
12585
12586    /// A link swapped onto the folder's path after the session holds it redirects no bind: the
12587    /// child's open reaches another directory, and the spawn fails naming the step.
12588    #[test]
12589    fn a_link_swapped_onto_the_temp_folder_redirects_no_bind() {
12590        const TEST: &str = "a_link_swapped_onto_the_temp_folder_redirects_no_bind";
12591        let base = home_tempdir();
12592        let workdir = home_tempdir();
12593        let policy = coreutils_policy(&[]);
12594        let Some(enforcement) = mapped_enforcement(
12595            TEST,
12596            &policy,
12597            murmur_artifact::ContainmentClass::Scoped,
12598            base.path(),
12599            workdir.path(),
12600        ) else {
12601            return;
12602        };
12603        let folder = enforcement.temp_dir_path().unwrap().to_path_buf();
12604        let decoy = base.path().join("decoy");
12605        std::fs::create_dir(&decoy).unwrap();
12606        std::fs::rename(&folder, base.path().join("folder-moved")).unwrap();
12607        std::os::unix::fs::symlink(&decoy, &folder).unwrap();
12608
12609        let error = crate::shell::execute_shell(
12610            "bash",
12611            &["-c", "echo x > /tmp/swapped"],
12612            &[],
12613            &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
12614            &policy,
12615            &enforcement,
12616        )
12617        .expect_err("a swapped folder fails the spawn")
12618        .to_string();
12619        println!("{error}");
12620        assert!(
12621            error.contains("could not map /tmp onto this session's temporary folder"),
12622            "{error}"
12623        );
12624        assert!(error.contains("opening the temporary folder"), "{error}");
12625        assert!(
12626            error.contains(&std::io::Error::from_raw_os_error(libc::ESTALE).to_string()),
12627            "{error}"
12628        );
12629        assert_eq!(
12630            std::fs::read_dir(&decoy).unwrap().count(),
12631            0,
12632            "the link's target was written"
12633        );
12634    }
12635
12636    /// A policy allowlisting `binary` with its interpreter runtime derived the way
12637    /// `stage_session` derives it, `work` excluded as the capsule-writable root, through a host
12638    /// facts cache of its own.
12639    fn derived_interpreter_policy(binary: &str, work: &Path) -> CapabilityPolicy {
12640        let mut policy = CapabilityPolicy {
12641            shell_allow: vec![binary.to_string()],
12642            ..CapabilityPolicy::default()
12643        };
12644        let cache = tempfile::tempdir().unwrap();
12645        policy.shell_derived_interpreter_runtime =
12646            crate::interpreter_runtime::derive_interpreter_runtimes(
12647                &policy.shell_allow,
12648                &[],
12649                &[work],
12650                &crate::host_facts::HostFactsCache::at(cache.path().join("host_facts")),
12651            )
12652            .runtimes;
12653        policy
12654    }
12655
12656    /// What `program` prints when this host's `python3` runs it outside any sandbox.
12657    fn unsandboxed_python(program: &str) -> String {
12658        let output = std::process::Command::new("python3")
12659            .args(["-I", "-c", program])
12660            .output()
12661            .unwrap();
12662        assert!(output.status.success(), "{output:?}");
12663        String::from_utf8(output.stdout).unwrap().trim().to_string()
12664    }
12665
12666    /// Asserts the derived runtime holds the directories `encodings` and `_sqlite3` load from on
12667    /// this host, as the interpreter itself reports them, and returns the first: the stdlib.
12668    fn assert_derives_stdlib_and_sqlite(policy: &CapabilityPolicy) -> PathBuf {
12669        let stdlib = PathBuf::from(unsandboxed_python(
12670            "import encodings, os; print(os.path.dirname(os.path.dirname(encodings.__file__)))",
12671        ));
12672        let dynload = PathBuf::from(unsandboxed_python(
12673            "import _sqlite3, os; print(os.path.dirname(os.path.realpath(_sqlite3.__file__)))",
12674        ));
12675        let derived = &policy.shell_derived_interpreter_runtime;
12676        println!("derived: {derived:#?}");
12677        let dirs: Vec<&PathBuf> = derived.iter().flat_map(|runtime| &runtime.dirs).collect();
12678        let canonical = |path: &Path| std::fs::canonicalize(path).unwrap();
12679        assert!(
12680            dirs.contains(&&canonical(&stdlib)),
12681            "{stdlib:?} not in {dirs:?}"
12682        );
12683        assert!(
12684            dirs.contains(&&canonical(&dynload)),
12685            "{dynload:?} not in {dirs:?}"
12686        );
12687        canonical(&stdlib)
12688    }
12689
12690    /// The program that opens `secret` and lists `parent`, each in its own `try`, printing the
12691    /// exception class name or `allowed`.
12692    fn python_probe_outside(secret: &Path, parent: &Path) -> String {
12693        format!(
12694            "import os\n\
12695             for label, act in (('open', lambda: open({secret:?}).read()), \
12696             ('listdir', lambda: os.listdir({parent:?}))):\n\
12697             \x20   try:\n\
12698             \x20       act(); print(label, 'allowed')\n\
12699             \x20   except Exception as error:\n\
12700             \x20       print(label, type(error).__name__)\n",
12701            secret = secret.to_string_lossy(),
12702            parent = parent.to_string_lossy(),
12703        )
12704    }
12705
12706    fn execute(
12707        binary: &str,
12708        args: &[&str],
12709        accessible: &Path,
12710        policy: &CapabilityPolicy,
12711        enforcement: &ShellEnforcement,
12712    ) -> crate::shell::ShellResult {
12713        let result = crate::shell::execute_shell(
12714            binary,
12715            args,
12716            &[],
12717            &crate::workdir_fs::WorkdirRoot::open(accessible).unwrap(),
12718            policy,
12719            enforcement,
12720        )
12721        .unwrap();
12722        println!(
12723            "exit {}\n--- stdout\n{}--- stderr\n{}---",
12724            result.exit_code, result.stdout, result.stderr
12725        );
12726        result
12727    }
12728
12729    /// A policy allowlisting `shell_allow` and reaching `network_allow`, with the trust store
12730    /// derived the way `stage_session` derives it, `work` excluded as the capsule-writable root.
12731    fn trust_store_policy(
12732        shell_allow: &[&str],
12733        network_allow: &[&str],
12734        work: &Path,
12735    ) -> CapabilityPolicy {
12736        let mut policy = CapabilityPolicy {
12737            shell_allow: shell_allow.iter().map(|name| name.to_string()).collect(),
12738            network_allow: network_allow.iter().map(|rule| rule.to_string()).collect(),
12739            ..CapabilityPolicy::default()
12740        };
12741        policy.shell_trust_store = crate::trust_store::derive_trust_store(
12742            &policy.shell_allow,
12743            &policy.network_allow,
12744            &[work],
12745        );
12746        println!("derived trust store: {:#?}", policy.shell_trust_store);
12747        policy
12748    }
12749
12750    /// The first CA bundle file of the candidate table this host has.
12751    fn host_ca_bundle() -> Option<PathBuf> {
12752        crate::trust_store::TRUST_STORE_CANDIDATES
12753            .iter()
12754            .filter(|candidate| !candidate.directory)
12755            .map(|candidate| PathBuf::from(candidate.path))
12756            .find(|path| std::fs::metadata(path).is_ok_and(|m| m.is_file()))
12757    }
12758
12759    /// An allowlisted `curl` in a capsule with a network allowlist and no `interpreter_runtime`
12760    /// block verifies a server certificate under `scoped`.
12761    #[test]
12762    fn a_scoped_curl_with_network_allow_completes_an_https_request() {
12763        let test = "a_scoped_curl_with_network_allow_completes_an_https_request";
12764        if find_on_path("curl").is_none() {
12765            eprintln!("SKIP — PROVES NOTHING: {test} needs curl on PATH");
12766            return;
12767        }
12768        let unsandboxed = std::process::Command::new("curl")
12769            .args([
12770                "-sS",
12771                "-o",
12772                "/dev/null",
12773                "-w",
12774                "%{http_code}",
12775                "--max-time",
12776                "15",
12777            ])
12778            .arg("https://example.com/")
12779            .output()
12780            .unwrap();
12781        if String::from_utf8_lossy(&unsandboxed.stdout) != "200" {
12782            eprintln!(
12783                "SKIP — PROVES NOTHING: {test} needs https://example.com/ to answer 200 outside \
12784                 the sandbox; it gave {unsandboxed:?}"
12785            );
12786            return;
12787        }
12788        let (_base, work, _outside) = work_and_outside();
12789        let policy = trust_store_policy(&["curl"], &["example.com"], &work);
12790        assert!(policy.shell_interpreter_runtime.is_empty());
12791        assert!(
12792            !policy.shell_trust_store.is_empty(),
12793            "this host has a trust store the candidate table names"
12794        );
12795        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
12796            return;
12797        };
12798
12799        let fetched = execute(
12800            "curl",
12801            &[
12802                "-sS",
12803                "-o",
12804                "/dev/null",
12805                "-w",
12806                "%{http_code}",
12807                "--max-time",
12808                "30",
12809                "https://example.com/",
12810            ],
12811            &work,
12812            &policy,
12813            &enforcement,
12814        );
12815        enforcement.end_session();
12816        assert_eq!(fetched.exit_code, 0, "{}", fetched.stderr);
12817        assert_eq!(fetched.stdout, "200");
12818    }
12819
12820    /// The trust store reaches a command only when the capsule declares a network allowlist.
12821    #[test]
12822    fn a_scoped_command_reads_the_trust_store_only_with_network_allow() {
12823        let test = "a_scoped_command_reads_the_trust_store_only_with_network_allow";
12824        assert!(
12825            crate::trust_store::derive_trust_store(&[], &["example.com".into()], &[]).is_empty()
12826        );
12827        let Some(bundle) = host_ca_bundle() else {
12828            eprintln!("SKIP — PROVES NOTHING: {test} needs a CA bundle the candidate table names");
12829            return;
12830        };
12831        let bundle_arg = bundle.to_str().unwrap();
12832        let (_base, work, _outside) = work_and_outside();
12833
12834        let offline = trust_store_policy(&["cat"], &[], &work);
12835        assert!(offline.shell_trust_store.is_empty());
12836        let online = trust_store_policy(&["cat"], &["127.0.0.1"], &work);
12837        assert!(!online.shell_trust_store.is_empty());
12838        let Some(enforcement) = scoped_landlock_enforcement(test, &offline, 1) else {
12839            return;
12840        };
12841        for derived in &online.shell_trust_store {
12842            assert!(
12843                !enforcement
12844                    .landlock_grants
12845                    .iter()
12846                    .any(|grant| grant.path == derived.path),
12847                "{derived:?} granted without network.allow"
12848            );
12849        }
12850        let denied = execute("cat", &[bundle_arg], &work, &offline, &enforcement);
12851        enforcement.end_session();
12852        assert_ne!(denied.exit_code, 0);
12853        assert!(
12854            denied.stderr.contains("Permission denied"),
12855            "{}",
12856            denied.stderr
12857        );
12858
12859        let Some(enforcement) = scoped_landlock_enforcement(test, &online, 1) else {
12860            return;
12861        };
12862        let read = execute("cat", &[bundle_arg], &work, &online, &enforcement);
12863        enforcement.end_session();
12864        assert_eq!(read.exit_code, 0, "{}", read.stderr);
12865        assert!(
12866            read.stdout.starts_with("-----BEGIN CERTIFICATE-----"),
12867            "{}",
12868            &read.stdout[..read.stdout.len().min(200)]
12869        );
12870    }
12871
12872    /// The trust-store grant reaches the certificates and nothing beside them: not the directory
12873    /// above, its OpenSSL configuration or its private keys, and nothing is writable.
12874    #[test]
12875    fn the_trust_store_grant_reaches_nothing_beyond_the_trust_store() {
12876        let test = "the_trust_store_grant_reaches_nothing_beyond_the_trust_store";
12877        let (_base, work, _outside) = work_and_outside();
12878        let policy = trust_store_policy(&["cat", "ls", "bash"], &["127.0.0.1"], &work);
12879        for entry in &policy.shell_trust_store {
12880            for broad in ["/", "/etc", "/etc/ssl", "/etc/pki", "/etc/pki/tls"] {
12881                assert_ne!(entry.path, Path::new(broad));
12882            }
12883            assert!(
12884                !entry.path.components().any(|c| c.as_os_str() == "private"),
12885                "{entry:?}"
12886            );
12887        }
12888        assert!(resolve_trust_store_grants(&policy.shell_trust_store)
12889            .iter()
12890            .all(|grant| !grant.executable));
12891        if std::fs::metadata("/etc/ssl/certs").map_or(true, |m| !m.is_dir()) {
12892            eprintln!("SKIP — PROVES NOTHING: {test} needs /etc/ssl/certs on this host");
12893            return;
12894        }
12895        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
12896            return;
12897        };
12898
12899        let listed = execute("ls", &["/etc/ssl/certs"], &work, &policy, &enforcement);
12900        assert_eq!(listed.exit_code, 0, "{}", listed.stderr);
12901        let above = execute("ls", &["/etc/ssl"], &work, &policy, &enforcement);
12902        assert_ne!(above.exit_code, 0);
12903        assert!(
12904            above.stderr.contains("Permission denied"),
12905            "{}",
12906            above.stderr
12907        );
12908        if std::fs::metadata("/etc/ssl/openssl.cnf").is_ok() {
12909            let config = execute(
12910                "cat",
12911                &["/etc/ssl/openssl.cnf"],
12912                &work,
12913                &policy,
12914                &enforcement,
12915            );
12916            assert_ne!(config.exit_code, 0);
12917            assert!(
12918                config.stderr.contains("Permission denied"),
12919                "{}",
12920                config.stderr
12921            );
12922        }
12923        let private = execute("ls", &["/etc/ssl/private"], &work, &policy, &enforcement);
12924        assert_ne!(private.exit_code, 0);
12925        let probe = Path::new("/etc/ssl/certs/murmur-probe");
12926        let written = execute(
12927            "bash",
12928            &["-c", "echo x > /etc/ssl/certs/murmur-probe"],
12929            &work,
12930            &policy,
12931            &enforcement,
12932        );
12933        enforcement.end_session();
12934        assert_ne!(written.exit_code, 0);
12935        assert!(std::fs::symlink_metadata(probe).is_err());
12936    }
12937
12938    /// A `scoped` command cannot write into `~/.murmur/host_facts`: with the home outside the
12939    /// workdir, a shell redirection there is refused and leaves no file.
12940    #[test]
12941    fn a_scoped_command_cannot_write_the_host_facts_cache() {
12942        let test = "a_scoped_command_cannot_write_the_host_facts_cache";
12943        let home = home_tempdir();
12944        let workdir = home_tempdir();
12945        let cache = home.path().join(".murmur").join(
12946            crate::murmur_home::HomeEntryKind::HostFacts
12947                .file_name()
12948                .unwrap(),
12949        );
12950        crate::murmur_home::hold_private_dir(&home.path().join(".murmur")).unwrap();
12951        crate::murmur_home::hold_private_dir(&cache).unwrap();
12952        let policy = coreutils_policy(&[]);
12953        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
12954            return;
12955        };
12956        let probe = cache.join("probe");
12957        let written = run_bash_in(
12958            workdir.path(),
12959            &format!("printf x > '{}'", probe.display()),
12960            &policy,
12961            &enforcement,
12962        );
12963        enforcement.end_session();
12964        assert_ne!(written.exit_code, 0);
12965        assert!(
12966            written.stderr.contains("Permission denied"),
12967            "{}",
12968            written.stderr
12969        );
12970        assert!(std::fs::symlink_metadata(&probe).is_err());
12971    }
12972
12973    #[test]
12974    fn resolve_trust_store_grants_reads_and_lists_but_never_executes() {
12975        let paths = [
12976            crate::trust_store::TrustStorePath {
12977                path: PathBuf::from("/etc/ssl/certs"),
12978                directory: true,
12979            },
12980            crate::trust_store::TrustStorePath {
12981                path: PathBuf::from("/etc/pki/tls/cacert.pem"),
12982                directory: false,
12983            },
12984        ];
12985        assert_eq!(
12986            resolve_trust_store_grants(&paths),
12987            vec![
12988                LandlockGrant {
12989                    path: PathBuf::from("/etc/ssl/certs"),
12990                    list_dir: true,
12991                    executable: false,
12992                    pinned: None,
12993                },
12994                LandlockGrant {
12995                    path: PathBuf::from("/etc/pki/tls/cacert.pem"),
12996                    list_dir: false,
12997                    executable: false,
12998                    pinned: None,
12999                },
13000            ]
13001        );
13002        assert!(resolve_trust_store_grants(&[]).is_empty());
13003    }
13004
13005    /// `ShellEnforcement::resolve` adds exactly the policy's trust-store grants on both Landlock
13006    /// tiers, whatever the host, and leaves the sealed `/etc` grants in place.
13007    #[test]
13008    fn shell_enforcement_resolve_adds_the_trust_store() {
13009        use murmur_artifact::ContainmentClass;
13010        let store = vec![
13011            crate::trust_store::TrustStorePath {
13012                path: PathBuf::from("/murmur-test/certs"),
13013                directory: true,
13014            },
13015            crate::trust_store::TrustStorePath {
13016                path: PathBuf::from("/murmur-test/bundle.pem"),
13017                directory: false,
13018            },
13019        ];
13020        let expected = resolve_trust_store_grants(&store);
13021        let with_store = CapabilityPolicy {
13022            shell_trust_store: store.clone(),
13023            ..CapabilityPolicy::default()
13024        };
13025        let without = CapabilityPolicy::default();
13026        for (tier, declared) in [
13027            (EnforcementTier::KernelFull, ContainmentClass::Scoped),
13028            (EnforcementTier::KernelSealed, ContainmentClass::Sealed),
13029        ] {
13030            let decision =
13031                crate::containment::ContainmentDecision::decide(tier, declared, false, false);
13032            let granted = |policy: &CapabilityPolicy| {
13033                let enforcement =
13034                    ShellEnforcement::resolve(policy, &decision, HostProbe::probe()).unwrap();
13035                assert_eq!(enforcement.tier, tier);
13036                enforcement.landlock_grants
13037            };
13038            let with_grants = granted(&with_store);
13039            let without_grants = granted(&without);
13040            for grant in &expected {
13041                assert_eq!(
13042                    with_grants.iter().filter(|g| *g == grant).count(),
13043                    1,
13044                    "{tier:?}: {grant:?}"
13045                );
13046            }
13047            assert!(without_grants
13048                .iter()
13049                .all(|grant| !grant.path.starts_with("/murmur-test")));
13050            assert_eq!(with_grants.len(), without_grants.len() + expected.len());
13051            let sealed_etc = resolve_sealed_etc_landlock_grants(tier);
13052            assert_eq!(sealed_etc.is_empty(), tier != EnforcementTier::KernelSealed);
13053            for grant in &sealed_etc {
13054                assert!(with_grants.contains(grant), "{tier:?}: {grant:?}");
13055            }
13056        }
13057    }
13058
13059    /// An allowlisted `python3` with no `interpreter_runtime` block imports its standard library
13060    /// and a C extension needing a library outside its own closure under `scoped`, while a file
13061    /// outside its runtime and the directory above its stdlib stay denied.
13062    #[test]
13063    fn a_scoped_python3_imports_its_stdlib_and_nothing_outside_it() {
13064        let test = "a_scoped_python3_imports_its_stdlib_and_nothing_outside_it";
13065        if find_on_path("python3").is_none() {
13066            println!("[SKIP] {test}: no python3 on PATH");
13067            return;
13068        }
13069        let (_base, work, outside) = work_and_outside();
13070        let policy = derived_interpreter_policy("python3", &work);
13071        let stdlib = assert_derives_stdlib_and_sqlite(&policy);
13072        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
13073            return;
13074        };
13075
13076        let imported = execute(
13077            "python3",
13078            &["-c", "import json, sqlite3, venv; print('imported')"],
13079            &work,
13080            &policy,
13081            &enforcement,
13082        );
13083        assert_eq!(imported.exit_code, 0, "{}", imported.stderr);
13084        assert_eq!(imported.stdout.trim(), "imported");
13085
13086        let secret = outside.join("secret.txt");
13087        std::fs::write(&secret, "secret").unwrap();
13088        let probe = python_probe_outside(&secret, stdlib.parent().unwrap());
13089        let denied = execute("python3", &["-c", &probe], &work, &policy, &enforcement);
13090        assert_eq!(denied.exit_code, 0, "{}", denied.stderr);
13091        assert_eq!(
13092            denied.stdout.lines().collect::<Vec<_>>(),
13093            vec!["open PermissionError", "listdir PermissionError"]
13094        );
13095    }
13096
13097    /// The same interpreter under `sealed`: it imports what it imported under `scoped`, and the
13098    /// file outside its runtime is neither readable nor present in the composed root.
13099    #[test]
13100    fn a_sealed_python3_imports_its_stdlib_and_nothing_outside_it() {
13101        let test = "a_sealed_python3_imports_its_stdlib_and_nothing_outside_it";
13102        if crate::network_namespace::skip_without_egress_namespace(test) {
13103            return;
13104        }
13105        if find_on_path("python3").is_none() {
13106            println!("[SKIP] {test}: no python3 on PATH");
13107            return;
13108        }
13109        let probe = HostProbe::probe();
13110        if probe.tier() != EnforcementTier::KernelSealed {
13111            eprintln!(
13112                "SKIP — PROVES NOTHING ABOUT THE SEALED ROOT ON THIS HOST: {test} needs a host \
13113                 that reaches KernelSealed; detected {:?}. No composed root is built in this run.",
13114                probe.tier()
13115            );
13116            return;
13117        }
13118        let (_base, work, outside) = work_and_outside();
13119        let policy = derived_interpreter_policy("python3", &work);
13120        assert_derives_stdlib_and_sqlite(&policy);
13121        let runtime = tempfile::tempdir().unwrap();
13122        let enforcement = ShellEnforcement::resolve_declared(
13123            &policy,
13124            murmur_artifact::ContainmentClass::Sealed,
13125            probe,
13126        )
13127        .expect("a sealed-capable host resolves sealed enforcement")
13128        .with_runtime_dir(test_runtime_dir(runtime.path()), None);
13129        assert_eq!(enforcement.tier, EnforcementTier::KernelSealed);
13130
13131        let imported = execute(
13132            "python3",
13133            &["-c", "import json, sqlite3, venv; print('imported')"],
13134            &work,
13135            &policy,
13136            &enforcement,
13137        );
13138        assert_eq!(imported.exit_code, 0, "{}", imported.stderr);
13139        assert_eq!(imported.stdout.trim(), "imported");
13140
13141        let secret = outside.join("secret.txt");
13142        std::fs::write(&secret, "secret").unwrap();
13143        let program = format!(
13144            "try:\n    open({:?}).read(); print('allowed')\nexcept Exception as error:\n    \
13145             print(type(error).__name__)\n",
13146            secret.to_string_lossy()
13147        );
13148        let denied = execute("python3", &["-c", &program], &work, &policy, &enforcement);
13149        assert_eq!(denied.exit_code, 0, "{}", denied.stderr);
13150        let outcome = denied.stdout.trim();
13151        assert!(
13152            outcome == "PermissionError" || outcome == "FileNotFoundError",
13153            "{outcome}"
13154        );
13155    }
13156
13157    /// An allowlisted `perl` with no `interpreter_runtime` block loads `POSIX`, an XS module, out
13158    /// of its own `@INC` under `scoped`.
13159    #[test]
13160    fn a_scoped_perl_loads_posix_from_its_inc() {
13161        let test = "a_scoped_perl_loads_posix_from_its_inc";
13162        if find_on_path("perl").is_none() {
13163            println!("[SKIP] {test}: no perl on PATH");
13164            return;
13165        }
13166        let (_base, work, _outside) = work_and_outside();
13167        let policy = derived_interpreter_policy("perl", &work);
13168        assert!(
13169            !policy.shell_derived_interpreter_runtime.is_empty(),
13170            "perl was not recognised or reported nothing"
13171        );
13172        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
13173            return;
13174        };
13175        let result = execute(
13176            "perl",
13177            &["-MPOSIX", "-e", "print qq{loaded\n}"],
13178            &work,
13179            &policy,
13180            &enforcement,
13181        );
13182        assert_eq!(result.exit_code, 0, "{}", result.stderr);
13183        assert_eq!(result.stdout.trim(), "loaded");
13184    }
13185
13186    // ---------------------------------------------------------------- host paths
13187
13188    /// The rights each host-path rule is built with: `ReadFile | ReadDir` for `read`, the
13189    /// native tool store's right-set for `read-write`, and `Execute` never.
13190    #[test]
13191    fn host_path_landlock_rights_are_read_list_or_the_workdir_set_without_execute() {
13192        use landlock::AccessFs;
13193        assert_eq!(
13194            linux_enforce::host_path_access_rights(false),
13195            AccessFs::ReadFile | AccessFs::ReadDir
13196        );
13197        assert_eq!(
13198            linux_enforce::host_path_access_rights(true),
13199            linux_enforce::workdir_access_rights(false)
13200        );
13201        for read_write in [false, true] {
13202            assert!(!linux_enforce::host_path_access_rights(read_write).contains(AccessFs::Execute));
13203        }
13204    }
13205
13206    /// `ro/` holding `in.txt`, `rw/` and `outside/` holding `secret.txt`, under `HOME` (never
13207    /// `/tmp`, which a host path may not be under), with the two host paths a test lends.
13208    struct HostPathDirs {
13209        _base: tempfile::TempDir,
13210        ro: PathBuf,
13211        rw: PathBuf,
13212        outside: PathBuf,
13213    }
13214
13215    impl HostPathDirs {
13216        fn new() -> Self {
13217            let base = home_tempdir();
13218            let root = std::fs::canonicalize(base.path()).unwrap();
13219            let (ro, rw, outside) = (root.join("ro"), root.join("rw"), root.join("outside"));
13220            for dir in [&ro, &rw, &outside] {
13221                std::fs::create_dir(dir).unwrap();
13222            }
13223            std::fs::write(ro.join("in.txt"), "lent\n").unwrap();
13224            std::fs::write(outside.join("secret.txt"), "secret\n").unwrap();
13225            Self {
13226                _base: base,
13227                ro,
13228                rw,
13229                outside,
13230            }
13231        }
13232
13233        fn host_paths(&self) -> Vec<crate::host_paths::HostPath> {
13234            use crate::host_paths::{HostPath, HostPathAccess};
13235            vec![
13236                HostPath {
13237                    declared: self.ro.display().to_string(),
13238                    path: self.ro.clone(),
13239                    access: HostPathAccess::Read,
13240                    credential_locations: Vec::new(),
13241                },
13242                HostPath {
13243                    declared: self.rw.display().to_string(),
13244                    path: self.rw.clone(),
13245                    access: HostPathAccess::ReadWrite,
13246                    credential_locations: Vec::new(),
13247                },
13248            ]
13249        }
13250
13251        /// Four builtin-only commands — read `ro/`, write `ro/`, write `rw/`, read `outside/` — run
13252        /// on `enforcement`, each asserted.
13253        fn assert_reach(&self, enforcement: &ShellEnforcement, policy: &CapabilityPolicy) {
13254            let workdir = tempfile::tempdir().unwrap();
13255            let read = run_bash_in(
13256                workdir.path(),
13257                &format!(
13258                    "read -r line < '{}/in.txt' && echo \"$line\"",
13259                    self.ro.display()
13260                ),
13261                policy,
13262                enforcement,
13263            );
13264            assert_eq!(read.exit_code, 0, "{}", read.stderr);
13265            assert!(
13266                read.stdout.lines().any(|line| line == "lent"),
13267                "{}",
13268                read.stdout
13269            );
13270
13271            let write_ro = run_bash_in(
13272                workdir.path(),
13273                &format!("echo x > '{}/new'", self.ro.display()),
13274                policy,
13275                enforcement,
13276            );
13277            assert_ne!(write_ro.exit_code, 0);
13278            assert!(
13279                write_ro.stderr.contains("Permission denied")
13280                    || write_ro.stderr.contains("Read-only file system"),
13281                "{}",
13282                write_ro.stderr
13283            );
13284            assert!(!self.ro.join("new").exists());
13285
13286            let write_rw = run_bash_in(
13287                workdir.path(),
13288                &format!("echo written > '{}/new'", self.rw.display()),
13289                policy,
13290                enforcement,
13291            );
13292            assert_eq!(write_rw.exit_code, 0, "{}", write_rw.stderr);
13293            assert_eq!(
13294                std::fs::read_to_string(self.rw.join("new")).unwrap(),
13295                "written\n"
13296            );
13297
13298            let outside = run_bash_in(
13299                workdir.path(),
13300                &format!("read -r line < '{}/secret.txt'", self.outside.display()),
13301                policy,
13302                enforcement,
13303            );
13304            assert_ne!(outside.exit_code, 0, "outside/secret.txt was read");
13305        }
13306    }
13307
13308    /// The enforcement a capsule declaring `class` gets here with `host_paths`, holding a session's
13309    /// directories, or `None` after a skip line when this host does not reach `tier`.
13310    fn host_path_enforcement(
13311        test: &str,
13312        policy: &CapabilityPolicy,
13313        class: murmur_artifact::ContainmentClass,
13314        tier: EnforcementTier,
13315    ) -> Option<(ShellEnforcement, tempfile::TempDir)> {
13316        if crate::network_namespace::skip_without_egress_namespace(test) {
13317            return None;
13318        }
13319        let probe = HostProbe::probe();
13320        let reaches = match tier {
13321            EnforcementTier::KernelSealed => probe.tier() == EnforcementTier::KernelSealed,
13322            _ => matches!(
13323                probe.tier(),
13324                EnforcementTier::KernelFull | EnforcementTier::KernelSealed
13325            ),
13326        };
13327        if !reaches {
13328            eprintln!(
13329                "SKIP — PROVES NOTHING ON THIS HOST: {test} needs {tier:?}; detected {:?}",
13330                probe.tier()
13331            );
13332            return None;
13333        }
13334        let runtime = home_tempdir();
13335        let enforcement = ShellEnforcement::resolve_declared(policy, class, probe)
13336            .expect("the enforcement resolves")
13337            .with_runtime_dir(test_runtime_dir(runtime.path()), None);
13338        assert_eq!(enforcement.tier, tier);
13339        eprintln!("{test}: tier {tier:?}");
13340        Some((enforcement, runtime))
13341    }
13342
13343    /// [`ShellEnforcement::resolve`] for a capsule declaring `class` on a host of `tier`, from no
13344    /// probe: a resolution test that spawns nothing.
13345    fn resolve_on_tier(
13346        policy: &CapabilityPolicy,
13347        tier: EnforcementTier,
13348        class: murmur_artifact::ContainmentClass,
13349    ) -> Result<ShellEnforcement, String> {
13350        let decision = crate::containment::ContainmentDecision::decide(tier, class, false, false);
13351        let probe = HostProbe::from_parts(
13352            None,
13353            crate::namespace_probe::NamespaceReading {
13354                network: Err(crate::network_namespace::NamespaceAttemptError::Creator(
13355                    "not probed".to_string(),
13356                )),
13357                mount: crate::namespace_probe::MountAttempt::NotAttempted,
13358                sealed_root: crate::namespace_probe::MountAttempt::NotAttempted,
13359                userns_grant: crate::sealed::UsernsGrant::Withheld,
13360                attachment: crate::sealed::ProfileAttachment::NotReported,
13361            },
13362        );
13363        ShellEnforcement::resolve(policy, &decision, probe)
13364    }
13365
13366    #[test]
13367    fn host_paths_on_kernel_full_read_write_and_outside() {
13368        const TEST: &str = "host_paths_on_kernel_full_read_write_and_outside";
13369        let dirs = HostPathDirs::new();
13370        let policy = CapabilityPolicy {
13371            shell_allow: vec!["bash".to_string()],
13372            host_paths: dirs.host_paths(),
13373            ..CapabilityPolicy::default()
13374        };
13375        let Some((enforcement, _runtime)) = host_path_enforcement(
13376            TEST,
13377            &policy,
13378            murmur_artifact::ContainmentClass::Scoped,
13379            EnforcementTier::KernelFull,
13380        ) else {
13381            return;
13382        };
13383        dirs.assert_reach(&enforcement, &policy);
13384    }
13385
13386    #[test]
13387    fn host_paths_on_kernel_sealed_are_bound_at_their_own_path() {
13388        const TEST: &str = "host_paths_on_kernel_sealed_are_bound_at_their_own_path";
13389        let dirs = HostPathDirs::new();
13390        let policy = CapabilityPolicy {
13391            shell_allow: vec!["bash".to_string()],
13392            host_paths: dirs.host_paths(),
13393            ..CapabilityPolicy::default()
13394        };
13395        let Some((enforcement, _runtime)) = host_path_enforcement(
13396            TEST,
13397            &policy,
13398            murmur_artifact::ContainmentClass::Sealed,
13399            EnforcementTier::KernelSealed,
13400        ) else {
13401            return;
13402        };
13403        dirs.assert_reach(&enforcement, &policy);
13404        // Outside is not denied but absent: the composed root does not hold it.
13405        let workdir = tempfile::tempdir().unwrap();
13406        let absent = run_bash_in(
13407            workdir.path(),
13408            &format!(
13409                "[ -e '{}/secret.txt' ] && echo present || echo absent",
13410                dirs.outside.display()
13411            ),
13412            &policy,
13413            &enforcement,
13414        );
13415        assert!(
13416            absent.stdout.lines().any(|line| line == "absent"),
13417            "{}",
13418            absent.stdout
13419        );
13420    }
13421
13422    /// A read-write host path holding a folder the session may run from: refused when resolving
13423    /// a `KernelFull` enforcement, and on `KernelSealed` that folder is bound read-only on top,
13424    /// so the capsule cannot rewrite what it runs while the rest of the entry stays writable.
13425    #[test]
13426    fn a_read_write_host_path_over_an_execute_root_is_refused_scoped_and_protected_sealed() {
13427        const TEST: &str =
13428            "a_read_write_host_path_over_an_execute_root_is_refused_scoped_and_protected_sealed";
13429        let base = home_tempdir();
13430        let tools = std::fs::canonicalize(base.path()).unwrap().join("tools");
13431        let bin = tools.join("bin");
13432        std::fs::create_dir_all(&bin).unwrap();
13433        std::fs::write(bin.join("tool"), "#!/bin/bash\necho tool ran\n").unwrap();
13434        let policy = CapabilityPolicy {
13435            shell_allow: vec!["bash".to_string()],
13436            shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
13437                binary: "bash".to_string(),
13438                dirs: vec![murmur_artifact::InterpreterRuntimeDir {
13439                    path: bin.display().to_string(),
13440                    list_dir: true,
13441                }],
13442            }],
13443            host_paths: vec![crate::host_paths::HostPath {
13444                declared: tools.display().to_string(),
13445                path: tools.clone(),
13446                access: crate::host_paths::HostPathAccess::ReadWrite,
13447                credential_locations: Vec::new(),
13448            }],
13449            ..CapabilityPolicy::default()
13450        };
13451        let error = resolve_on_tier(
13452            &policy,
13453            EnforcementTier::KernelFull,
13454            murmur_artifact::ContainmentClass::Scoped,
13455        )
13456        .expect_err("scoped refuses a read-write host path over an execute root");
13457        assert!(error.contains(&bin.display().to_string()), "{error}");
13458
13459        let Some((enforcement, _runtime)) = host_path_enforcement(
13460            TEST,
13461            &policy,
13462            murmur_artifact::ContainmentClass::Sealed,
13463            EnforcementTier::KernelSealed,
13464        ) else {
13465            return;
13466        };
13467        assert_eq!(enforcement.host_path_read_only_binds, vec![bin.clone()]);
13468        let workdir = tempfile::tempdir().unwrap();
13469        let rewrite = run_bash_in(
13470            workdir.path(),
13471            &format!("echo x > '{}/tool'", bin.display()),
13472            &policy,
13473            &enforcement,
13474        );
13475        assert_ne!(rewrite.exit_code, 0);
13476        assert!(
13477            rewrite.stderr.contains("Read-only file system"),
13478            "{}",
13479            rewrite.stderr
13480        );
13481        let add = run_bash_in(
13482            workdir.path(),
13483            &format!("echo x > '{}/new'", bin.display()),
13484            &policy,
13485            &enforcement,
13486        );
13487        assert_ne!(add.exit_code, 0);
13488        assert!(
13489            add.stderr.contains("Read-only file system"),
13490            "{}",
13491            add.stderr
13492        );
13493        assert_eq!(
13494            std::fs::read_to_string(bin.join("tool")).unwrap(),
13495            "#!/bin/bash\necho tool ran\n"
13496        );
13497        assert!(!bin.join("new").exists());
13498        let elsewhere = run_bash_in(
13499            workdir.path(),
13500            &format!("echo kept > '{}/notes'", tools.display()),
13501            &policy,
13502            &enforcement,
13503        );
13504        assert_eq!(elsewhere.exit_code, 0, "{}", elsewhere.stderr);
13505        assert_eq!(
13506            std::fs::read_to_string(tools.join("notes")).unwrap(),
13507            "kept\n"
13508        );
13509    }
13510
13511    /// A host path that can no longer be opened fails the spawn naming it, on both Landlock tiers.
13512    #[test]
13513    fn a_vanished_host_path_fails_the_spawn_naming_it() {
13514        let base = home_tempdir();
13515        let gone = std::fs::canonicalize(base.path()).unwrap().join("gone");
13516        std::fs::create_dir(&gone).unwrap();
13517        let host_paths = vec![crate::host_paths::HostPath {
13518            declared: gone.display().to_string(),
13519            path: gone.clone(),
13520            access: crate::host_paths::HostPathAccess::Read,
13521            credential_locations: Vec::new(),
13522        }];
13523        std::fs::remove_dir(&gone).unwrap();
13524        let workdir = tempfile::tempdir().unwrap();
13525        for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
13526            let enforcement = ShellEnforcement {
13527                tier,
13528                host_paths: host_paths.clone(),
13529                ..host_bounding_base().with_test_session_dirs()
13530            };
13531            let mut command = std::process::Command::new("bash");
13532            let Err(error) = prepare_enforcement(&mut command, &enforcement, workdir.path()) else {
13533                panic!("{tier:?}: a vanished host path must fail the spawn");
13534            };
13535            assert!(
13536                error.contains(&gone.display().to_string()),
13537                "{tier:?}: {error}"
13538            );
13539        }
13540    }
13541
13542    /// No host path, no change: no rule, no bind, no base restriction.
13543    #[test]
13544    fn no_host_paths_add_nothing_to_the_enforcement() {
13545        let policy = CapabilityPolicy {
13546            shell_allow: vec!["bash".to_string()],
13547            ..CapabilityPolicy::default()
13548        };
13549        for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
13550            let class = if tier == EnforcementTier::KernelSealed {
13551                murmur_artifact::ContainmentClass::Sealed
13552            } else {
13553                murmur_artifact::ContainmentClass::Scoped
13554            };
13555            let enforcement = resolve_on_tier(&policy, tier, class).unwrap();
13556            assert_eq!(enforcement.tier, tier);
13557            assert!(enforcement.host_paths.is_empty());
13558            assert!(enforcement.host_path_read_only_binds.is_empty());
13559        }
13560    }
13561}