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. A driver
45//!     that answers with nothing usable is an unknown family and stays silent; a driver that
46//!     could not be asked at all — the spawn failed with an OS error, or a signal killed it before
47//!     it answered — warns with `W-SEC-029` instead, because silence there would read as a
48//!     `W-SEC-012` check that passed.
49//!
50//! ## Why both are gated on the *declared* floor
51//!
52//! Both no-op unless `declared_floor == ContainmentClass::Sealed`, and neither consults a host
53//! probe — the same shape, for the same reason, as
54//! [`crate::staged_runtime::check_staged_runtime_floor`]. "Reachable in the composed root" is only
55//! a coherent question when there *is* a composed root, and one is built only for a capsule that
56//! declared `sealed`. Under `scoped`/`advisory` the host filesystem is simply the host filesystem:
57//! `~/.local/lib/python3.12` is right where it always was, and refusing there would be refusing a
58//! capsule that works.
59//!
60//! Both run in `stage_session` *after* `check_containment_floor`, so by the time either is
61//! reached, a `sealed` declaration means this host cleared the probe and the composed-root
62//! construction really will be attempted.
63//!
64//! ## What this does not do
65//!
66//! Nothing here widens anything. No path becomes reachable inside a composed root, no Landlock
67//! rule is added or relaxed, and no manifest key is introduced. The entire contribution is
68//! turning two silent under-deliveries into a named refusal and named warnings at launch.
69
70use std::path::{Path, PathBuf};
71
72use murmur_artifact::{security_warning_link, ContainmentClass, W_SEC_012, W_SEC_029};
73
74use crate::errors::{RuntimeError, UnreachableEntrypoint};
75use crate::types::CapabilityPolicy;
76
77/// How much of a candidate file [`shebang_interpreter_name`] reads to decide whether it is a
78/// script. A shebang line is capped at 127 bytes on Linux (`BINPRM_BUF_SIZE`) and the kernel
79/// ignores anything past that, so this is already generous; it exists so that pointing
80/// `shell.allow` at a multi-gigabyte file cannot turn a policy check into a `read` of the whole
81/// thing.
82const SHEBANG_PROBE_BYTES: u64 = 512;
83
84/// Compiler drivers whose real work happens in helper binaries they fork and exec themselves, and
85/// the helper names to ask each one about via `-print-prog-name=`.
86///
87/// Deliberately a fixed, short, hand-maintained table rather than anything derived. There is no
88/// general way to ask an arbitrary program "what else will you exec?", and the one family that
89/// *can* be asked — the GCC driver, whose `-print-prog-name=` exists precisely to answer it — is
90/// the family whose absence from the exec grant set breaks real compiles. Anything
91/// outside this table is simply not probed; that is a known, deliberate hole, not an oversight.
92///
93/// The helper lists are per-driver because the driver picks its own front end: `gcc` reaches
94/// `cc1`, `g++` reaches `cc1plus`, and both reach the assembler, the linker and the linker
95/// wrapper. `cc`/`gcc` are listed with *both* front ends because either can compile C++ when
96/// pointed at a `.cc` file or given `-x c++`, and over-asking costs one extra probe while
97/// under-asking costs a missed warning.
98///
99/// **Extending this.** Add a `(driver, helpers)` row. The only requirement is that the driver
100/// answers `-print-prog-name=<helper>` on stdout with either an absolute path or the helper name
101/// unchanged — `clang`/`clang++` do (they accept the flag for GCC compatibility), so adding them
102/// is a one-line change plus a helper list (`clang -cc1` is in-process, so the useful names there
103/// are the assembler and linker: `as`, `ld`, `lld`). A driver that runs but does not answer that
104/// flag contributes nothing and warns about nothing, because [`probe_helper_path`] treats an
105/// unrecognised flag as [`HelperProbe::NothingUsable`]. A driver that cannot be run at all is
106/// reported once, as an [`UnprobedToolchainDriver`], whatever its row lists.
107const KNOWN_TOOLCHAIN_DRIVERS: &[(&str, &[&str])] = &[
108    ("cc", &["cc1", "cc1plus", "as", "ld", "collect2"]),
109    ("gcc", &["cc1", "cc1plus", "as", "ld", "collect2"]),
110    ("g++", &["cc1plus", "as", "ld", "collect2"]),
111    ("c++", &["cc1plus", "as", "ld", "collect2"]),
112];
113
114/// One compiler driver whose helper subprocess has no `Execute`-carrying grant under `sealed`.
115///
116/// Values, not printed strings, so the decision is assertable in a unit test without capturing
117/// stderr. [`warn_on_unreachable_toolchain_helpers`] both returns these and prints them, so the
118/// `W-SEC-012` text has exactly one definition shared by `stage_session` and `mur doctor`.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct ToolchainHelperWarning {
121    /// The `capabilities.shell.allow` entry that named the driver, verbatim.
122    pub driver: String,
123    /// The helper name asked about, e.g. `cc1plus`.
124    pub helper: String,
125    /// Where the driver said that helper lives on this host.
126    pub resolved_path: PathBuf,
127}
128
129/// One compiler driver whose `-print-prog-name=` probe could not be run, so `W-SEC-012` was not
130/// evaluated for the helpers named in `unchecked_helpers`.
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct UnprobedToolchainDriver {
133    /// The `capabilities.shell.allow` entry that named the driver, verbatim.
134    pub driver: String,
135    /// The absolute path that entry resolved to, which is what the probe tried to exec.
136    pub driver_path: PathBuf,
137    /// The helper whose probe failed, followed by every later helper in the driver's
138    /// [`KNOWN_TOOLCHAIN_DRIVERS`] row, in table order. Helpers before it were probed normally.
139    pub unchecked_helpers: Vec<String>,
140    /// `Some` when the spawn itself failed (`PermissionDenied`, `ExecutableFileBusy`, …); `None`
141    /// when the driver started and a signal terminated it before it answered.
142    pub error_kind: Option<std::io::ErrorKind>,
143    /// The spawn error's `Display`, or `terminated by signal N`.
144    pub reason: String,
145}
146
147/// Everything [`warn_on_unreachable_toolchain_helpers`] decided. Both lists empty means every
148/// known driver was either probed with every helper covered, or answered nothing usable; a
149/// driver that could not be asked always lands in `unprobed`.
150#[derive(Debug, Clone, PartialEq, Eq, Default)]
151pub struct ToolchainHelperReport {
152    /// One `W-SEC-012` each.
153    pub uncovered: Vec<ToolchainHelperWarning>,
154    /// One `W-SEC-029` each.
155    pub unprobed: Vec<UnprobedToolchainDriver>,
156}
157
158/// Reads the leading bytes of `path` and, if it is a script, returns the bare name of the
159/// interpreter its shebang names.
160///
161/// Returns `None` for everything else — a real ELF image (whose first four bytes are `\x7fELF`,
162/// not `#!`), a directory, a file that cannot be read, a path that does not exist. "Not a script"
163/// and "cannot tell" deliberately collapse into the same answer: the caller's only use for `Some`
164/// is to *add* a refusal, so an unreadable file shrinks the check rather than failing it, matching
165/// the shrink-not-fail rule every other resolver in this crate follows.
166///
167/// One level of `env` indirection is resolved, because it is how nearly every installed console
168/// script is actually written:
169///
170///   * `#!/usr/bin/python3.12` → `Some("python3.12")` — the shebang target's last component.
171///   * `#!/usr/bin/env python3` → `Some("python3")` — the first real argument, not `env`.
172///   * `#!/usr/bin/env -S python3 -u` → `Some("python3")` — flags and `VAR=value` assignments are
173///     skipped, since `env`'s own arguments are not the interpreter.
174///   * `#!/usr/bin/env` with nothing after it → `Some("env")`, the only honest answer available.
175///
176/// A *bare name* is returned, never a path, because the name is what gets compared against a
177/// declared `interpreter_runtime`/`staged_runtime` grant's `binary` field, which the manifest
178/// schema defines as a bare binary name.
179///
180/// Pure apart from one bounded read, so it is unit-testable with tempfiles on any OS.
181pub(crate) fn shebang_interpreter_name(path: &Path) -> Option<String> {
182    let line = shebang_line(path, SHEBANG_PROBE_BYTES)?;
183
184    let mut tokens = line.split_whitespace();
185    let target = base_name(tokens.next()?)?;
186
187    if target != "env" {
188        return Some(target);
189    }
190    // `env`'s own flags (`-S`, `-i`, `--split-string=…`) and `VAR=value` assignments come before
191    // the program it will exec; the first token that is neither is the interpreter.
192    for token in tokens {
193        if token.starts_with('-') || token.contains('=') {
194            continue;
195        }
196        return base_name(token);
197    }
198    Some(target)
199}
200
201/// The text of the `#!` line at the head of `path`, after the `#!`, reading at most `limit` bytes.
202/// `None` when the file cannot be read or does not start with `#!`.
203///
204/// The shebang is the first line and only the first line; a `\r` counts as a terminator so a
205/// CRLF-saved script does not yield an interpreter with a stray carriage return glued on.
206pub(crate) fn shebang_line(path: &Path, limit: u64) -> Option<String> {
207    use std::io::Read;
208
209    let mut head = Vec::new();
210    std::fs::File::open(path)
211        .ok()?
212        .take(limit)
213        .read_to_end(&mut head)
214        .ok()?;
215
216    shebang_line_of(&head)
217}
218
219/// [`shebang_line`] over bytes already in hand: the text after a leading `#!` up to the first
220/// `\n` or `\r`, or `None` when `head` does not start with `#!`. The caller bounds `head`.
221pub(crate) fn shebang_line_of(head: &[u8]) -> Option<String> {
222    let rest = head.strip_prefix(b"#!")?;
223    let line_end = rest
224        .iter()
225        .position(|&byte| byte == b'\n' || byte == b'\r')
226        .unwrap_or(rest.len());
227    Some(String::from_utf8_lossy(&rest[..line_end]).into_owned())
228}
229
230/// Last path component of `raw`, as an owned `String`. `None` for an empty or root-only string,
231/// which cannot name a binary.
232fn base_name(raw: &str) -> Option<String> {
233    Path::new(raw)
234        .file_name()
235        .map(|name| name.to_string_lossy().into_owned())
236}
237
238/// Refuses a `sealed` capsule whose `capabilities.shell.allow` names a script that nothing
239/// declared could make importable inside the composed root.
240///
241/// See the module doc for why this is a refusal and what it does *not* claim. In short: for each
242/// allowlisted entry that resolves to a `#!` script, the entry is accepted when **any** of the
243/// following holds, and collected as unreachable when none does.
244///
245///   1. **The script already lives under [`crate::sealed::SEALED_RUNTIME_PATHS`].** That tree is
246///      bind-mounted whole into every composed root and granted `list_dir: true`, so a system
247///      `/usr/bin/pip3` reaches its `dist-packages` beside it without anything being declared.
248///      (Read access is all an import needs; the missing `Execute` right on that tree is Case 2's
249///      problem, below, not this one's.)
250///   2. **The script lives under a directory the manifest already named** in a `staged_runtime`
251///      `source_path` or an `interpreter_runtime` dir. The operator staged the tree the script is
252///      part of.
253///   3. **Some grant names this binary, or its shebang interpreter, by name.** This is the
254///      general case, and the match is *exact string equality* against the grant's `binary` field
255///      — not a prefix, not a basename, not case-insensitive. It can be exact because the
256///      manifest parser already requires a grant's `binary` to appear verbatim in the same
257///      block's `allow`, so the strings being compared come from the same list.
258///
259/// Rule 3 is the loose one on purpose. A grant naming `python3` satisfies every script whose
260/// shebang says `python3`, regardless of whether the directory that grant names is the one holding
261/// the package the script imports. Verifying *that* would mean deriving the import closure, which
262/// is the thing this module opens by saying is not derivable. So the guarantee here is narrow and
263/// stated rather than implied: the operator was made to think about the interpreter's runtime and
264/// write something down. Getting the directory right is still on them, and the refusal text names
265/// the `strace` invocation that measures it.
266///
267/// A non-script entry is skipped unconditionally — an ELF binary's reachability is already
268/// `resolve_landlock_grants`' concern, and re-deciding it here would be a second, weaker opinion
269/// about a question that is already answered correctly.
270pub fn check_interpreted_entrypoints_reachable(
271    policy: &CapabilityPolicy,
272    declared_floor: ContainmentClass,
273) -> Result<(), RuntimeError> {
274    check_interpreted_entrypoints_reachable_in(policy, declared_floor, &host_path_dirs())
275}
276
277/// Testable core of [`check_interpreted_entrypoints_reachable`], with the `PATH` directory list
278/// injected so a test can point it at a temp directory holding synthetic fixtures instead of at
279/// whatever the machine running the suite happens to have installed. Same injection pattern
280/// `sandbox::resolve_exec_allowlist_in` and `resolve_landlock_grants_in` already use.
281fn check_interpreted_entrypoints_reachable_in(
282    policy: &CapabilityPolicy,
283    declared_floor: ContainmentClass,
284    path_dirs: &[PathBuf],
285) -> Result<(), RuntimeError> {
286    if declared_floor != ContainmentClass::Sealed {
287        return Ok(());
288    }
289
290    let fixed_dirs = sealed_runtime_paths();
291    let declared_dirs = declared_grant_dirs(policy);
292    let mut entries: Vec<UnreachableEntrypoint> = Vec::new();
293
294    for entry in &policy.shell_allow {
295        if entry.is_empty() {
296            continue;
297        }
298        let resolved = PathBuf::from(crate::sandbox::resolve_invoked_binary_path_in(
299            entry, path_dirs,
300        ));
301        // `resolve_invoked_binary_path_in` falls back to the bare name when nothing resolves. A
302        // name that resolves to nothing is not this check's business: it cannot be exec'd at all,
303        // which the OS will say more clearly than this refusal could.
304        if !resolved.is_absolute() {
305            continue;
306        }
307        if under_any(&resolved, &fixed_dirs) || under_any(&resolved, &declared_dirs) {
308            continue;
309        }
310        let Some(interpreter) = shebang_interpreter_name(&resolved) else {
311            continue;
312        };
313        if declared_grant_binaries(policy)
314            .any(|binary| binary == entry.as_str() || binary == interpreter.as_str())
315        {
316            continue;
317        }
318        entries.push(UnreachableEntrypoint {
319            binary: entry.clone(),
320            resolved_path: resolved,
321            interpreter,
322        });
323    }
324
325    if entries.is_empty() {
326        return Ok(());
327    }
328    // Every offender in one refusal, following `StagedRuntimeRequiresSealed`: an operator fixing
329    // this should not have to re-run to discover the second script.
330    Err(RuntimeError::ShellBinaryPackageUnreachable { entries })
331}
332
333/// Warns (`W-SEC-012`, non-fatal, once per uncovered helper) when a `sealed` capsule allowlists a
334/// known compiler driver whose helper binaries have no grant carrying the Landlock `Execute`
335/// right. Returns what it warned about so the decision is assertable without capturing stderr.
336///
337/// A helper is **covered** by exactly two things, both of which carry `executable: true`:
338///
339///   * a declared `staged_runtime` `source_path` or `interpreter_runtime` directory the helper
340///     lives under (see `sandbox::resolve_staged_runtime_landlock_grants` and
341///     `resolve_interpreter_runtime_grants`); or
342///   * the helper being allowlisted in its own right — either named directly in `shell.allow`, or
343///     resolved to by some `shell.allow` entry — since every allowlisted binary gets its own
344///     `Execute` grant.
345///
346/// Residence under [`crate::sealed::SEALED_RUNTIME_PATHS`] is **not** coverage, and this is the
347/// one place where that distinction is the entire point. `sandbox::resolve_sealed_runtime_landlock_grants`
348/// grants that tree `executable: false` by deliberate design — it is the only grant covering whole
349/// host trees the manifest never named, so it must make them enumerable without making them
350/// runnable. `/usr/bin/as` and `/usr/libexec/gcc/.../cc1` are therefore present in the composed
351/// root, readable, and un-exec'able. Treating "it's under `/usr`" as coverage here would suppress
352/// exactly the warning this function exists to emit.
353///
354/// Never returns an error and never fails a launch. Under a declared `sealed` floor, each known
355/// driver entry that resolves to an absolute path ends in exactly one of three states:
356///
357/// | Probe outcome | Reported as |
358/// |---|---|
359/// | The driver answered, for each helper | zero or more `uncovered` entries, one `W-SEC-012` each |
360/// | The driver ran and named nothing usable — non-zero exit, empty or unrecognised output, a path that does not exist | nothing: an unknown driver family shrinks the check rather than failing it |
361/// | The driver could not be asked — the spawn failed with an OS error, or a signal killed it | one `unprobed` entry and one `W-SEC-029`, after which that entry's remaining helpers are not probed |
362///
363/// A driver entry that does not resolve contributes nothing to either list.
364pub fn warn_on_unreachable_toolchain_helpers(
365    policy: &CapabilityPolicy,
366    declared_floor: ContainmentClass,
367) -> ToolchainHelperReport {
368    let report = unreachable_toolchain_helpers_in(policy, declared_floor, &host_path_dirs());
369    for warning in &report.uncovered {
370        let link = security_warning_link(W_SEC_012);
371        crate::runtime_err!(
372            "[capsule-runtime] warning[{W_SEC_012}]: capabilities.shell.allow grants the compiler \
373             driver '{}', but its helper '{}' at {} has no grant carrying the Landlock Execute \
374             right under the 'sealed' composed root — the fixed sealed runtime tree ({}) is bound \
375             read-only and listable but deliberately not executable, so the driver will start and \
376             then fail partway through a real compile; declare \
377             capabilities.shell.interpreter_runtime or staged_runtime for '{}' naming that \
378             helper's directory ({}) to grant it ({link})",
379            warning.driver,
380            warning.helper,
381            warning.resolved_path.display(),
382            crate::sealed::SEALED_RUNTIME_PATHS.join(", "),
383            warning.driver,
384            warning
385                .resolved_path
386                .parent()
387                .unwrap_or_else(|| Path::new("/"))
388                .display(),
389        );
390    }
391    for unprobed in &report.unprobed {
392        let link = security_warning_link(W_SEC_029);
393        crate::runtime_err!(
394            "[capsule-runtime] warning[{W_SEC_029}]: capabilities.shell.allow grants the compiler \
395             driver '{}', but running {} -print-prog-name=<helper> failed ({}), so W-SEC-012 was \
396             not evaluated for its helpers [{}] and they may have no Execute grant under the \
397             'sealed' composed root; check that {} can be executed by this user and re-run \
398             `mur doctor` ({link})",
399            unprobed.driver,
400            unprobed.driver_path.display(),
401            unprobed.reason,
402            unprobed.unchecked_helpers.join(", "),
403            unprobed.driver_path.display(),
404        );
405    }
406    report
407}
408
409/// Testable core of [`warn_on_unreachable_toolchain_helpers`]: decides, prints nothing, and takes
410/// the `PATH` directory list as an argument.
411fn unreachable_toolchain_helpers_in(
412    policy: &CapabilityPolicy,
413    declared_floor: ContainmentClass,
414    path_dirs: &[PathBuf],
415) -> ToolchainHelperReport {
416    let mut report = ToolchainHelperReport::default();
417    if declared_floor != ContainmentClass::Sealed {
418        return report;
419    }
420
421    let declared_dirs = declared_grant_dirs(policy);
422    // Every path some `shell.allow` entry resolves to, so a helper that is itself allowlisted
423    // under a different spelling (`/usr/bin/ld` vs `ld`) still counts as covered.
424    let allowlisted_paths: Vec<PathBuf> = policy
425        .shell_allow
426        .iter()
427        .filter(|entry| !entry.is_empty())
428        .map(|entry| {
429            PathBuf::from(crate::sandbox::resolve_invoked_binary_path_in(
430                entry, path_dirs,
431            ))
432        })
433        .collect();
434
435    for entry in &policy.shell_allow {
436        let Some((_, helpers)) = KNOWN_TOOLCHAIN_DRIVERS
437            .iter()
438            .find(|(driver, _)| base_name(entry).as_deref() == Some(*driver))
439        else {
440            continue;
441        };
442        let driver_path = PathBuf::from(crate::sandbox::resolve_invoked_binary_path_in(
443            entry, path_dirs,
444        ));
445        if !driver_path.is_absolute() {
446            continue;
447        }
448
449        for (index, helper) in helpers.iter().enumerate() {
450            let resolved_path = match probe_helper_path(&driver_path, helper, path_dirs) {
451                HelperProbe::Located(path) => path,
452                HelperProbe::NothingUsable => continue,
453                HelperProbe::NotAsked { error_kind, reason } => {
454                    // A driver that cannot be exec'd for one helper cannot be exec'd for the
455                    // next either, so one report covers the rest of the row.
456                    let unprobed = UnprobedToolchainDriver {
457                        driver: entry.clone(),
458                        driver_path: driver_path.clone(),
459                        unchecked_helpers: helpers[index..]
460                            .iter()
461                            .map(|name| (*name).to_string())
462                            .collect(),
463                        error_kind,
464                        reason,
465                    };
466                    if !report.unprobed.contains(&unprobed) {
467                        report.unprobed.push(unprobed);
468                    }
469                    break;
470                }
471            };
472            if under_any(&resolved_path, &declared_dirs)
473                || policy.shell_allow.iter().any(|allowed| allowed == helper)
474                || allowlisted_paths.contains(&resolved_path)
475            {
476                continue;
477            }
478            let warning = ToolchainHelperWarning {
479                driver: entry.clone(),
480                helper: (*helper).to_string(),
481                resolved_path,
482            };
483            // `cc` and `gcc` are usually the same binary reached under two names, so a capsule
484            // allowlisting both would otherwise report each helper twice.
485            if !report.uncovered.contains(&warning) {
486                report.uncovered.push(warning);
487            }
488        }
489    }
490
491    report
492}
493
494/// What one `-print-prog-name=` probe established.
495#[derive(Debug, Clone, PartialEq, Eq)]
496enum HelperProbe {
497    /// The driver answered with a helper path that exists; canonical.
498    Located(PathBuf),
499    /// The driver ran and exited, but named nothing usable.
500    NothingUsable,
501    /// The driver was never asked: the spawn failed (`error_kind` is `Some`), or a signal
502    /// terminated it before it exited (`error_kind` is `None`).
503    NotAsked {
504        error_kind: Option<std::io::ErrorKind>,
505        reason: String,
506    },
507}
508
509/// Asks `driver` where it would find `helper`, via the GCC driver's own `-print-prog-name=` query.
510///
511/// A spawn error, or an exit by signal, is [`HelperProbe::NotAsked`]: the driver gave no answer,
512/// so nothing is known about `helper`. The spawn happens exactly once; a driver that is busy
513/// (`ETXTBSY`) or out of resources at staging time is reported rather than retried.
514///
515/// A driver that exited on its own has answered, and the answer has three shapes, all handled here
516/// because GCC itself produces two of them:
517///
518///   * an **absolute path** — `cc -print-prog-name=cc1` →
519///     `/usr/libexec/gcc/x86_64-linux-gnu/13/cc1`. The driver found it in its private libexec
520///     directory. Canonicalized and returned.
521///   * the **helper name, unchanged** — `cc -print-prog-name=as` → `as`. This is *not* "no such
522///     helper": it is the driver saying it will let `execvp` find that one on `PATH`, which is
523///     exactly what it does for the assembler and the linker on a Debian/Ubuntu GCC. So the name
524///     is resolved through `path_dirs` rather than discarded; `as` and `ld` really do live at
525///     `/usr/bin/as` and `/usr/bin/ld`, really are exec'd during a compile, and really are inside
526///     the non-executable fixed sealed tree. Discarding them would have made this check blind to
527///     two of the four helpers that matter most.
528///   * **anything else** — a driver that does not understand the flag (it typically echoes the
529///     flag back, or writes usage text to stderr and exits non-zero), an empty stdout, a path that
530///     does not exist. All [`HelperProbe::NothingUsable`]: shrink-not-fail, so an unknown driver
531///     family contributes no warnings rather than bogus ones.
532///
533/// This is the one part of the module that spawns a process. It is a single short-lived exec of a
534/// binary the manifest itself allowlisted, running in the parent at staging time — never in a
535/// forked child's `pre_exec` window, and never after the sandbox is installed.
536fn probe_helper_path(driver: &Path, helper: &str, path_dirs: &[PathBuf]) -> HelperProbe {
537    let output = match std::process::Command::new(driver)
538        .arg(format!("-print-prog-name={helper}"))
539        .output()
540    {
541        Ok(output) => output,
542        Err(error) => {
543            return HelperProbe::NotAsked {
544                error_kind: Some(error.kind()),
545                reason: error.to_string(),
546            }
547        }
548    };
549    if let Some(reason) = terminating_signal(&output.status) {
550        return HelperProbe::NotAsked {
551            error_kind: None,
552            reason,
553        };
554    }
555    if !output.status.success() {
556        return HelperProbe::NothingUsable;
557    }
558    let printed = String::from_utf8_lossy(&output.stdout).trim().to_string();
559    if printed.is_empty() {
560        return HelperProbe::NothingUsable;
561    }
562
563    let candidate = if printed == helper {
564        // The driver deferred to `PATH`; resolve it the way the exec would.
565        PathBuf::from(crate::sandbox::resolve_invoked_binary_path_in(
566            helper, path_dirs,
567        ))
568    } else {
569        PathBuf::from(&printed)
570    };
571    if !candidate.is_absolute() {
572        return HelperProbe::NothingUsable;
573    }
574    match std::fs::canonicalize(candidate) {
575        Ok(path) => HelperProbe::Located(path),
576        Err(_) => HelperProbe::NothingUsable,
577    }
578}
579
580/// `terminated by signal N` when `status` records a signal rather than an exit code. Only Unix
581/// distinguishes the two.
582#[cfg(unix)]
583fn terminating_signal(status: &std::process::ExitStatus) -> Option<String> {
584    use std::os::unix::process::ExitStatusExt;
585    status
586        .signal()
587        .map(|signal| format!("terminated by signal {signal}"))
588}
589
590#[cfg(not(unix))]
591fn terminating_signal(_status: &std::process::ExitStatus) -> Option<String> {
592    None
593}
594
595/// The host `PATH`, split the same way `execvp` splits it — the single place this module reads
596/// process environment, so both public entry points share one definition of "where binaries are".
597fn host_path_dirs() -> Vec<PathBuf> {
598    std::env::var_os("PATH")
599        .map(|path| std::env::split_paths(&path).collect())
600        .unwrap_or_default()
601}
602
603/// [`crate::sealed::SEALED_RUNTIME_PATHS`] as owned paths, for [`under_any`].
604fn sealed_runtime_paths() -> Vec<PathBuf> {
605    crate::sealed::SEALED_RUNTIME_PATHS
606        .iter()
607        .map(PathBuf::from)
608        .collect()
609}
610
611/// Every host directory the manifest already named: each `staged_runtime` `source_path` and each
612/// `interpreter_runtime` directory. These are the trees that get a bind and an
613/// `executable: true` Landlock grant, so residence under one is coverage for both checks.
614fn declared_grant_dirs(policy: &CapabilityPolicy) -> Vec<PathBuf> {
615    policy
616        .shell_staged_runtime
617        .iter()
618        .map(|grant| PathBuf::from(&grant.source_path))
619        .chain(
620            policy
621                .shell_interpreter_runtime
622                .iter()
623                .flat_map(|grant| grant.dirs.iter().map(|dir| PathBuf::from(&dir.path))),
624        )
625        .filter(|path| path.is_absolute())
626        .collect()
627}
628
629/// Every binary name a `staged_runtime` or `interpreter_runtime` grant declares.
630fn declared_grant_binaries(policy: &CapabilityPolicy) -> impl Iterator<Item = &str> {
631    policy
632        .shell_staged_runtime
633        .iter()
634        .map(|grant| grant.binary.as_str())
635        .chain(
636            policy
637                .shell_interpreter_runtime
638                .iter()
639                .map(|grant| grant.binary.as_str()),
640        )
641}
642
643/// Whether `path` is `root` or sits beneath one of `roots`.
644///
645/// `Path::starts_with` is component-wise, not textual, so `/usrlocal/bin/pip` is correctly *not*
646/// under `/usr` — the trap a `str::starts_with` would fall into.
647fn under_any(path: &Path, roots: &[PathBuf]) -> bool {
648    roots.iter().any(|root| path.starts_with(root))
649}
650
651#[cfg(test)]
652mod tests {
653    use super::*;
654    use murmur_artifact::{InterpreterRuntimeDir, InterpreterRuntimeGrant, StagedRuntimeGrant};
655
656    /// Every containment class, so a new variant cannot quietly escape a test that iterates "all
657    /// of them". Mirrors the same list in `staged_runtime`'s tests.
658    const ALL_CLASSES: &[ContainmentClass] = &[
659        ContainmentClass::Advisory,
660        ContainmentClass::Scoped,
661        ContainmentClass::Sealed,
662    ];
663
664    /// Writes an executable file with the given first bytes into `dir`, and returns its path.
665    fn write_binary(dir: &Path, name: &str, contents: &[u8]) -> PathBuf {
666        let path = dir.join(name);
667        std::fs::write(&path, contents).expect("write fixture");
668        #[cfg(unix)]
669        {
670            use std::os::unix::fs::PermissionsExt;
671            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
672                .expect("chmod fixture");
673        }
674        path
675    }
676
677    fn script(dir: &Path, name: &str, shebang: &str) -> PathBuf {
678        write_binary(dir, name, format!("{shebang}\nprint('hi')\n").as_bytes())
679    }
680
681    /// A minimal, deliberately *invalid* ELF image: enough magic for `shebang_interpreter_name` to
682    /// reject it as "not a script", which is the only property these tests need from it.
683    fn elf(dir: &Path, name: &str) -> PathBuf {
684        let mut bytes = b"\x7fELF\x02\x01\x01\x00".to_vec();
685        bytes.resize(128, 0);
686        write_binary(dir, name, &bytes)
687    }
688
689    fn policy_allowing(names: &[&str]) -> CapabilityPolicy {
690        CapabilityPolicy {
691            shell_allow: names.iter().map(|name| (*name).to_string()).collect(),
692            ..CapabilityPolicy::default()
693        }
694    }
695
696    fn interpreter_grant(binary: &str, dirs: &[&str]) -> InterpreterRuntimeGrant {
697        InterpreterRuntimeGrant {
698            binary: binary.to_string(),
699            dirs: dirs
700                .iter()
701                .map(|path| InterpreterRuntimeDir {
702                    path: (*path).to_string(),
703                    list_dir: true,
704                })
705                .collect(),
706        }
707    }
708
709    fn staged_grant(binary: &str, source_path: &str) -> StagedRuntimeGrant {
710        StagedRuntimeGrant {
711            binary: binary.to_string(),
712            source_path: source_path.to_string(),
713            pin: "test-pin-1".to_string(),
714        }
715    }
716
717    // ---------------------------------------------------------------- shebang_interpreter_name
718
719    #[test]
720    fn shebang_returns_the_interpreter_basename_for_a_direct_target() {
721        let dir = tempfile::tempdir().expect("tempdir");
722        let path = script(dir.path(), "direct", "#!/usr/bin/python3.12");
723        assert_eq!(
724            shebang_interpreter_name(&path),
725            Some("python3.12".to_string())
726        );
727    }
728
729    #[test]
730    fn shebang_resolves_one_level_of_env_indirection() {
731        let dir = tempfile::tempdir().expect("tempdir");
732        for (name, line, expected) in [
733            ("plain-env", "#!/usr/bin/env python3", "python3"),
734            ("split-string", "#!/usr/bin/env -S python3 -u", "python3"),
735            ("assignment", "#!/usr/bin/env FOO=bar node", "node"),
736            // Nothing follows `env`, so `env` itself is the only honest answer.
737            ("bare-env", "#!/usr/bin/env", "env"),
738        ] {
739            let path = script(dir.path(), name, line);
740            assert_eq!(
741                shebang_interpreter_name(&path),
742                Some(expected.to_string()),
743                "shebang line: {line}"
744            );
745        }
746    }
747
748    /// A CRLF-saved script must not yield `"python3\r"`, which would silently fail every
749    /// grant-name comparison downstream.
750    #[test]
751    fn shebang_stops_at_a_carriage_return() {
752        let dir = tempfile::tempdir().expect("tempdir");
753        let path = write_binary(
754            dir.path(),
755            "crlf",
756            b"#!/usr/bin/env python3\r\nprint(1)\r\n",
757        );
758        assert_eq!(shebang_interpreter_name(&path), Some("python3".to_string()));
759    }
760
761    /// The property that makes "no shebang" a usable proxy for "this is an ELF binary, and its
762    /// reachability is already `resolve_landlock_grants`' problem".
763    #[test]
764    fn shebang_is_none_for_an_elf_image_and_for_unreadable_paths() {
765        let dir = tempfile::tempdir().expect("tempdir");
766        assert_eq!(shebang_interpreter_name(&elf(dir.path(), "binary")), None);
767        assert_eq!(
768            shebang_interpreter_name(&write_binary(dir.path(), "plain", b"just text\n")),
769            None
770        );
771        assert_eq!(
772            shebang_interpreter_name(&dir.path().join("does-not-exist")),
773            None
774        );
775        assert_eq!(shebang_interpreter_name(dir.path()), None);
776    }
777
778    /// The read is bounded, so pointing `shell.allow` at a huge file costs 512 bytes, not the
779    /// file. Asserted by giving the shebang line more leading junk than the bound allows.
780    #[test]
781    fn shebang_probe_reads_a_bounded_prefix_only() {
782        let dir = tempfile::tempdir().expect("tempdir");
783        let mut bytes = vec![b'#'; SHEBANG_PROBE_BYTES as usize * 4];
784        bytes.extend_from_slice(b"\n#!/usr/bin/env python3\n");
785        // The `#!` is past the bound *and* not at byte 0, so this is not a script either way.
786        assert_eq!(
787            shebang_interpreter_name(&write_binary(dir.path(), "huge", &bytes)),
788            None
789        );
790    }
791
792    // ------------------------------------------------- check_interpreted_entrypoints_reachable
793
794    /// Scenario 1: a script outside every fixed and declared tree, with no grant naming it or its
795    /// interpreter, refuses — and the refusal is actionable on its own text.
796    #[test]
797    fn an_uncovered_script_entrypoint_refuses_naming_binary_path_and_interpreter() {
798        let dir = tempfile::tempdir().expect("tempdir");
799        script(dir.path(), "pip", "#!/usr/bin/env python3");
800        let dirs = vec![dir.path().to_path_buf()];
801
802        let error = check_interpreted_entrypoints_reachable_in(
803            &policy_allowing(&["pip"]),
804            ContainmentClass::Sealed,
805            &dirs,
806        )
807        .expect_err("an uncovered interpreted entrypoint must refuse");
808
809        let rendered = error.to_string();
810        assert!(rendered.contains("pip"), "{rendered}");
811        assert!(
812            rendered.contains(&dir.path().join("pip").display().to_string()),
813            "{rendered}"
814        );
815        assert!(rendered.contains("python3"), "{rendered}");
816        assert!(rendered.contains("interpreter_runtime"), "{rendered}");
817        assert!(rendered.contains("strace"), "{rendered}");
818    }
819
820    /// Scenario 2: any covering grant naming the script *or* its interpreter is enough. This is
821    /// the deliberately loose rule — the grant's directory is never checked for correctness.
822    #[test]
823    fn a_grant_naming_the_script_or_its_interpreter_is_sufficient() {
824        let dir = tempfile::tempdir().expect("tempdir");
825        script(dir.path(), "pip", "#!/usr/bin/env python3");
826        let dirs = vec![dir.path().to_path_buf()];
827
828        for policy in [
829            CapabilityPolicy {
830                // Names the interpreter, and points at a directory that has nothing to do with
831                // the script's actual package — still accepted, by design.
832                shell_interpreter_runtime: vec![interpreter_grant("python3", &["/opt/unrelated"])],
833                ..policy_allowing(&["pip"])
834            },
835            CapabilityPolicy {
836                // Names the script itself.
837                shell_staged_runtime: vec![staged_grant("pip", "/opt/unrelated")],
838                ..policy_allowing(&["pip"])
839            },
840        ] {
841            assert!(
842                check_interpreted_entrypoints_reachable_in(
843                    &policy,
844                    ContainmentClass::Sealed,
845                    &dirs
846                )
847                .is_ok(),
848                "a declared covering grant must be sufficient"
849            );
850        }
851    }
852
853    /// Scenario 2, second limb: a script that *sits inside* a declared tree is covered by
854    /// residence, with no name match needed.
855    #[test]
856    fn a_script_inside_a_declared_grant_directory_is_covered_by_residence() {
857        let dir = tempfile::tempdir().expect("tempdir");
858        script(dir.path(), "pip", "#!/usr/bin/env python3");
859        let dirs = vec![dir.path().to_path_buf()];
860
861        let policy = CapabilityPolicy {
862            shell_staged_runtime: vec![staged_grant(
863                "some-other-binary",
864                dir.path().to_str().expect("utf-8 temp path"),
865            )],
866            ..policy_allowing(&["pip"])
867        };
868        assert!(check_interpreted_entrypoints_reachable_in(
869            &policy,
870            ContainmentClass::Sealed,
871            &dirs
872        )
873        .is_ok());
874    }
875
876    /// Scenario 3: a system script under the fixed,
877    /// fully-bound sealed tree needs no declaration at all.
878    #[test]
879    fn a_script_under_a_fixed_sealed_runtime_path_needs_no_grant() {
880        // Path-form `shell.allow` entries bypass `PATH` entirely, so this asserts the prefix rule
881        // without needing a writable `/usr` on the machine running the suite.
882        for entry in crate::sealed::SEALED_RUNTIME_PATHS {
883            let policy = policy_allowing(&[&format!("{entry}/bin/some-console-script")]);
884            assert!(
885                check_interpreted_entrypoints_reachable_in(&policy, ContainmentClass::Sealed, &[])
886                    .is_ok(),
887                "a path under {entry} must never be collected"
888            );
889        }
890    }
891
892    /// The component-wise prefix rule: `/usr-local` is not under `/usr`. Guards against anyone
893    /// "simplifying" `under_any` into a string comparison.
894    #[test]
895    fn prefix_matching_is_component_wise_not_textual() {
896        assert!(under_any(
897            Path::new("/usr/bin/pip"),
898            &[PathBuf::from("/usr")]
899        ));
900        assert!(!under_any(
901            Path::new("/usrlocal/bin/pip"),
902            &[PathBuf::from("/usr")]
903        ));
904    }
905
906    /// Scenario 4: ELF entries are never this check's business, whatever else is true.
907    #[test]
908    fn elf_entrypoints_are_never_collected() {
909        let dir = tempfile::tempdir().expect("tempdir");
910        elf(dir.path(), "bash");
911        let dirs = vec![dir.path().to_path_buf()];
912        assert!(check_interpreted_entrypoints_reachable_in(
913            &policy_allowing(&["bash"]),
914            ContainmentClass::Sealed,
915            &dirs
916        )
917        .is_ok());
918    }
919
920    /// An entry that resolves to nothing is not a reachability problem — it is a missing binary,
921    /// which the OS reports far more clearly than this refusal could.
922    #[test]
923    fn an_unresolvable_entry_is_skipped_rather_than_refused() {
924        assert!(check_interpreted_entrypoints_reachable_in(
925            &policy_allowing(&["definitely-not-installed-anywhere"]),
926            ContainmentClass::Sealed,
927            &[]
928        )
929        .is_ok());
930    }
931
932    /// Scenario 1, the "name every offender at once" property inherited from
933    /// `StagedRuntimeRequiresSealed`.
934    #[test]
935    fn the_refusal_names_every_unreachable_entrypoint() {
936        let dir = tempfile::tempdir().expect("tempdir");
937        script(dir.path(), "pip", "#!/usr/bin/env python3");
938        script(dir.path(), "rake", "#!/usr/bin/env ruby");
939        let dirs = vec![dir.path().to_path_buf()];
940
941        let error = check_interpreted_entrypoints_reachable_in(
942            &policy_allowing(&["pip", "rake"]),
943            ContainmentClass::Sealed,
944            &dirs,
945        )
946        .expect_err("both entrypoints are uncovered");
947        let rendered = error.to_string();
948        for expected in ["pip", "python3", "rake", "ruby"] {
949            assert!(
950                rendered.contains(expected),
951                "missing {expected}: {rendered}"
952            );
953        }
954    }
955
956    // ------------------------------------------------------ declared-floor gating (Scenario 5)
957
958    /// Both checks are functions of the *declared* floor alone, and both are fully inert below
959    /// `sealed`. Asserted together because they share the gate and must not drift apart.
960    #[test]
961    fn both_checks_are_inert_below_a_declared_sealed_floor() {
962        let dir = tempfile::tempdir().expect("tempdir");
963        script(dir.path(), "pip", "#!/usr/bin/env python3");
964        let dirs = vec![dir.path().to_path_buf()];
965        let policy = policy_allowing(&["pip", "cc"]);
966
967        for class in ALL_CLASSES {
968            let refusal = check_interpreted_entrypoints_reachable_in(&policy, *class, &dirs);
969            let report = unreachable_toolchain_helpers_in(&policy, *class, &dirs);
970            if *class == ContainmentClass::Sealed {
971                assert!(refusal.is_err(), "sealed must still be checked");
972            } else {
973                assert!(refusal.is_ok(), "{class} must not be refused");
974                assert!(report.uncovered.is_empty(), "{class} must not warn");
975                assert!(
976                    report.unprobed.is_empty(),
977                    "{class} must not probe anything: {report:?}"
978                );
979            }
980        }
981    }
982
983    // ------------------------------------------------- warn_on_unreachable_toolchain_helpers
984
985    /// A stand-in compiler driver that answers `-print-prog-name=<helper>` the way GCC does,
986    /// letting Scenarios 6 and 7 run on any host with a shell — including one with no real GCC.
987    /// Absolute answers for the front ends, `PATH`-deferred answers for the assembler/linker,
988    /// matching GCC's own behaviour.
989    ///
990    /// Settled before returning, so a probe of the result cannot fail with `ETXTBSY`.
991    #[cfg(unix)]
992    fn fake_driver(dir: &Path, name: &str, libexec: &Path) -> PathBuf {
993        let script = format!(
994            "#!/bin/sh\n\
995             case \"$1\" in\n\
996             -print-prog-name=cc1) echo {libexec}/cc1 ;;\n\
997             -print-prog-name=cc1plus) echo {libexec}/cc1plus ;;\n\
998             -print-prog-name=collect2) echo {libexec}/collect2 ;;\n\
999             -print-prog-name=as) echo as ;;\n\
1000             -print-prog-name=ld) echo ld ;;\n\
1001             *) exit 1 ;;\n\
1002             esac\n",
1003            libexec = libexec.display()
1004        );
1005        let path = write_binary(dir, name, script.as_bytes());
1006        settle_exec_fixture(&path);
1007        path
1008    }
1009
1010    /// Upper bound on [`settle_exec_fixture`]'s attempts; with [`SETTLE_EXEC_INTERVAL`] the
1011    /// helper gives up after about two seconds of `ETXTBSY`.
1012    #[cfg(unix)]
1013    const SETTLE_EXEC_ATTEMPTS: u32 = 200;
1014
1015    #[cfg(unix)]
1016    const SETTLE_EXEC_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10);
1017
1018    /// Execs `path` until an attempt fails with something other than `ExecutableFileBusy`,
1019    /// panicking if it is still busy after [`SETTLE_EXEC_ATTEMPTS`].
1020    ///
1021    /// `std::fs::write` closes its descriptor before returning, but a child another test thread
1022    /// forked while that descriptor was open holds an inherited copy until its own `execve`
1023    /// closes it (`O_CLOEXEC`). An exec of the fixture inside that window fails with `ETXTBSY`.
1024    /// Once one exec of the file has got past the busy check, no process holds a write
1025    /// descriptor for it, and none can acquire one by inheritance because nobody holds one to
1026    /// inherit — so the race cannot recur for that file, and a later exec by the code under test
1027    /// sees the file as a normal executable.
1028    #[cfg(unix)]
1029    fn settle_exec_fixture(path: &Path) {
1030        for _ in 0..SETTLE_EXEC_ATTEMPTS {
1031            match std::process::Command::new(path).output() {
1032                Err(error) if error.kind() == std::io::ErrorKind::ExecutableFileBusy => {
1033                    std::thread::sleep(SETTLE_EXEC_INTERVAL);
1034                }
1035                _ => return,
1036            }
1037        }
1038        panic!(
1039            "{} was still text file busy after {SETTLE_EXEC_ATTEMPTS} exec attempts",
1040            path.display()
1041        );
1042    }
1043
1044    /// Scenario 6: an allowlisted driver whose helpers have no `Execute`-carrying grant warns,
1045    /// naming driver, helper and resolved path.
1046    #[cfg(unix)]
1047    #[test]
1048    fn an_uncovered_toolchain_helper_warns_naming_driver_helper_and_path() {
1049        let dir = tempfile::tempdir().expect("tempdir");
1050        let libexec = dir.path().join("libexec");
1051        std::fs::create_dir_all(&libexec).expect("libexec");
1052        for helper in ["cc1", "cc1plus", "collect2"] {
1053            write_binary(&libexec, helper, b"#!/bin/sh\nexit 0\n");
1054        }
1055        fake_driver(dir.path(), "cc", &libexec);
1056        let dirs = vec![dir.path().to_path_buf()];
1057
1058        let report = unreachable_toolchain_helpers_in(
1059            &policy_allowing(&["cc"]),
1060            ContainmentClass::Sealed,
1061            &dirs,
1062        );
1063
1064        assert!(
1065            report.unprobed.is_empty(),
1066            "the driver must be probed: {report:?}"
1067        );
1068        let cc1 = report
1069            .uncovered
1070            .iter()
1071            .find(|warning| warning.helper == "cc1")
1072            .unwrap_or_else(|| panic!("cc1 must be reported as uncovered: {report:?}"));
1073        assert_eq!(cc1.driver, "cc");
1074        assert_eq!(
1075            cc1.resolved_path,
1076            std::fs::canonicalize(libexec.join("cc1")).expect("canonical cc1")
1077        );
1078    }
1079
1080    /// Scenario 7: a declared `interpreter_runtime` directory covers every helper inside it,
1081    /// because such a grant carries `executable: true`.
1082    #[cfg(unix)]
1083    #[test]
1084    fn a_declared_grant_directory_covers_the_helpers_inside_it() {
1085        let dir = tempfile::tempdir().expect("tempdir");
1086        let libexec = dir.path().join("libexec");
1087        std::fs::create_dir_all(&libexec).expect("libexec");
1088        for helper in ["cc1", "cc1plus", "collect2"] {
1089            write_binary(&libexec, helper, b"#!/bin/sh\nexit 0\n");
1090        }
1091        fake_driver(dir.path(), "cc", &libexec);
1092        let dirs = vec![dir.path().to_path_buf()];
1093
1094        let policy = CapabilityPolicy {
1095            shell_interpreter_runtime: vec![interpreter_grant(
1096                "cc",
1097                &[libexec.to_str().expect("utf-8 temp path")],
1098            )],
1099            ..policy_allowing(&["cc"])
1100        };
1101        let report = unreachable_toolchain_helpers_in(&policy, ContainmentClass::Sealed, &dirs);
1102
1103        assert!(
1104            report.unprobed.is_empty(),
1105            "the driver must be probed: {report:?}"
1106        );
1107        assert!(
1108            !report
1109                .uncovered
1110                .iter()
1111                .any(|warning| warning.helper == "cc1"),
1112            "a declared grant directory must cover its helpers: {report:?}"
1113        );
1114    }
1115
1116    /// The `PATH`-deferred answer (`-print-prog-name=as` → `as`) is resolved rather than
1117    /// discarded — the deviation from the design that keeps two of the four helpers that matter
1118    /// most from being invisible to this check.
1119    #[cfg(unix)]
1120    #[test]
1121    fn a_path_deferred_helper_answer_is_resolved_not_discarded() {
1122        let dir = tempfile::tempdir().expect("tempdir");
1123        let libexec = dir.path().join("libexec");
1124        std::fs::create_dir_all(&libexec).expect("libexec");
1125        // `as` lives on the injected `PATH`, not in the driver's libexec, and the driver answers
1126        // with the bare name.
1127        write_binary(dir.path(), "as", b"#!/bin/sh\nexit 0\n");
1128        fake_driver(dir.path(), "cc", &libexec);
1129        let dirs = vec![dir.path().to_path_buf()];
1130
1131        let report = unreachable_toolchain_helpers_in(
1132            &policy_allowing(&["cc"]),
1133            ContainmentClass::Sealed,
1134            &dirs,
1135        );
1136
1137        assert!(
1138            report.unprobed.is_empty(),
1139            "the driver must be probed: {report:?}"
1140        );
1141        let assembler = report
1142            .uncovered
1143            .iter()
1144            .find(|warning| warning.helper == "as")
1145            .unwrap_or_else(|| panic!("a PATH-deferred helper must still be checked: {report:?}"));
1146        assert_eq!(
1147            assembler.resolved_path,
1148            std::fs::canonicalize(dir.path().join("as")).expect("canonical as")
1149        );
1150    }
1151
1152    /// The load-bearing distinction: the fixed sealed tree is bound and listable
1153    /// but **not** executable, so a helper resolving under it is still uncovered. Written as a
1154    /// direct assertion on the coverage rule so that granting `SEALED_RUNTIME_PATHS` the
1155    /// `Execute` right one day cannot silently pass this test.
1156    #[test]
1157    fn residence_under_the_fixed_sealed_tree_is_not_coverage() {
1158        let policy = CapabilityPolicy {
1159            shell_interpreter_runtime: Vec::new(),
1160            shell_staged_runtime: Vec::new(),
1161            ..policy_allowing(&["cc"])
1162        };
1163        // `/usr/bin/as` is inside the fixed tree and inside nothing the policy declared.
1164        assert!(under_any(Path::new("/usr/bin/as"), &sealed_runtime_paths()));
1165        assert!(
1166            !under_any(Path::new("/usr/bin/as"), &declared_grant_dirs(&policy)),
1167            "the fixed tree must never be folded into the declared-grant set"
1168        );
1169    }
1170
1171    /// A helper the capsule allowlisted in its own right already has an `Execute` grant.
1172    #[cfg(unix)]
1173    #[test]
1174    fn a_helper_that_is_itself_allowlisted_is_covered() {
1175        let dir = tempfile::tempdir().expect("tempdir");
1176        let libexec = dir.path().join("libexec");
1177        std::fs::create_dir_all(&libexec).expect("libexec");
1178        write_binary(&libexec, "cc1", b"#!/bin/sh\nexit 0\n");
1179        fake_driver(dir.path(), "cc", &libexec);
1180        let dirs = vec![dir.path().to_path_buf()];
1181
1182        let report = unreachable_toolchain_helpers_in(
1183            &policy_allowing(&["cc", "cc1"]),
1184            ContainmentClass::Sealed,
1185            &dirs,
1186        );
1187        assert!(
1188            report.unprobed.is_empty(),
1189            "the driver must be probed: {report:?}"
1190        );
1191        assert!(
1192            !report
1193                .uncovered
1194                .iter()
1195                .any(|warning| warning.helper == "cc1"),
1196            "an allowlisted helper carries its own Execute grant: {report:?}"
1197        );
1198    }
1199
1200    /// A `shell.allow` entry that is not a known driver is never probed, so no unrelated binary
1201    /// is spawned by this check.
1202    #[cfg(unix)]
1203    #[test]
1204    fn a_non_driver_entry_is_never_probed() {
1205        let dir = tempfile::tempdir().expect("tempdir");
1206        // A "driver" that would answer if asked — but it is not in the known-driver table.
1207        fake_driver(dir.path(), "not-a-compiler", &dir.path().join("libexec"));
1208        let dirs = vec![dir.path().to_path_buf()];
1209
1210        assert_eq!(
1211            unreachable_toolchain_helpers_in(
1212                &policy_allowing(&["not-a-compiler"]),
1213                ContainmentClass::Sealed,
1214                &dirs
1215            ),
1216            ToolchainHelperReport::default()
1217        );
1218    }
1219
1220    /// Every helper in the `cc` row, as the owned strings `unchecked_helpers` holds.
1221    fn cc_row() -> Vec<String> {
1222        ["cc1", "cc1plus", "as", "ld", "collect2"]
1223            .iter()
1224            .map(|helper| (*helper).to_string())
1225            .collect()
1226    }
1227
1228    /// A driver the launching user cannot exec (`EACCES`) was never asked anything, so it is
1229    /// reported with every helper unchecked rather than read as a driver with nothing to say.
1230    #[cfg(unix)]
1231    #[test]
1232    fn a_driver_that_cannot_be_executed_is_reported_as_unprobed() {
1233        use std::os::unix::fs::PermissionsExt;
1234
1235        let dir = tempfile::tempdir().expect("tempdir");
1236        let driver = write_binary(dir.path(), "cc", b"#!/bin/sh\nexit 0\n");
1237        std::fs::set_permissions(&driver, std::fs::Permissions::from_mode(0o644))
1238            .expect("chmod 0644");
1239        let entry = driver.to_str().expect("utf-8 temp path");
1240
1241        let report = unreachable_toolchain_helpers_in(
1242            &policy_allowing(&[entry]),
1243            ContainmentClass::Sealed,
1244            &[],
1245        );
1246
1247        assert!(report.uncovered.is_empty(), "{report:?}");
1248        assert_eq!(report.unprobed.len(), 1, "{report:?}");
1249        let unprobed = &report.unprobed[0];
1250        assert_eq!(unprobed.driver, entry);
1251        assert_eq!(
1252            unprobed.driver_path,
1253            std::fs::canonicalize(&driver).expect("canonical driver")
1254        );
1255        assert_eq!(
1256            unprobed.error_kind,
1257            Some(std::io::ErrorKind::PermissionDenied),
1258            "{report:?}"
1259        );
1260        assert_eq!(unprobed.unchecked_helpers, cc_row());
1261    }
1262
1263    /// A process holding a write descriptor on the driver makes its exec fail with `ETXTBSY`.
1264    /// That is reported as unprobed, not as a driver that answered nothing.
1265    #[cfg(target_os = "linux")]
1266    #[test]
1267    fn a_driver_busy_being_written_is_reported_as_unprobed() {
1268        let dir = tempfile::tempdir().expect("tempdir");
1269        let libexec = dir.path().join("libexec");
1270        std::fs::create_dir_all(&libexec).expect("libexec");
1271        for helper in ["cc1", "cc1plus", "collect2"] {
1272            write_binary(&libexec, helper, b"#!/bin/sh\nexit 0\n");
1273        }
1274        // `fake_driver`'s script, written without settling: the handle below keeps it busy.
1275        let driver = write_binary(
1276            dir.path(),
1277            "cc",
1278            format!(
1279                "#!/bin/sh\n\
1280                 case \"$1\" in\n\
1281                 -print-prog-name=cc1) echo {libexec}/cc1 ;;\n\
1282                 *) exit 1 ;;\n\
1283                 esac\n",
1284                libexec = libexec.display()
1285            )
1286            .as_bytes(),
1287        );
1288        let dirs = vec![dir.path().to_path_buf()];
1289
1290        let writer = std::fs::OpenOptions::new()
1291            .append(true)
1292            .open(&driver)
1293            .expect("open driver for append");
1294        let report = unreachable_toolchain_helpers_in(
1295            &policy_allowing(&["cc"]),
1296            ContainmentClass::Sealed,
1297            &dirs,
1298        );
1299        drop(writer);
1300
1301        assert!(report.uncovered.is_empty(), "{report:?}");
1302        assert_eq!(report.unprobed.len(), 1, "{report:?}");
1303        assert_eq!(
1304            report.unprobed[0].error_kind,
1305            Some(std::io::ErrorKind::ExecutableFileBusy),
1306            "{report:?}"
1307        );
1308        assert_eq!(report.unprobed[0].unchecked_helpers, cc_row());
1309    }
1310
1311    /// A driver a signal terminates before it exits gave no answer, so it is unprobed with no
1312    /// `error_kind`.
1313    #[cfg(unix)]
1314    #[test]
1315    fn a_driver_killed_before_answering_is_reported_as_unprobed() {
1316        let dir = tempfile::tempdir().expect("tempdir");
1317        let driver = write_binary(dir.path(), "cc", b"#!/bin/sh\nkill -9 $$\n");
1318        settle_exec_fixture(&driver);
1319        let dirs = vec![dir.path().to_path_buf()];
1320
1321        let report = unreachable_toolchain_helpers_in(
1322            &policy_allowing(&["cc"]),
1323            ContainmentClass::Sealed,
1324            &dirs,
1325        );
1326
1327        assert!(report.uncovered.is_empty(), "{report:?}");
1328        assert_eq!(report.unprobed.len(), 1, "{report:?}");
1329        assert_eq!(report.unprobed[0].error_kind, None, "{report:?}");
1330        assert!(report.unprobed[0].reason.contains("signal 9"), "{report:?}");
1331        assert_eq!(report.unprobed[0].unchecked_helpers, cc_row());
1332    }
1333
1334    /// A driver that runs and exits non-zero for every helper is an unknown family: silent in
1335    /// both lists.
1336    #[cfg(unix)]
1337    #[test]
1338    fn a_driver_that_does_not_understand_the_flag_contributes_nothing() {
1339        let dir = tempfile::tempdir().expect("tempdir");
1340        let driver = write_binary(dir.path(), "cc", b"#!/bin/sh\nexit 1\n");
1341        settle_exec_fixture(&driver);
1342        let dirs = vec![dir.path().to_path_buf()];
1343
1344        let report = unreachable_toolchain_helpers_in(
1345            &policy_allowing(&["cc"]),
1346            ContainmentClass::Sealed,
1347            &dirs,
1348        );
1349
1350        assert_eq!(report, ToolchainHelperReport::default());
1351    }
1352
1353    /// The settle helper gives up while a write descriptor keeps the file busy, and returns once
1354    /// that descriptor is gone.
1355    #[cfg(target_os = "linux")]
1356    #[test]
1357    fn the_exec_settle_retries_only_while_busy() {
1358        let dir = tempfile::tempdir().expect("tempdir");
1359        let path = write_binary(dir.path(), "busy", b"#!/bin/sh\nexit 0\n");
1360
1361        let writer = std::fs::OpenOptions::new()
1362            .append(true)
1363            .open(&path)
1364            .expect("open fixture for append");
1365        let while_busy = std::panic::catch_unwind(|| settle_exec_fixture(&path));
1366        assert!(
1367            while_busy.is_err(),
1368            "a held write handle must exhaust the bound"
1369        );
1370
1371        drop(writer);
1372        settle_exec_fixture(&path);
1373    }
1374
1375    /// The registry is the *whole* contract of which drivers get probed, so its contents are
1376    /// pinned here rather than left implicit — a future slice adding `clang` should have to
1377    /// update this list deliberately.
1378    #[test]
1379    fn the_known_driver_registry_is_the_documented_four() {
1380        let drivers: Vec<&str> = KNOWN_TOOLCHAIN_DRIVERS
1381            .iter()
1382            .map(|(driver, _)| *driver)
1383            .collect();
1384        assert_eq!(drivers, vec!["cc", "gcc", "g++", "c++"]);
1385        for (driver, helpers) in KNOWN_TOOLCHAIN_DRIVERS {
1386            assert!(
1387                helpers.contains(&"as") && helpers.contains(&"ld"),
1388                "{driver} must be probed for the assembler and linker it execs"
1389            );
1390        }
1391    }
1392}