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