Skip to main content

capsule_runtime/
reachability.rs

1//! Stage-time reachability checks: does a granted `capabilities.shell.allow` binary have any
2//! chance of *functioning* inside a `sealed` composed root, or only of starting?
3//!
4//! ## The gap this closes
5//!
6//! [`crate::sandbox::resolve_landlock_grants`] derives, from each allowlisted binary, the exact
7//! files the dynamic loader will touch: the binary, its `PT_INTERP`, and the transitive
8//! `DT_NEEDED` closure. Under `sealed` those become read-only binds in the composed root plus
9//! Landlock rules inside it. That derivation is complete for one class of program — a dynamically
10//! linked ELF executable whose entire runtime dependency set is recorded in its own headers — and
11//! it is silently *incomplete* for two others:
12//!
13//!   1. **An interpreted entrypoint.** `~/.local/bin/pip` is a five-line Python script behind a
14//!      `#!` shebang. `parse_elf_dependencies` returns `None` for it, so its derived closure is
15//!      empty; [`crate::sandbox::resolve_sealed_bind_dirs`] binds the directory the script *sits*
16//!      in, and nothing at all binds `~/.local/lib/python3.12/site-packages/pip`, which is where
17//!      the thing it imports on its first line actually lives. The capsule launches, the
18//!      composed root is built, Landlock loads, and then — some number of agent turns later — a
19//!      shell tool call dies with `ModuleNotFoundError: No module named 'pip'`. That is an
20//!      ENOENT-class failure *inside* the root, not a denial: nothing in the trace says "policy",
21//!      and the operator is left debugging a Python install that is fine on the host.
22//!   2. **A compiler driver.** `cc` does not compile anything itself; it forks and execs `cc1`,
23//!      `as`, `ld` and `collect2`. Those are separate binaries, outside `cc`'s own `DT_NEEDED`
24//!      closure, and they live under `/usr` — inside [`crate::sealed::SEALED_RUNTIME_PATHS`],
25//!      which is bind-mounted into every composed root and granted `list_dir: true,
26//!      executable: false` (see `sandbox::resolve_sealed_runtime_landlock_grants`). Present,
27//!      readable, listable, and not executable. `cc --version` works; the first real compile does
28//!      not.
29//!
30//! Neither gap is derivable the way the ELF closure is. An interpreted program's import closure
31//! depends on `sys.path`, on `.pth` files, on whatever the script does at runtime — the design
32//! card behind this module states it plainly: *an arbitrary interpreted closure is not generally
33//! derivable*. So this module does not try to derive it. It asks the weaker question that is
34//! actually answerable at staging time, and answers it honestly:
35//!
36//!   * **Did the operator declare *anything* that could cover this entrypoint?** If not,
37//!     [`check_interpreted_entrypoints_reachable`] refuses the launch. It verifies a covering
38//!     grant was *declared*, never that the directory it names is the right one — that stays the
39//!     operator's job, and the refusal text says how to measure it.
40//!   * **Is this compiler driver's toolchain reachable with an `Execute` right?** If not,
41//!     [`warn_on_unreachable_toolchain_helpers`] warns with `W-SEC-012` and lets the launch
42//!     proceed. A warning rather than a refusal because the probe behind it
43//!     (`<driver> -print-prog-name=<helper>`) is a heuristic about one driver family, and a hard
44//!     refusal built on a heuristic would block capsules that would in fact have worked.
45//!
46//! ## Why both are gated on the *declared* floor
47//!
48//! Both no-op unless `declared_floor == ContainmentClass::Sealed`, and neither consults a host
49//! probe — the same shape, for the same reason, as
50//! [`crate::staged_runtime::check_staged_runtime_floor`]. "Reachable in the composed root" is only
51//! a coherent question when there *is* a composed root, and one is built only for a capsule that
52//! declared `sealed`. Under `scoped`/`advisory` the host filesystem is simply the host filesystem:
53//! `~/.local/lib/python3.12` is right where it always was, and refusing there would be refusing a
54//! capsule that works.
55//!
56//! Both run in `stage_session` *after* `check_containment_floor`, so by the time either is
57//! reached, a `sealed` declaration means this host cleared the probe and the composed-root
58//! construction really will be attempted.
59//!
60//! ## What this does not do
61//!
62//! Nothing here widens anything. No path becomes reachable inside a composed root, no Landlock
63//! rule is added or relaxed, and no manifest key is introduced. The entire contribution is
64//! turning two silent under-deliveries into a named refusal and a named warning at launch.
65
66use std::path::{Path, PathBuf};
67
68use murmur_artifact::{security_warning_link, ContainmentClass, W_SEC_012};
69
70use crate::errors::{RuntimeError, UnreachableEntrypoint};
71use crate::types::CapabilityPolicy;
72
73/// How much of a candidate file [`shebang_interpreter_name`] reads to decide whether it is a
74/// script. A shebang line is capped at 127 bytes on Linux (`BINPRM_BUF_SIZE`) and the kernel
75/// ignores anything past that, so this is already generous; it exists so that pointing
76/// `shell.allow` at a multi-gigabyte file cannot turn a policy check into a `read` of the whole
77/// thing.
78const SHEBANG_PROBE_BYTES: u64 = 512;
79
80/// Compiler drivers whose real work happens in helper binaries they fork and exec themselves, and
81/// the helper names to ask each one about via `-print-prog-name=`.
82///
83/// Deliberately a fixed, short, hand-maintained table rather than anything derived. There is no
84/// general way to ask an arbitrary program "what else will you exec?", and the one family that
85/// *can* be asked — the GCC driver, whose `-print-prog-name=` exists precisely to answer it — is
86/// the family whose absence from the exec grant set breaks real compiles. Anything
87/// outside this table is simply not probed; that is a known, deliberate hole, not an oversight.
88///
89/// The helper lists are per-driver because the driver picks its own front end: `gcc` reaches
90/// `cc1`, `g++` reaches `cc1plus`, and both reach the assembler, the linker and the linker
91/// wrapper. `cc`/`gcc` are listed with *both* front ends because either can compile C++ when
92/// pointed at a `.cc` file or given `-x c++`, and over-asking costs one extra probe while
93/// under-asking costs a missed warning.
94///
95/// **Extending this.** Add a `(driver, helpers)` row. The only requirement is that the driver
96/// answers `-print-prog-name=<helper>` on stdout with either an absolute path or the helper name
97/// unchanged — `clang`/`clang++` do (they accept the flag for GCC compatibility), so adding them
98/// is a one-line change plus a helper list (`clang -cc1` is in-process, so the useful names there
99/// are the assembler and linker: `as`, `ld`, `lld`). A driver that does not answer that flag
100/// contributes nothing and warns about nothing, because [`probe_helper_path`] treats an
101/// unrecognised flag as "not found" (see its own doc comment).
102const KNOWN_TOOLCHAIN_DRIVERS: &[(&str, &[&str])] = &[
103    ("cc", &["cc1", "cc1plus", "as", "ld", "collect2"]),
104    ("gcc", &["cc1", "cc1plus", "as", "ld", "collect2"]),
105    ("g++", &["cc1plus", "as", "ld", "collect2"]),
106    ("c++", &["cc1plus", "as", "ld", "collect2"]),
107];
108
109/// One compiler driver whose helper subprocess has no `Execute`-carrying grant under `sealed`.
110///
111/// Values, not printed strings, so the decision is assertable in a unit test without capturing
112/// stderr. [`warn_on_unreachable_toolchain_helpers`] both returns these and prints them, so the
113/// `W-SEC-012` text has exactly one definition shared by `stage_session` and `mur doctor`.
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct ToolchainHelperWarning {
116    /// The `capabilities.shell.allow` entry that named the driver, verbatim.
117    pub driver: String,
118    /// The helper name asked about, e.g. `cc1plus`.
119    pub helper: String,
120    /// Where the driver said that helper lives on this host.
121    pub resolved_path: PathBuf,
122}
123
124/// Reads the leading bytes of `path` and, if it is a script, returns the bare name of the
125/// interpreter its shebang names.
126///
127/// Returns `None` for everything else — a real ELF image (whose first four bytes are `\x7fELF`,
128/// not `#!`), a directory, a file that cannot be read, a path that does not exist. "Not a script"
129/// and "cannot tell" deliberately collapse into the same answer: the caller's only use for `Some`
130/// is to *add* a refusal, so an unreadable file shrinks the check rather than failing it, matching
131/// the shrink-not-fail rule every other resolver in this crate follows.
132///
133/// One level of `env` indirection is resolved, because it is how nearly every installed console
134/// script is actually written:
135///
136///   * `#!/usr/bin/python3.12` → `Some("python3.12")` — the shebang target's last component.
137///   * `#!/usr/bin/env python3` → `Some("python3")` — the first real argument, not `env`.
138///   * `#!/usr/bin/env -S python3 -u` → `Some("python3")` — flags and `VAR=value` assignments are
139///     skipped, since `env`'s own arguments are not the interpreter.
140///   * `#!/usr/bin/env` with nothing after it → `Some("env")`, the only honest answer available.
141///
142/// A *bare name* is returned, never a path, because the name is what gets compared against a
143/// declared `interpreter_runtime`/`staged_runtime` grant's `binary` field, which the manifest
144/// schema defines as a bare binary name.
145///
146/// Pure apart from one bounded read, so it is unit-testable with tempfiles on any OS.
147pub(crate) fn shebang_interpreter_name(path: &Path) -> Option<String> {
148    use std::io::Read;
149
150    let mut head = Vec::new();
151    std::fs::File::open(path)
152        .ok()?
153        .take(SHEBANG_PROBE_BYTES)
154        .read_to_end(&mut head)
155        .ok()?;
156
157    let rest = head.strip_prefix(b"#!")?;
158    // The shebang is the first line and only the first line; a `\r` counts as a terminator so a
159    // CRLF-saved script does not yield an interpreter name with a stray carriage return glued on.
160    let line_end = rest
161        .iter()
162        .position(|&byte| byte == b'\n' || byte == b'\r')
163        .unwrap_or(rest.len());
164    let line = String::from_utf8_lossy(&rest[..line_end]);
165
166    let mut tokens = line.split_whitespace();
167    let target = base_name(tokens.next()?)?;
168
169    if target != "env" {
170        return Some(target);
171    }
172    // `env`'s own flags (`-S`, `-i`, `--split-string=…`) and `VAR=value` assignments come before
173    // the program it will exec; the first token that is neither is the interpreter.
174    for token in tokens {
175        if token.starts_with('-') || token.contains('=') {
176            continue;
177        }
178        return base_name(token);
179    }
180    Some(target)
181}
182
183/// Last path component of `raw`, as an owned `String`. `None` for an empty or root-only string,
184/// which cannot name a binary.
185fn base_name(raw: &str) -> Option<String> {
186    Path::new(raw)
187        .file_name()
188        .map(|name| name.to_string_lossy().into_owned())
189}
190
191/// Refuses a `sealed` capsule whose `capabilities.shell.allow` names a script that nothing
192/// declared could make importable inside the composed root.
193///
194/// See the module doc for why this is a refusal and what it does *not* claim. In short: for each
195/// allowlisted entry that resolves to a `#!` script, the entry is accepted when **any** of the
196/// following holds, and collected as unreachable when none does.
197///
198///   1. **The script already lives under [`crate::sealed::SEALED_RUNTIME_PATHS`].** That tree is
199///      bind-mounted whole into every composed root and granted `list_dir: true`, so a system
200///      `/usr/bin/pip3` reaches its `dist-packages` beside it without anything being declared.
201///      (Read access is all an import needs; the missing `Execute` right on that tree is Case 2's
202///      problem, below, not this one's.)
203///   2. **The script lives under a directory the manifest already named** in a `staged_runtime`
204///      `source_path` or an `interpreter_runtime` dir. The operator staged the tree the script is
205///      part of.
206///   3. **Some grant names this binary, or its shebang interpreter, by name.** This is the
207///      general case, and the match is *exact string equality* against the grant's `binary` field
208///      — not a prefix, not a basename, not case-insensitive. It can be exact because the
209///      manifest parser already requires a grant's `binary` to appear verbatim in the same
210///      block's `allow`, so the strings being compared come from the same list.
211///
212/// Rule 3 is the loose one on purpose. A grant naming `python3` satisfies every script whose
213/// shebang says `python3`, regardless of whether the directory that grant names is the one holding
214/// the package the script imports. Verifying *that* would mean deriving the import closure, which
215/// is the thing this module opens by saying is not derivable. So the guarantee here is narrow and
216/// stated rather than implied: the operator was made to think about the interpreter's runtime and
217/// write something down. Getting the directory right is still on them, and the refusal text names
218/// the `strace` invocation that measures it.
219///
220/// A non-script entry is skipped unconditionally — an ELF binary's reachability is already
221/// `resolve_landlock_grants`' concern, and re-deciding it here would be a second, weaker opinion
222/// about a question that is already answered correctly.
223pub fn check_interpreted_entrypoints_reachable(
224    policy: &CapabilityPolicy,
225    declared_floor: ContainmentClass,
226) -> Result<(), RuntimeError> {
227    check_interpreted_entrypoints_reachable_in(policy, declared_floor, &host_path_dirs())
228}
229
230/// Testable core of [`check_interpreted_entrypoints_reachable`], with the `PATH` directory list
231/// injected so a test can point it at a temp directory holding synthetic fixtures instead of at
232/// whatever the machine running the suite happens to have installed. Same injection pattern
233/// `sandbox::resolve_exec_allowlist_in` and `resolve_landlock_grants_in` already use.
234fn check_interpreted_entrypoints_reachable_in(
235    policy: &CapabilityPolicy,
236    declared_floor: ContainmentClass,
237    path_dirs: &[PathBuf],
238) -> Result<(), RuntimeError> {
239    if declared_floor != ContainmentClass::Sealed {
240        return Ok(());
241    }
242
243    let fixed_dirs = sealed_runtime_paths();
244    let declared_dirs = declared_grant_dirs(policy);
245    let mut entries: Vec<UnreachableEntrypoint> = Vec::new();
246
247    for entry in &policy.shell_allow {
248        if entry.is_empty() {
249            continue;
250        }
251        let resolved = PathBuf::from(crate::sandbox::resolve_invoked_binary_path_in(
252            entry, path_dirs,
253        ));
254        // `resolve_invoked_binary_path_in` falls back to the bare name when nothing resolves. A
255        // name that resolves to nothing is not this check's business: it cannot be exec'd at all,
256        // which the OS will say more clearly than this refusal could.
257        if !resolved.is_absolute() {
258            continue;
259        }
260        if under_any(&resolved, &fixed_dirs) || under_any(&resolved, &declared_dirs) {
261            continue;
262        }
263        let Some(interpreter) = shebang_interpreter_name(&resolved) else {
264            continue;
265        };
266        if declared_grant_binaries(policy)
267            .any(|binary| binary == entry.as_str() || binary == interpreter.as_str())
268        {
269            continue;
270        }
271        entries.push(UnreachableEntrypoint {
272            binary: entry.clone(),
273            resolved_path: resolved,
274            interpreter,
275        });
276    }
277
278    if entries.is_empty() {
279        return Ok(());
280    }
281    // Every offender in one refusal, following `StagedRuntimeRequiresSealed`: an operator fixing
282    // this should not have to re-run to discover the second script.
283    Err(RuntimeError::ShellBinaryPackageUnreachable { entries })
284}
285
286/// Warns (`W-SEC-012`, non-fatal, once per uncovered helper) when a `sealed` capsule allowlists a
287/// known compiler driver whose helper binaries have no grant carrying the Landlock `Execute`
288/// right. Returns what it warned about so the decision is assertable without capturing stderr.
289///
290/// A helper is **covered** by exactly two things, both of which carry `executable: true`:
291///
292///   * a declared `staged_runtime` `source_path` or `interpreter_runtime` directory the helper
293///     lives under (see `sandbox::resolve_staged_runtime_landlock_grants` and
294///     `resolve_interpreter_runtime_grants`); or
295///   * the helper being allowlisted in its own right — either named directly in `shell.allow`, or
296///     resolved to by some `shell.allow` entry — since every allowlisted binary gets its own
297///     `Execute` grant.
298///
299/// Residence under [`crate::sealed::SEALED_RUNTIME_PATHS`] is **not** coverage, and this is the
300/// one place where that distinction is the entire point. `sandbox::resolve_sealed_runtime_landlock_grants`
301/// grants that tree `executable: false` by deliberate design — it is the only grant covering whole
302/// host trees the manifest never named, so it must make them enumerable without making them
303/// runnable. `/usr/bin/as` and `/usr/libexec/gcc/.../cc1` are therefore present in the composed
304/// root, readable, and un-exec'able. Treating "it's under `/usr`" as coverage here would suppress
305/// exactly the warning this function exists to emit.
306///
307/// Never returns an error and never fails a launch. Each step is shrink-not-fail: a driver that
308/// does not resolve, a probe that fails to spawn, a helper the driver does not know about, all
309/// contribute nothing.
310pub fn warn_on_unreachable_toolchain_helpers(
311    policy: &CapabilityPolicy,
312    declared_floor: ContainmentClass,
313) -> Vec<ToolchainHelperWarning> {
314    let warnings = unreachable_toolchain_helpers_in(policy, declared_floor, &host_path_dirs());
315    for warning in &warnings {
316        let link = security_warning_link(W_SEC_012);
317        eprintln!(
318            "[capsule-runtime] warning[{W_SEC_012}]: capabilities.shell.allow grants the compiler \
319             driver '{}', but its helper '{}' at {} has no grant carrying the Landlock Execute \
320             right under the 'sealed' composed root — the fixed sealed runtime tree ({}) is bound \
321             read-only and listable but deliberately not executable, so the driver will start and \
322             then fail partway through a real compile; declare \
323             capabilities.shell.interpreter_runtime or staged_runtime for '{}' naming that \
324             helper's directory ({}) to grant it ({link})",
325            warning.driver,
326            warning.helper,
327            warning.resolved_path.display(),
328            crate::sealed::SEALED_RUNTIME_PATHS.join(", "),
329            warning.driver,
330            warning
331                .resolved_path
332                .parent()
333                .unwrap_or_else(|| Path::new("/"))
334                .display(),
335        );
336    }
337    warnings
338}
339
340/// Testable core of [`warn_on_unreachable_toolchain_helpers`]: decides, prints nothing, and takes
341/// the `PATH` directory list as an argument.
342fn unreachable_toolchain_helpers_in(
343    policy: &CapabilityPolicy,
344    declared_floor: ContainmentClass,
345    path_dirs: &[PathBuf],
346) -> Vec<ToolchainHelperWarning> {
347    if declared_floor != ContainmentClass::Sealed {
348        return Vec::new();
349    }
350
351    let declared_dirs = declared_grant_dirs(policy);
352    // Every path some `shell.allow` entry resolves to, so a helper that is itself allowlisted
353    // under a different spelling (`/usr/bin/ld` vs `ld`) still counts as covered.
354    let allowlisted_paths: Vec<PathBuf> = policy
355        .shell_allow
356        .iter()
357        .filter(|entry| !entry.is_empty())
358        .map(|entry| {
359            PathBuf::from(crate::sandbox::resolve_invoked_binary_path_in(
360                entry, path_dirs,
361            ))
362        })
363        .collect();
364
365    let mut warnings: Vec<ToolchainHelperWarning> = Vec::new();
366
367    for entry in &policy.shell_allow {
368        let Some((_, helpers)) = KNOWN_TOOLCHAIN_DRIVERS
369            .iter()
370            .find(|(driver, _)| base_name(entry).as_deref() == Some(*driver))
371        else {
372            continue;
373        };
374        let driver_path = PathBuf::from(crate::sandbox::resolve_invoked_binary_path_in(
375            entry, path_dirs,
376        ));
377        if !driver_path.is_absolute() {
378            continue;
379        }
380
381        for helper in *helpers {
382            let Some(resolved_path) = probe_helper_path(&driver_path, helper, path_dirs) else {
383                continue;
384            };
385            if under_any(&resolved_path, &declared_dirs)
386                || policy.shell_allow.iter().any(|allowed| allowed == helper)
387                || allowlisted_paths.contains(&resolved_path)
388            {
389                continue;
390            }
391            let warning = ToolchainHelperWarning {
392                driver: entry.clone(),
393                helper: (*helper).to_string(),
394                resolved_path,
395            };
396            // `cc` and `gcc` are usually the same binary reached under two names, so a capsule
397            // allowlisting both would otherwise report each helper twice.
398            if !warnings.contains(&warning) {
399                warnings.push(warning);
400            }
401        }
402    }
403
404    warnings
405}
406
407/// Asks `driver` where it would find `helper`, via the GCC driver's own `-print-prog-name=` query,
408/// and returns the canonical path when that answer names a real file.
409///
410/// The flag's output contract has three shapes, and all three are handled here because this host's
411/// own `gcc` produces two of them:
412///
413///   * an **absolute path** — `cc -print-prog-name=cc1` →
414///     `/usr/libexec/gcc/x86_64-linux-gnu/13/cc1`. The driver found it in its private libexec
415///     directory. Canonicalized and returned.
416///   * the **helper name, unchanged** — `cc -print-prog-name=as` → `as`. This is *not* "no such
417///     helper": it is the driver saying it will let `execvp` find that one on `PATH`, which is
418///     exactly what it does for the assembler and the linker on a Debian/Ubuntu GCC. So the name
419///     is resolved through `path_dirs` rather than discarded; `as` and `ld` really do live at
420///     `/usr/bin/as` and `/usr/bin/ld`, really are exec'd during a compile, and really are inside
421///     the non-executable fixed sealed tree. Discarding them would have made this check blind to
422///     two of the four helpers that matter most.
423///   * **anything else** — a driver that does not understand the flag (it typically echoes the
424///     flag back, or writes usage text to stderr and exits non-zero), an empty stdout, a path that
425///     no longer exists. All treated as "not found, skip": shrink-not-fail, so an unknown driver
426///     family contributes no warnings rather than bogus ones.
427///
428/// This is the one part of the module that spawns a process. It is a single short-lived exec of a
429/// binary the manifest itself allowlisted, running in the parent at staging time — never in a
430/// forked child's `pre_exec` window, and never after the sandbox is installed.
431fn probe_helper_path(driver: &Path, helper: &str, path_dirs: &[PathBuf]) -> Option<PathBuf> {
432    let output = std::process::Command::new(driver)
433        .arg(format!("-print-prog-name={helper}"))
434        .output()
435        .ok()?;
436    if !output.status.success() {
437        return None;
438    }
439    let printed = String::from_utf8_lossy(&output.stdout).trim().to_string();
440    if printed.is_empty() {
441        return None;
442    }
443
444    let candidate = if printed == helper {
445        // The driver deferred to `PATH`; resolve it the way the exec would.
446        PathBuf::from(crate::sandbox::resolve_invoked_binary_path_in(
447            helper, path_dirs,
448        ))
449    } else {
450        PathBuf::from(&printed)
451    };
452    if !candidate.is_absolute() {
453        return None;
454    }
455    std::fs::canonicalize(candidate).ok()
456}
457
458/// The host `PATH`, split the same way `execvp` splits it — the single place this module reads
459/// process environment, so both public entry points share one definition of "where binaries are".
460fn host_path_dirs() -> Vec<PathBuf> {
461    std::env::var_os("PATH")
462        .map(|path| std::env::split_paths(&path).collect())
463        .unwrap_or_default()
464}
465
466/// [`crate::sealed::SEALED_RUNTIME_PATHS`] as owned paths, for [`under_any`].
467fn sealed_runtime_paths() -> Vec<PathBuf> {
468    crate::sealed::SEALED_RUNTIME_PATHS
469        .iter()
470        .map(PathBuf::from)
471        .collect()
472}
473
474/// Every host directory the manifest already named: each `staged_runtime` `source_path` and each
475/// `interpreter_runtime` directory. These are the trees that get a bind and an
476/// `executable: true` Landlock grant, so residence under one is coverage for both checks.
477fn declared_grant_dirs(policy: &CapabilityPolicy) -> Vec<PathBuf> {
478    policy
479        .shell_staged_runtime
480        .iter()
481        .map(|grant| PathBuf::from(&grant.source_path))
482        .chain(
483            policy
484                .shell_interpreter_runtime
485                .iter()
486                .flat_map(|grant| grant.dirs.iter().map(|dir| PathBuf::from(&dir.path))),
487        )
488        .filter(|path| path.is_absolute())
489        .collect()
490}
491
492/// Every binary name a `staged_runtime` or `interpreter_runtime` grant declares.
493fn declared_grant_binaries(policy: &CapabilityPolicy) -> impl Iterator<Item = &str> {
494    policy
495        .shell_staged_runtime
496        .iter()
497        .map(|grant| grant.binary.as_str())
498        .chain(
499            policy
500                .shell_interpreter_runtime
501                .iter()
502                .map(|grant| grant.binary.as_str()),
503        )
504}
505
506/// Whether `path` is `root` or sits beneath one of `roots`.
507///
508/// `Path::starts_with` is component-wise, not textual, so `/usrlocal/bin/pip` is correctly *not*
509/// under `/usr` — the trap a `str::starts_with` would fall into.
510fn under_any(path: &Path, roots: &[PathBuf]) -> bool {
511    roots.iter().any(|root| path.starts_with(root))
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517    use murmur_artifact::{InterpreterRuntimeDir, InterpreterRuntimeGrant, StagedRuntimeGrant};
518
519    /// Every containment class, so a new variant cannot quietly escape a test that iterates "all
520    /// of them". Mirrors the same list in `staged_runtime`'s tests.
521    const ALL_CLASSES: &[ContainmentClass] = &[
522        ContainmentClass::Advisory,
523        ContainmentClass::Scoped,
524        ContainmentClass::Sealed,
525    ];
526
527    /// Writes an executable file with the given first bytes into `dir`, and returns its path.
528    fn write_binary(dir: &Path, name: &str, contents: &[u8]) -> PathBuf {
529        let path = dir.join(name);
530        std::fs::write(&path, contents).expect("write fixture");
531        #[cfg(unix)]
532        {
533            use std::os::unix::fs::PermissionsExt;
534            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
535                .expect("chmod fixture");
536        }
537        path
538    }
539
540    fn script(dir: &Path, name: &str, shebang: &str) -> PathBuf {
541        write_binary(dir, name, format!("{shebang}\nprint('hi')\n").as_bytes())
542    }
543
544    /// A minimal, deliberately *invalid* ELF image: enough magic for `shebang_interpreter_name` to
545    /// reject it as "not a script", which is the only property these tests need from it.
546    fn elf(dir: &Path, name: &str) -> PathBuf {
547        let mut bytes = b"\x7fELF\x02\x01\x01\x00".to_vec();
548        bytes.resize(128, 0);
549        write_binary(dir, name, &bytes)
550    }
551
552    fn policy_allowing(names: &[&str]) -> CapabilityPolicy {
553        CapabilityPolicy {
554            shell_allow: names.iter().map(|name| (*name).to_string()).collect(),
555            ..CapabilityPolicy::default()
556        }
557    }
558
559    fn interpreter_grant(binary: &str, dirs: &[&str]) -> InterpreterRuntimeGrant {
560        InterpreterRuntimeGrant {
561            binary: binary.to_string(),
562            dirs: dirs
563                .iter()
564                .map(|path| InterpreterRuntimeDir {
565                    path: (*path).to_string(),
566                    list_dir: true,
567                })
568                .collect(),
569        }
570    }
571
572    fn staged_grant(binary: &str, source_path: &str) -> StagedRuntimeGrant {
573        StagedRuntimeGrant {
574            binary: binary.to_string(),
575            source_path: source_path.to_string(),
576            pin: "test-pin-1".to_string(),
577        }
578    }
579
580    // ---------------------------------------------------------------- shebang_interpreter_name
581
582    #[test]
583    fn shebang_returns_the_interpreter_basename_for_a_direct_target() {
584        let dir = tempfile::tempdir().expect("tempdir");
585        let path = script(dir.path(), "direct", "#!/usr/bin/python3.12");
586        assert_eq!(
587            shebang_interpreter_name(&path),
588            Some("python3.12".to_string())
589        );
590    }
591
592    #[test]
593    fn shebang_resolves_one_level_of_env_indirection() {
594        let dir = tempfile::tempdir().expect("tempdir");
595        for (name, line, expected) in [
596            ("plain-env", "#!/usr/bin/env python3", "python3"),
597            ("split-string", "#!/usr/bin/env -S python3 -u", "python3"),
598            ("assignment", "#!/usr/bin/env FOO=bar node", "node"),
599            // Nothing follows `env`, so `env` itself is the only honest answer.
600            ("bare-env", "#!/usr/bin/env", "env"),
601        ] {
602            let path = script(dir.path(), name, line);
603            assert_eq!(
604                shebang_interpreter_name(&path),
605                Some(expected.to_string()),
606                "shebang line: {line}"
607            );
608        }
609    }
610
611    /// A CRLF-saved script must not yield `"python3\r"`, which would silently fail every
612    /// grant-name comparison downstream.
613    #[test]
614    fn shebang_stops_at_a_carriage_return() {
615        let dir = tempfile::tempdir().expect("tempdir");
616        let path = write_binary(
617            dir.path(),
618            "crlf",
619            b"#!/usr/bin/env python3\r\nprint(1)\r\n",
620        );
621        assert_eq!(shebang_interpreter_name(&path), Some("python3".to_string()));
622    }
623
624    /// The property that makes "no shebang" a usable proxy for "this is an ELF binary, and its
625    /// reachability is already `resolve_landlock_grants`' problem".
626    #[test]
627    fn shebang_is_none_for_an_elf_image_and_for_unreadable_paths() {
628        let dir = tempfile::tempdir().expect("tempdir");
629        assert_eq!(shebang_interpreter_name(&elf(dir.path(), "binary")), None);
630        assert_eq!(
631            shebang_interpreter_name(&write_binary(dir.path(), "plain", b"just text\n")),
632            None
633        );
634        assert_eq!(
635            shebang_interpreter_name(&dir.path().join("does-not-exist")),
636            None
637        );
638        assert_eq!(shebang_interpreter_name(dir.path()), None);
639    }
640
641    /// The read is bounded, so pointing `shell.allow` at a huge file costs 512 bytes, not the
642    /// file. Asserted by giving the shebang line more leading junk than the bound allows.
643    #[test]
644    fn shebang_probe_reads_a_bounded_prefix_only() {
645        let dir = tempfile::tempdir().expect("tempdir");
646        let mut bytes = vec![b'#'; SHEBANG_PROBE_BYTES as usize * 4];
647        bytes.extend_from_slice(b"\n#!/usr/bin/env python3\n");
648        // The `#!` is past the bound *and* not at byte 0, so this is not a script either way.
649        assert_eq!(
650            shebang_interpreter_name(&write_binary(dir.path(), "huge", &bytes)),
651            None
652        );
653    }
654
655    // ------------------------------------------------- check_interpreted_entrypoints_reachable
656
657    /// Scenario 1: a script outside every fixed and declared tree, with no grant naming it or its
658    /// interpreter, refuses — and the refusal is actionable on its own text.
659    #[test]
660    fn an_uncovered_script_entrypoint_refuses_naming_binary_path_and_interpreter() {
661        let dir = tempfile::tempdir().expect("tempdir");
662        script(dir.path(), "pip", "#!/usr/bin/env python3");
663        let dirs = vec![dir.path().to_path_buf()];
664
665        let error = check_interpreted_entrypoints_reachable_in(
666            &policy_allowing(&["pip"]),
667            ContainmentClass::Sealed,
668            &dirs,
669        )
670        .expect_err("an uncovered interpreted entrypoint must refuse");
671
672        let rendered = error.to_string();
673        assert!(rendered.contains("pip"), "{rendered}");
674        assert!(
675            rendered.contains(&dir.path().join("pip").display().to_string()),
676            "{rendered}"
677        );
678        assert!(rendered.contains("python3"), "{rendered}");
679        assert!(rendered.contains("interpreter_runtime"), "{rendered}");
680        assert!(rendered.contains("strace"), "{rendered}");
681    }
682
683    /// Scenario 2: any covering grant naming the script *or* its interpreter is enough. This is
684    /// the deliberately loose rule — the grant's directory is never checked for correctness.
685    #[test]
686    fn a_grant_naming_the_script_or_its_interpreter_is_sufficient() {
687        let dir = tempfile::tempdir().expect("tempdir");
688        script(dir.path(), "pip", "#!/usr/bin/env python3");
689        let dirs = vec![dir.path().to_path_buf()];
690
691        for policy in [
692            CapabilityPolicy {
693                // Names the interpreter, and points at a directory that has nothing to do with
694                // the script's actual package — still accepted, by design.
695                shell_interpreter_runtime: vec![interpreter_grant("python3", &["/opt/unrelated"])],
696                ..policy_allowing(&["pip"])
697            },
698            CapabilityPolicy {
699                // Names the script itself.
700                shell_staged_runtime: vec![staged_grant("pip", "/opt/unrelated")],
701                ..policy_allowing(&["pip"])
702            },
703        ] {
704            assert!(
705                check_interpreted_entrypoints_reachable_in(
706                    &policy,
707                    ContainmentClass::Sealed,
708                    &dirs
709                )
710                .is_ok(),
711                "a declared covering grant must be sufficient"
712            );
713        }
714    }
715
716    /// Scenario 2, second limb: a script that *sits inside* a declared tree is covered by
717    /// residence, with no name match needed.
718    #[test]
719    fn a_script_inside_a_declared_grant_directory_is_covered_by_residence() {
720        let dir = tempfile::tempdir().expect("tempdir");
721        script(dir.path(), "pip", "#!/usr/bin/env python3");
722        let dirs = vec![dir.path().to_path_buf()];
723
724        let policy = CapabilityPolicy {
725            shell_staged_runtime: vec![staged_grant(
726                "some-other-binary",
727                dir.path().to_str().expect("utf-8 temp path"),
728            )],
729            ..policy_allowing(&["pip"])
730        };
731        assert!(check_interpreted_entrypoints_reachable_in(
732            &policy,
733            ContainmentClass::Sealed,
734            &dirs
735        )
736        .is_ok());
737    }
738
739    /// Scenario 3: a system script under the fixed,
740    /// fully-bound sealed tree needs no declaration at all.
741    #[test]
742    fn a_script_under_a_fixed_sealed_runtime_path_needs_no_grant() {
743        // Path-form `shell.allow` entries bypass `PATH` entirely, so this asserts the prefix rule
744        // without needing a writable `/usr` on the machine running the suite.
745        for entry in crate::sealed::SEALED_RUNTIME_PATHS {
746            let policy = policy_allowing(&[&format!("{entry}/bin/some-console-script")]);
747            assert!(
748                check_interpreted_entrypoints_reachable_in(&policy, ContainmentClass::Sealed, &[])
749                    .is_ok(),
750                "a path under {entry} must never be collected"
751            );
752        }
753    }
754
755    /// The component-wise prefix rule: `/usr-local` is not under `/usr`. Guards against anyone
756    /// "simplifying" `under_any` into a string comparison.
757    #[test]
758    fn prefix_matching_is_component_wise_not_textual() {
759        assert!(under_any(
760            Path::new("/usr/bin/pip"),
761            &[PathBuf::from("/usr")]
762        ));
763        assert!(!under_any(
764            Path::new("/usrlocal/bin/pip"),
765            &[PathBuf::from("/usr")]
766        ));
767    }
768
769    /// Scenario 4: ELF entries are never this check's business, whatever else is true.
770    #[test]
771    fn elf_entrypoints_are_never_collected() {
772        let dir = tempfile::tempdir().expect("tempdir");
773        elf(dir.path(), "bash");
774        let dirs = vec![dir.path().to_path_buf()];
775        assert!(check_interpreted_entrypoints_reachable_in(
776            &policy_allowing(&["bash"]),
777            ContainmentClass::Sealed,
778            &dirs
779        )
780        .is_ok());
781    }
782
783    /// An entry that resolves to nothing is not a reachability problem — it is a missing binary,
784    /// which the OS reports far more clearly than this refusal could.
785    #[test]
786    fn an_unresolvable_entry_is_skipped_rather_than_refused() {
787        assert!(check_interpreted_entrypoints_reachable_in(
788            &policy_allowing(&["definitely-not-installed-anywhere"]),
789            ContainmentClass::Sealed,
790            &[]
791        )
792        .is_ok());
793    }
794
795    /// Scenario 1, the "name every offender at once" property inherited from
796    /// `StagedRuntimeRequiresSealed`.
797    #[test]
798    fn the_refusal_names_every_unreachable_entrypoint() {
799        let dir = tempfile::tempdir().expect("tempdir");
800        script(dir.path(), "pip", "#!/usr/bin/env python3");
801        script(dir.path(), "rake", "#!/usr/bin/env ruby");
802        let dirs = vec![dir.path().to_path_buf()];
803
804        let error = check_interpreted_entrypoints_reachable_in(
805            &policy_allowing(&["pip", "rake"]),
806            ContainmentClass::Sealed,
807            &dirs,
808        )
809        .expect_err("both entrypoints are uncovered");
810        let rendered = error.to_string();
811        for expected in ["pip", "python3", "rake", "ruby"] {
812            assert!(
813                rendered.contains(expected),
814                "missing {expected}: {rendered}"
815            );
816        }
817    }
818
819    // ------------------------------------------------------ declared-floor gating (Scenario 5)
820
821    /// Both checks are functions of the *declared* floor alone, and both are fully inert below
822    /// `sealed`. Asserted together because they share the gate and must not drift apart.
823    #[test]
824    fn both_checks_are_inert_below_a_declared_sealed_floor() {
825        let dir = tempfile::tempdir().expect("tempdir");
826        script(dir.path(), "pip", "#!/usr/bin/env python3");
827        let dirs = vec![dir.path().to_path_buf()];
828        let policy = policy_allowing(&["pip", "cc"]);
829
830        for class in ALL_CLASSES {
831            let refusal = check_interpreted_entrypoints_reachable_in(&policy, *class, &dirs);
832            let warnings = unreachable_toolchain_helpers_in(&policy, *class, &dirs);
833            if *class == ContainmentClass::Sealed {
834                assert!(refusal.is_err(), "sealed must still be checked");
835            } else {
836                assert!(refusal.is_ok(), "{class} must not be refused");
837                assert!(warnings.is_empty(), "{class} must not warn");
838            }
839        }
840    }
841
842    // ------------------------------------------------- warn_on_unreachable_toolchain_helpers
843
844    /// A stand-in compiler driver that answers `-print-prog-name=<helper>` the way GCC does,
845    /// letting Scenarios 6 and 7 run on any host with a shell — including one with no real GCC.
846    /// Absolute answers for the front ends, `PATH`-deferred answers for the assembler/linker,
847    /// matching GCC's own behaviour.
848    #[cfg(unix)]
849    fn fake_driver(dir: &Path, name: &str, libexec: &Path) -> PathBuf {
850        let script = format!(
851            "#!/bin/sh\n\
852             case \"$1\" in\n\
853             -print-prog-name=cc1) echo {libexec}/cc1 ;;\n\
854             -print-prog-name=cc1plus) echo {libexec}/cc1plus ;;\n\
855             -print-prog-name=collect2) echo {libexec}/collect2 ;;\n\
856             -print-prog-name=as) echo as ;;\n\
857             -print-prog-name=ld) echo ld ;;\n\
858             *) exit 1 ;;\n\
859             esac\n",
860            libexec = libexec.display()
861        );
862        write_binary(dir, name, script.as_bytes())
863    }
864
865    /// Scenario 6: an allowlisted driver whose helpers have no `Execute`-carrying grant warns,
866    /// naming driver, helper and resolved path.
867    #[cfg(unix)]
868    #[test]
869    fn an_uncovered_toolchain_helper_warns_naming_driver_helper_and_path() {
870        let dir = tempfile::tempdir().expect("tempdir");
871        let libexec = dir.path().join("libexec");
872        std::fs::create_dir_all(&libexec).expect("libexec");
873        for helper in ["cc1", "cc1plus", "collect2"] {
874            write_binary(&libexec, helper, b"#!/bin/sh\nexit 0\n");
875        }
876        fake_driver(dir.path(), "cc", &libexec);
877        let dirs = vec![dir.path().to_path_buf()];
878
879        let warnings = unreachable_toolchain_helpers_in(
880            &policy_allowing(&["cc"]),
881            ContainmentClass::Sealed,
882            &dirs,
883        );
884
885        let cc1 = warnings
886            .iter()
887            .find(|warning| warning.helper == "cc1")
888            .expect("cc1 must be reported as uncovered");
889        assert_eq!(cc1.driver, "cc");
890        assert_eq!(
891            cc1.resolved_path,
892            std::fs::canonicalize(libexec.join("cc1")).expect("canonical cc1")
893        );
894    }
895
896    /// Scenario 7: a declared `interpreter_runtime` directory covers every helper inside it,
897    /// because such a grant carries `executable: true`.
898    #[cfg(unix)]
899    #[test]
900    fn a_declared_grant_directory_covers_the_helpers_inside_it() {
901        let dir = tempfile::tempdir().expect("tempdir");
902        let libexec = dir.path().join("libexec");
903        std::fs::create_dir_all(&libexec).expect("libexec");
904        for helper in ["cc1", "cc1plus", "collect2"] {
905            write_binary(&libexec, helper, b"#!/bin/sh\nexit 0\n");
906        }
907        fake_driver(dir.path(), "cc", &libexec);
908        let dirs = vec![dir.path().to_path_buf()];
909
910        let policy = CapabilityPolicy {
911            shell_interpreter_runtime: vec![interpreter_grant(
912                "cc",
913                &[libexec.to_str().expect("utf-8 temp path")],
914            )],
915            ..policy_allowing(&["cc"])
916        };
917        let warnings = unreachable_toolchain_helpers_in(&policy, ContainmentClass::Sealed, &dirs);
918
919        assert!(
920            !warnings.iter().any(|warning| warning.helper == "cc1"),
921            "a declared grant directory must cover its helpers: {warnings:?}"
922        );
923    }
924
925    /// The `PATH`-deferred answer (`-print-prog-name=as` → `as`) is resolved rather than
926    /// discarded — the deviation from the design that keeps two of the four helpers that matter
927    /// most from being invisible to this check.
928    #[cfg(unix)]
929    #[test]
930    fn a_path_deferred_helper_answer_is_resolved_not_discarded() {
931        let dir = tempfile::tempdir().expect("tempdir");
932        let libexec = dir.path().join("libexec");
933        std::fs::create_dir_all(&libexec).expect("libexec");
934        // `as` lives on the injected `PATH`, not in the driver's libexec, and the driver answers
935        // with the bare name.
936        write_binary(dir.path(), "as", b"#!/bin/sh\nexit 0\n");
937        fake_driver(dir.path(), "cc", &libexec);
938        let dirs = vec![dir.path().to_path_buf()];
939
940        let warnings = unreachable_toolchain_helpers_in(
941            &policy_allowing(&["cc"]),
942            ContainmentClass::Sealed,
943            &dirs,
944        );
945
946        let assembler = warnings
947            .iter()
948            .find(|warning| warning.helper == "as")
949            .expect("a PATH-deferred helper must still be checked");
950        assert_eq!(
951            assembler.resolved_path,
952            std::fs::canonicalize(dir.path().join("as")).expect("canonical as")
953        );
954    }
955
956    /// The load-bearing distinction: the fixed sealed tree is bound and listable
957    /// but **not** executable, so a helper resolving under it is still uncovered. Written as a
958    /// direct assertion on the coverage rule so that granting `SEALED_RUNTIME_PATHS` the
959    /// `Execute` right one day cannot silently pass this test.
960    #[test]
961    fn residence_under_the_fixed_sealed_tree_is_not_coverage() {
962        let policy = CapabilityPolicy {
963            shell_interpreter_runtime: Vec::new(),
964            shell_staged_runtime: Vec::new(),
965            ..policy_allowing(&["cc"])
966        };
967        // `/usr/bin/as` is inside the fixed tree and inside nothing the policy declared.
968        assert!(under_any(Path::new("/usr/bin/as"), &sealed_runtime_paths()));
969        assert!(
970            !under_any(Path::new("/usr/bin/as"), &declared_grant_dirs(&policy)),
971            "the fixed tree must never be folded into the declared-grant set"
972        );
973    }
974
975    /// A helper the capsule allowlisted in its own right already has an `Execute` grant.
976    #[cfg(unix)]
977    #[test]
978    fn a_helper_that_is_itself_allowlisted_is_covered() {
979        let dir = tempfile::tempdir().expect("tempdir");
980        let libexec = dir.path().join("libexec");
981        std::fs::create_dir_all(&libexec).expect("libexec");
982        write_binary(&libexec, "cc1", b"#!/bin/sh\nexit 0\n");
983        fake_driver(dir.path(), "cc", &libexec);
984        let dirs = vec![dir.path().to_path_buf()];
985
986        let warnings = unreachable_toolchain_helpers_in(
987            &policy_allowing(&["cc", "cc1"]),
988            ContainmentClass::Sealed,
989            &dirs,
990        );
991        assert!(
992            !warnings.iter().any(|warning| warning.helper == "cc1"),
993            "an allowlisted helper carries its own Execute grant: {warnings:?}"
994        );
995    }
996
997    /// A `shell.allow` entry that is not a known driver is never probed, so no unrelated binary
998    /// is spawned by this check.
999    #[cfg(unix)]
1000    #[test]
1001    fn a_non_driver_entry_is_never_probed() {
1002        let dir = tempfile::tempdir().expect("tempdir");
1003        // A "driver" that would answer if asked — but it is not in the known-driver table.
1004        fake_driver(dir.path(), "not-a-compiler", &dir.path().join("libexec"));
1005        let dirs = vec![dir.path().to_path_buf()];
1006
1007        assert!(unreachable_toolchain_helpers_in(
1008            &policy_allowing(&["not-a-compiler"]),
1009            ContainmentClass::Sealed,
1010            &dirs
1011        )
1012        .is_empty());
1013    }
1014
1015    /// The registry is the *whole* contract of which drivers get probed, so its contents are
1016    /// pinned here rather than left implicit — a future slice adding `clang` should have to
1017    /// update this list deliberately.
1018    #[test]
1019    fn the_known_driver_registry_is_the_documented_four() {
1020        let drivers: Vec<&str> = KNOWN_TOOLCHAIN_DRIVERS
1021            .iter()
1022            .map(|(driver, _)| *driver)
1023            .collect();
1024        assert_eq!(drivers, vec!["cc", "gcc", "g++", "c++"]);
1025        for (driver, helpers) in KNOWN_TOOLCHAIN_DRIVERS {
1026            assert!(
1027                helpers.contains(&"as") && helpers.contains(&"ld"),
1028                "{driver} must be probed for the assembler and linker it execs"
1029            );
1030        }
1031    }
1032}