Skip to main content

capsule_runtime/
interpreter_runtime.rs

1//! The runtime directories of an allowlisted interpreter, asked of the interpreter itself.
2//!
3//! A `capabilities.shell.allow` binary's ELF closure (the binary, its `PT_INTERP` and its
4//! `DT_NEEDED` libraries) is enough to start CPython or Perl and not enough to run either: each
5//! reads its standard library out of directories no ELF header names. On `scoped` those
6//! directories have no Landlock rule, so CPython dies in `init_fs_encoding` looking for
7//! `encodings`. Where they are differs by distribution, interpreter version and install method, so
8//! the runtime does not write any of them down. It recognises the interpreter from its own
9//! dynamic symbol table and runs it with a fixed program that prints the interpreter's import
10//! path: at the first staging that meets it, and again whenever [`crate::host_facts`] finds that
11//! a file it was worked out from has changed.
12//!
13//! Each existing directory the interpreter reports becomes a readable, listable, non-executable
14//! Landlock grant (see [`crate::sandbox::resolve_derived_interpreter_grants`]), and on `sealed` an
15//! optional composed-root bind. The ELF shared objects directly inside those directories are C
16//! extension modules; the `DT_NEEDED` closure of each (`libsqlite3.so.0` behind `_sqlite3`) is
17//! granted read-only too, because the interpreter's own closure does not include it.
18//!
19//! An interpreter the table below does not recognise is granted exactly its ELF closure, and
20//! `capabilities.shell.interpreter_runtime` remains the way to name its directories.
21
22use std::path::{Path, PathBuf};
23
24use crate::host_facts::{HostFactsCache, HostFactsUse};
25
26/// One interpreter family the runtime recognises from the binary itself.
27#[cfg(target_os = "linux")]
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub(crate) struct InterpreterKind<'a> {
30    /// Names the family in a diagnostic.
31    pub(crate) name: &'a str,
32    /// A symbol every build of this interpreter lists in its `.dynsym`, defined in a statically
33    /// linked build and undefined in one that imports it from the interpreter's shared library.
34    pub(crate) marker_symbol: &'a str,
35    /// The arguments that make the interpreter print its import path as NUL-separated entries and
36    /// exit, without reading any per-user or per-site customisation.
37    pub(crate) args: &'a [&'a str],
38}
39
40/// Every interpreter family the runtime recognises. To add one, name a symbol its binary always
41/// lists in `.dynsym` and a program that prints its import path NUL-separated while running no
42/// file the host or a user can customise.
43///
44///   - CPython: `-I` ignores every `PYTHON*` variable and the user site directory, and `-S` stops
45///     `site` running at startup, so no `.pth` file executes. `site.getsitepackages()` still
46///     names the distribution's own site directories (Debian's `dist-packages` among them).
47///   - Perl: `-f` skips `sitecustomize.pl`.
48#[cfg(target_os = "linux")]
49pub(crate) const INTERPRETER_KINDS: &[InterpreterKind<'static>] = &[
50    InterpreterKind {
51        name: "CPython",
52        marker_symbol: "Py_BytesMain",
53        args: &[
54            "-I",
55            "-S",
56            "-c",
57            "import sys, site; sys.stdout.write('\\0'.join(sys.path + site.getsitepackages()))",
58        ],
59    },
60    InterpreterKind {
61        name: "Perl",
62        marker_symbol: "perl_parse",
63        args: &["-f", "-e", "print join qq{\\0}, @INC"],
64    },
65];
66
67/// How long one interpreter query may run before it is killed and derives nothing.
68#[cfg(target_os = "linux")]
69pub(crate) const QUERY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
70
71/// What staging derived for one allowlisted interpreter.
72#[derive(Debug, Clone, PartialEq, Eq, Default)]
73pub struct DerivedInterpreterRuntime {
74    /// The interpreter, canonical: the file that was queried and that `shell.allow` resolves to.
75    pub binary: PathBuf,
76    /// Each existing, canonical entry of the interpreter's import path, in the order it reported
77    /// them: directories, and the zip archives CPython can import from.
78    pub dirs: Vec<PathBuf>,
79    /// The `DT_NEEDED` closure of the ELF shared objects directly inside [`Self::dirs`], less every
80    /// file already inside one of those directories or in the interpreter's own ELF closure.
81    pub libraries: Vec<PathBuf>,
82}
83
84impl DerivedInterpreterRuntime {
85    /// Each entry of [`Self::dirs`] with whether it is a directory, which decides whether its
86    /// grant is listable. The one place both the grant and the report ask.
87    pub(crate) fn entries(&self) -> impl Iterator<Item = (&Path, bool)> {
88        self.dirs.iter().map(|path| {
89            (
90                path.as_path(),
91                std::fs::metadata(path).is_ok_and(|m| m.is_dir()),
92            )
93        })
94    }
95}
96
97/// What one staging derived for its allowlisted binaries.
98#[derive(Debug, Clone, PartialEq, Eq, Default)]
99pub struct InterpreterRuntimes {
100    /// One per recognised interpreter whose runtime was derived, ordered by binary path.
101    pub runtimes: Vec<DerivedInterpreterRuntime>,
102    /// One per examined binary: every resolved `shell.allow` binary that no `staged` grant names
103    /// and that lies under no writable root, ordered by binary path.
104    pub host_facts: Vec<HostFactsUse>,
105}
106
107/// The runtime directories of each `shell_allow` binary that is a recognised interpreter.
108///
109/// `capsule_writable` are the trees the capsule's commands can write: a binary under one is never
110/// run, and nothing under one is derived. A binary a `staged` grant names is not queried, because
111/// its runtime is the staged tree.
112///
113/// Each examined binary's facts come from `cache` when its kept entry still matches the host, and
114/// are otherwise worked out, which runs the interpreter, and stored there. Either way the result
115/// is what a fresh derivation gives; [`InterpreterRuntimes::host_facts`] says which happened.
116///
117/// A query that cannot be completed (spawn error, a run past [`QUERY_TIMEOUT`], a non-zero exit,
118/// output that is not UTF-8, or no usable entry) derives nothing for that binary, writes one line
119/// to standard error naming the binary and the reason, and fails nothing. A query that did not
120/// complete is never kept.
121///
122/// Off Linux this returns nothing and runs nothing: there is no Landlock rule to feed.
123pub fn derive_interpreter_runtimes(
124    shell_allow: &[String],
125    staged: &[murmur_artifact::StagedRuntimeGrant],
126    capsule_writable: &[&Path],
127    cache: &HostFactsCache,
128) -> InterpreterRuntimes {
129    #[cfg(target_os = "linux")]
130    {
131        let path_dirs: Vec<PathBuf> = std::env::var_os("PATH")
132            .map(|path| std::env::split_paths(&path).collect())
133            .unwrap_or_default();
134        derive_in(
135            shell_allow,
136            staged,
137            capsule_writable,
138            &path_dirs,
139            INTERPRETER_KINDS,
140            QUERY_TIMEOUT,
141            cache,
142        )
143    }
144    #[cfg(not(target_os = "linux"))]
145    {
146        let _ = (shell_allow, staged, capsule_writable, cache);
147        InterpreterRuntimes::default()
148    }
149}
150
151/// The trees a session placed at `placement` lets its commands write, as
152/// [`derive_interpreter_runtimes`] takes them: the workdir root and the records group, which holds
153/// every session's temporary folder.
154pub fn capsule_writable_roots(placement: &crate::runtime_dir::RuntimeDirPlacement) -> [&Path; 2] {
155    [&placement.workdir, &placement.records_group]
156}
157
158/// [`derive_interpreter_runtimes`] with `PATH`, the kind table and the timeout injected.
159#[cfg(target_os = "linux")]
160pub(crate) fn derive_in(
161    shell_allow: &[String],
162    staged: &[murmur_artifact::StagedRuntimeGrant],
163    capsule_writable: &[&Path],
164    path_dirs: &[PathBuf],
165    kinds: &[InterpreterKind<'_>],
166    timeout: std::time::Duration,
167    cache: &HostFactsCache,
168) -> InterpreterRuntimes {
169    use crate::sandbox::resolve_exec_allowlist_in;
170
171    let roots: Vec<PathBuf> = capsule_writable
172        .iter()
173        .map(|root| crate::protected_paths::canonicalize_existing_prefix(root))
174        .collect();
175    let staged_names: Vec<String> = staged.iter().map(|grant| grant.binary.clone()).collect();
176    let staged_binaries = resolve_exec_allowlist_in(&staged_names, path_dirs);
177    let queried: Vec<String> = shell_allow
178        .iter()
179        .filter(|name| !staged_names.contains(name))
180        .cloned()
181        .collect();
182    let examined: Vec<PathBuf> = resolve_exec_allowlist_in(&queried, path_dirs)
183        .into_iter()
184        .filter(|binary| {
185            !staged_binaries.contains(binary) && !crate::reachability::under_any(binary, &roots)
186        })
187        .collect();
188
189    let mut derived = InterpreterRuntimes::default();
190    if examined.is_empty() {
191        return derived;
192    }
193    let shelf = cache.open(&roots);
194    for binary in examined {
195        let (runtime, source) =
196            crate::host_facts::runtime_of(shelf.as_ref(), &binary, kinds, &roots, timeout);
197        derived.runtimes.extend(runtime);
198        derived.host_facts.push(HostFactsUse { binary, source });
199    }
200    derived
201}
202
203#[cfg(target_os = "linux")]
204pub(crate) fn report_not_derived(binary: &Path, kind: InterpreterKind<'_>, reason: &str) {
205    crate::runtime_err!(
206        "[capsule-runtime] the runtime directories of {} interpreter {} were not derived: {reason}",
207        kind.name,
208        binary.display()
209    );
210}
211
212/// The first kind whose marker symbol the `.dynsym` of the file at `binary` lists. `None` for an
213/// ELF that lists none of them, for anything that is not ELF, a `#!` script included, and for a
214/// file that cannot be opened. Reads the ELF and section headers, `.dynsym` and its string table,
215/// never the whole file.
216#[cfg(target_os = "linux")]
217pub(crate) fn recognise<'a>(
218    binary: &Path,
219    kinds: &[InterpreterKind<'a>],
220) -> Option<InterpreterKind<'a>> {
221    let image = crate::sandbox::ElfFile::open_host(binary).ok()?;
222    let names = crate::sandbox::elf_dynamic_symbol_names(&image)?;
223    kinds
224        .iter()
225        .find(|kind| names.iter().any(|name| name == kind.marker_symbol))
226        .copied()
227}
228
229/// Runs `binary` with `kind`'s arguments, an empty environment, `/dev/null` as standard input and
230/// `/` as working directory, and returns the NUL-separated entries it printed. Killed at
231/// `timeout`. Standard error is read only to explain a failure.
232#[cfg(target_os = "linux")]
233pub(crate) fn query(
234    binary: &Path,
235    kind: InterpreterKind<'_>,
236    timeout: std::time::Duration,
237) -> Result<Vec<String>, String> {
238    use std::process::{Command, Stdio};
239    use std::time::Instant;
240
241    let started = Instant::now();
242    let mut child = Command::new(binary)
243        .args(kind.args)
244        .env_clear()
245        .current_dir("/")
246        .stdin(Stdio::null())
247        .stdout(Stdio::piped())
248        .stderr(Stdio::piped())
249        .spawn()
250        .map_err(|error| format!("it could not be started: {error}"))?;
251
252    let stdout = drain(child.stdout.take());
253    let stderr = drain(child.stderr.take());
254
255    let status = loop {
256        match child.try_wait() {
257            Ok(Some(status)) => break status,
258            Ok(None) if started.elapsed() < timeout => {
259                std::thread::sleep(std::time::Duration::from_millis(2));
260            }
261            Ok(None) => {
262                let _ = child.kill();
263                let _ = child.wait();
264                return Err(format!(
265                    "it did not exit within {} s and was killed",
266                    timeout.as_secs_f64()
267                ));
268            }
269            Err(error) => return Err(format!("waiting for it failed: {error}")),
270        }
271    };
272    let remaining = || timeout.saturating_sub(started.elapsed());
273    if !status.success() {
274        let detail = stderr
275            .recv_timeout(remaining())
276            .ok()
277            .and_then(|bytes| {
278                String::from_utf8_lossy(&bytes)
279                    .lines()
280                    .find(|line| !line.trim().is_empty())
281                    .map(|line| format!(": {}", line.trim()))
282            })
283            .unwrap_or_default();
284        return Err(format!("it exited with {status}{detail}"));
285    }
286    let printed = stdout
287        .recv_timeout(remaining())
288        .map_err(|_| "its output did not close before the deadline".to_string())?;
289    let printed = String::from_utf8(printed)
290        .map_err(|_| "it printed output that is not UTF-8".to_string())?;
291    Ok(printed
292        .split('\0')
293        .filter(|entry| !entry.is_empty())
294        .map(str::to_string)
295        .collect())
296}
297
298/// Reads `pipe` to its end on a thread of its own, so a full pipe cannot stall the child, and
299/// hands the bytes over a channel the caller reads with a deadline, so a descendant still holding
300/// the pipe cannot stall staging.
301#[cfg(target_os = "linux")]
302fn drain<R: std::io::Read + Send + 'static>(pipe: Option<R>) -> std::sync::mpsc::Receiver<Vec<u8>> {
303    let (sender, receiver) = std::sync::mpsc::channel();
304    if let Some(mut pipe) = pipe {
305        std::thread::spawn(move || {
306            let mut bytes = Vec::new();
307            let _ = pipe.read_to_end(&mut bytes);
308            let _ = sender.send(bytes);
309        });
310    }
311    receiver
312}
313
314/// The entries of `reported` that name an existing directory or regular file, canonical, in
315/// order and deduplicated. An entry is dropped when it is relative, does not exist, is `/`, the
316/// interpreter's own directory or an ancestor of it (never an install prefix), or lies under one
317/// of the canonical `roots`.
318#[cfg(target_os = "linux")]
319pub(crate) fn keep_entries(binary: &Path, reported: &[String], roots: &[PathBuf]) -> Vec<PathBuf> {
320    let own_dir = binary.parent().unwrap_or_else(|| Path::new("/"));
321    let mut kept: Vec<PathBuf> = Vec::new();
322    for entry in reported {
323        let path = Path::new(entry);
324        if !path.is_absolute() {
325            continue;
326        }
327        let Ok(canonical) = std::fs::canonicalize(path) else {
328            continue;
329        };
330        let Ok(metadata) = std::fs::metadata(&canonical) else {
331            continue;
332        };
333        if !(metadata.is_dir() || metadata.is_file()) {
334            continue;
335        }
336        if own_dir.starts_with(&canonical) || crate::reachability::under_any(&canonical, roots) {
337            continue;
338        }
339        if !kept.contains(&canonical) {
340            kept.push(canonical);
341        }
342    }
343    kept
344}
345
346/// Where a derivation takes the facts about directories and ELF files it reads: from the host,
347/// or from a kept entry that hands each out only while its recorded stamp still matches.
348#[cfg(target_os = "linux")]
349pub(crate) trait Facts {
350    /// The path a kept entry did not record, or recorded with another stamp.
351    type Changed;
352
353    /// The ELF shared objects directly inside `dir`, which is a canonical kept import-path entry.
354    /// Empty for one that cannot be listed.
355    fn shared_objects_in(&mut self, dir: &Path) -> Result<Vec<PathBuf>, Self::Changed>;
356
357    /// The dependencies of the canonical file at `path`, `None` when it cannot be read or is not
358    /// ELF64.
359    fn dependencies(
360        &mut self,
361        path: &Path,
362    ) -> Result<Option<crate::sandbox::ElfDependencies>, Self::Changed>;
363}
364
365/// The runtime of interpreter `binary` from the entries its completed query `reported`, filtered
366/// against the canonical `roots` and with its extension modules' closure walked through `facts`.
367/// `None`, after one line to standard error, when no entry is left to grant.
368#[cfg(target_os = "linux")]
369pub(crate) fn runtime_from_report<F: Facts>(
370    binary: &Path,
371    kind: InterpreterKind<'_>,
372    reported: &[String],
373    roots: &[PathBuf],
374    facts: &mut F,
375) -> Result<Option<DerivedInterpreterRuntime>, F::Changed> {
376    let dirs = keep_entries(binary, reported, roots);
377    if dirs.is_empty() {
378        report_not_derived(binary, kind, "it reported no existing directory to grant");
379        return Ok(None);
380    }
381    let libraries = extension_module_closure(binary, &dirs, roots, facts)?;
382    Ok(Some(DerivedInterpreterRuntime {
383        binary: binary.to_path_buf(),
384        dirs,
385        libraries,
386    }))
387}
388
389/// The `DT_NEEDED` closure of every ELF shared object directly inside one of the directories in
390/// `dirs` (no recursion), less the files inside `dirs` themselves and those in `binary`'s own ELF
391/// closure, which `shell.allow` already grants. Nothing under `roots` is read or returned.
392#[cfg(target_os = "linux")]
393fn extension_module_closure<F: Facts>(
394    binary: &Path,
395    dirs: &[PathBuf],
396    roots: &[PathBuf],
397    facts: &mut F,
398) -> Result<Vec<PathBuf>, F::Changed> {
399    let mut seeds = Vec::new();
400    for dir in dirs {
401        seeds.extend(facts.shared_objects_in(dir)?);
402    }
403    if seeds.is_empty() {
404        return Ok(Vec::new());
405    }
406    let search_dirs: Vec<PathBuf> = crate::sandbox::DEFAULT_LIBRARY_SEARCH_DIRS
407        .iter()
408        .map(PathBuf::from)
409        .collect();
410    let own_closure = crate::sandbox::resolve_landlock_grants(&[binary.to_path_buf()]);
411    let closure = crate::sandbox::walk_dependency_closure(&seeds, &search_dirs, roots, |path| {
412        facts.dependencies(path)
413    })?;
414    Ok(closure
415        .into_iter()
416        .filter(|path| !dirs.iter().any(|dir| path.starts_with(dir)))
417        .filter(|path| !own_closure.contains(path))
418        .collect())
419}
420
421#[cfg(all(test, target_os = "linux"))]
422#[allow(clippy::print_stdout)]
423pub(crate) mod tests {
424    use super::*;
425    use crate::host_facts::{HostFactsReason, HostFactsSource};
426    use std::time::Duration;
427
428    /// A kind that recognises `dash` (whose `.dynsym` lists `execve`) and runs `args` with it.
429    pub(crate) fn dash_kind<'a>(args: &'a [&'a str]) -> InterpreterKind<'a> {
430        InterpreterKind {
431            name: "test",
432            marker_symbol: "execve",
433            args,
434        }
435    }
436
437    /// A copy of the host's `dash` named `name` in a fresh directory under `parent`, or `None`
438    /// with a skip line when the host has none.
439    pub(crate) fn dash_copy(parent: &Path, name: &str) -> Option<PathBuf> {
440        let dash = std::fs::canonicalize("/bin/dash")
441            .or_else(|_| std::fs::canonicalize("/usr/bin/dash"))
442            .ok();
443        let Some(dash) = dash else {
444            println!("[SKIP] no dash on this host to stand in for an interpreter");
445            return None;
446        };
447        let bin = parent.join("bin");
448        std::fs::create_dir_all(&bin).unwrap();
449        let copy = bin.join(name);
450        std::fs::copy(&dash, &copy).unwrap();
451        Some(copy)
452    }
453
454    pub(crate) fn canonical_tempdir() -> (tempfile::TempDir, PathBuf) {
455        let temp = tempfile::tempdir().unwrap();
456        let root = std::fs::canonicalize(temp.path()).unwrap();
457        (temp, root)
458    }
459
460    /// This thread's `rchar` from `/proc/thread-self/io`: the bytes its `read`-family calls have
461    /// returned, page cache hits included. Panics when the file cannot be read.
462    pub(crate) fn thread_rchar() -> u64 {
463        let io = std::fs::read_to_string("/proc/thread-self/io")
464            .expect("/proc/thread-self/io is readable");
465        io.lines()
466            .find_map(|line| line.strip_prefix("rchar: "))
467            .and_then(|value| value.trim().parse().ok())
468            .expect("/proc/thread-self/io has an rchar line")
469    }
470
471    /// Appends `bytes` zero bytes to the file at `path`.
472    pub(crate) fn pad_with_zeros(path: &Path, bytes: usize) {
473        use std::io::Write;
474        let mut file = std::fs::OpenOptions::new().append(true).open(path).unwrap();
475        let chunk = vec![0u8; 1 << 20];
476        for _ in 0..bytes / chunk.len() {
477            file.write_all(&chunk).unwrap();
478        }
479        file.write_all(&chunk[..bytes % chunk.len()]).unwrap();
480    }
481
482    /// A host facts cache at `<root>/host_facts`, never the developer's own.
483    pub(crate) fn cache_in(root: &Path) -> HostFactsCache {
484        HostFactsCache::at(root.join("host_facts"))
485    }
486
487    #[test]
488    fn cpython_is_recognised_by_py_bytes_main() {
489        let Some(python) = crate::sandbox::find_on_path("python3") else {
490            println!("[SKIP] no python3 on PATH");
491            return;
492        };
493        let binary = std::fs::canonicalize(python).unwrap();
494        assert_eq!(
495            recognise(&binary, INTERPRETER_KINDS).unwrap().name,
496            "CPython"
497        );
498    }
499
500    #[test]
501    fn perl_is_recognised_by_perl_parse() {
502        let Some(perl) = crate::sandbox::find_on_path("perl") else {
503            println!("[SKIP] no perl on PATH");
504            return;
505        };
506        let binary = std::fs::canonicalize(perl).unwrap();
507        assert_eq!(recognise(&binary, INTERPRETER_KINDS).unwrap().name, "Perl");
508    }
509
510    #[test]
511    fn a_plain_elf_and_a_script_are_not_interpreters() {
512        let test_binary = std::env::current_exe().unwrap();
513        assert!(
514            crate::sandbox::elf_dynamic_symbol_names(
515                &crate::sandbox::ElfFile::open_host(&test_binary).unwrap()
516            )
517            .is_some(),
518            "the test binary is a dynamic ELF with a .dynsym to read"
519        );
520        assert_eq!(recognise(&test_binary, INTERPRETER_KINDS), None);
521        assert_eq!(recognise(Path::new("/bin/true"), INTERPRETER_KINDS), None);
522        let (_temp, root) = canonical_tempdir();
523        let script = root.join("script");
524        std::fs::write(
525            &script,
526            b"#!/usr/bin/python3\nimport sys\n# Py_BytesMain perl_parse\n",
527        )
528        .unwrap();
529        assert_eq!(recognise(&script, INTERPRETER_KINDS), None);
530        assert_eq!(recognise(&root.join("missing"), INTERPRETER_KINDS), None);
531    }
532
533    #[test]
534    fn reported_entries_keep_only_existing_absolute_paths_outside_the_prefix() {
535        let (_temp, root) = canonical_tempdir();
536        let prefix = root.join("prefix");
537        let stdlib = prefix.join("lib/python");
538        std::fs::create_dir_all(prefix.join("bin")).unwrap();
539        std::fs::create_dir_all(&stdlib).unwrap();
540        let zip = prefix.join("lib/python312.zip");
541        std::fs::write(&zip, b"PK").unwrap();
542        let excluded = root.join("work");
543        std::fs::create_dir_all(excluded.join("lib")).unwrap();
544        let binary = prefix.join("bin/python3");
545        let string = |path: &Path| path.to_string_lossy().into_owned();
546        let reported = vec![
547            "relative/lib".to_string(),
548            String::new(),
549            string(&root.join("missing")),
550            "/".to_string(),
551            string(&root),
552            string(&prefix),
553            string(&prefix.join("bin")),
554            string(&stdlib),
555            format!("{}/../python", stdlib.display()),
556            string(&zip),
557            string(&excluded.join("lib")),
558        ];
559        assert_eq!(
560            keep_entries(&binary, &reported, &[excluded]),
561            vec![stdlib, zip.clone()]
562        );
563        let derived = DerivedInterpreterRuntime {
564            binary,
565            dirs: vec![zip.clone()],
566            libraries: Vec::new(),
567        };
568        assert_eq!(
569            derived.entries().collect::<Vec<_>>(),
570            vec![(zip.as_path(), false)],
571            "a zip archive is kept as a non-listable file"
572        );
573    }
574
575    #[test]
576    fn a_binary_under_an_excluded_root_is_never_run() {
577        let (_temp, root) = canonical_tempdir();
578        let excluded = root.join("work");
579        let Some(fake) = dash_copy(&excluded, "fakeinterp") else {
580            return;
581        };
582        let marker = root.join("ran");
583        let script = format!("touch {}", marker.display());
584        let args = ["-c", script.as_str()];
585        let kinds = [dash_kind(&args)];
586        let path_dirs = vec![fake.parent().unwrap().to_path_buf()];
587        let allow = vec!["fakeinterp".to_string()];
588
589        let derived = derive_in(
590            &allow,
591            &[],
592            &[excluded.as_path()],
593            &path_dirs,
594            &kinds,
595            QUERY_TIMEOUT,
596            &cache_in(&root),
597        );
598        assert_eq!(derived, InterpreterRuntimes::default());
599        assert!(!marker.exists(), "the excluded binary was executed");
600
601        // The same stand-in outside every excluded root is recognised and run, so the absence
602        // of the marker above is the exclusion and not a stand-in that never runs.
603        derive_in(
604            &allow,
605            &[],
606            &[],
607            &path_dirs,
608            &kinds,
609            QUERY_TIMEOUT,
610            &cache_in(&root),
611        );
612        assert!(
613            marker.exists(),
614            "the stand-in did not run when nothing excluded it"
615        );
616    }
617
618    #[test]
619    fn a_staged_runtime_binary_is_not_queried() {
620        let (_temp, root) = canonical_tempdir();
621        let Some(fake) = dash_copy(&root.join("staged"), "stagedinterp") else {
622            return;
623        };
624        let marker = root.join("ran");
625        let script = format!("touch {}", marker.display());
626        let args = ["-c", script.as_str()];
627        let kinds = [dash_kind(&args)];
628        let path_dirs = vec![fake.parent().unwrap().to_path_buf()];
629        let staged = vec![murmur_artifact::StagedRuntimeGrant {
630            binary: "stagedinterp".to_string(),
631            source_path: root.join("staged").to_string_lossy().into_owned(),
632            pin: "test".to_string(),
633        }];
634        let derived = derive_in(
635            &["stagedinterp".to_string()],
636            &staged,
637            &[],
638            &path_dirs,
639            &kinds,
640            QUERY_TIMEOUT,
641            &cache_in(&root),
642        );
643        assert_eq!(derived, InterpreterRuntimes::default());
644        assert!(!marker.exists(), "the staged binary was queried");
645    }
646
647    #[test]
648    fn a_query_that_times_out_fails_or_prints_non_utf8_derives_nothing() {
649        let (_temp, root) = canonical_tempdir();
650        let Some(fake) = dash_copy(&root, "fakeinterp") else {
651            return;
652        };
653        let path_dirs = vec![fake.parent().unwrap().to_path_buf()];
654        let allow = vec!["fakeinterp".to_string()];
655        let cache = cache_in(&root);
656        // The positive control: the same stand-in printing an existing directory derives it.
657        let lib = root.join("lib");
658        std::fs::create_dir_all(&lib).unwrap();
659        let good = format!("printf '%s' {}", lib.display());
660        let cases: [(&str, &str, Duration); 4] = [
661            ("answers", good.as_str(), QUERY_TIMEOUT),
662            ("times out", "sleep 10", Duration::from_millis(300)),
663            ("exits non-zero", "printf /; exit 3", QUERY_TIMEOUT),
664            ("prints non-UTF-8", "printf '/\\377\\376'", QUERY_TIMEOUT),
665        ];
666        for (case, script, timeout) in cases {
667            let args = ["-c", script];
668            let kinds = [dash_kind(&args)];
669            let started = std::time::Instant::now();
670            let derived = derive_in(&allow, &[], &[], &path_dirs, &kinds, timeout, &cache);
671            if case == "answers" {
672                assert_eq!(derived.runtimes.len(), 1, "{case}");
673                assert_eq!(derived.runtimes[0].dirs, vec![lib.clone()], "{case}");
674            } else {
675                assert!(derived.runtimes.is_empty(), "{case}: {derived:?}");
676                // Never kept: the same query a second time runs again and is not taken from
677                // the cache.
678                let again = derive_in(&allow, &[], &[], &path_dirs, &kinds, timeout, &cache);
679                assert!(again.runtimes.is_empty(), "{case}: {again:?}");
680                assert!(
681                    !matches!(again.host_facts[0].source, HostFactsSource::Kept),
682                    "{case}: {again:?}"
683                );
684            }
685            assert!(
686                started.elapsed() < Duration::from_secs(5),
687                "{case} took {:?}",
688                started.elapsed()
689            );
690        }
691    }
692
693    #[test]
694    fn derived_grants_read_and_list_directories_and_never_execute() {
695        let (_temp, root) = canonical_tempdir();
696        let stdlib = root.join("lib/python");
697        std::fs::create_dir_all(&stdlib).unwrap();
698        let zip = root.join("lib/python312.zip");
699        std::fs::write(&zip, b"PK").unwrap();
700        let library = PathBuf::from("/opt/test/lib/libsqlite3.so.0");
701        let derived = vec![DerivedInterpreterRuntime {
702            binary: PathBuf::from("/opt/test/bin/python3"),
703            dirs: vec![stdlib.clone(), zip.clone()],
704            libraries: vec![library.clone()],
705        }];
706        let grant = |path: &Path, list_dir| crate::sandbox::LandlockGrant {
707            path: path.to_path_buf(),
708            list_dir,
709            executable: false,
710            pinned: None,
711        };
712        assert_eq!(
713            crate::sandbox::resolve_derived_interpreter_grants(&derived),
714            vec![
715                grant(&stdlib, true),
716                grant(&zip, false),
717                grant(&library, false)
718            ]
719        );
720
721        let policy = crate::types::CapabilityPolicy {
722            shell_derived_interpreter_runtime: derived,
723            ..Default::default()
724        };
725        let binds = crate::sandbox::resolve_sealed_bind_dirs(&[], &policy);
726        assert!(binds.contains(&stdlib), "{binds:?}");
727        assert!(binds.contains(&PathBuf::from("/opt/test/lib")), "{binds:?}");
728    }
729
730    /// The derivation for this host's `python3` and `perl`, through a temporary cache: each one
731    /// on `PATH` is derived, none of its entries is its own directory or an ancestor of it, the
732    /// second staging keeps every entry the first stored, and both equal a derivation through a
733    /// cache that cannot be used. The cold and kept times are printed for the staging cost each
734    /// adds.
735    #[test]
736    fn this_hosts_interpreters_derive_their_runtimes() {
737        let allow = vec!["python3".to_string(), "perl".to_string()];
738        let (_temp, root) = canonical_tempdir();
739        let cache = cache_in(&root);
740        let path_dirs: Vec<PathBuf> = std::env::var_os("PATH")
741            .map(|path| std::env::split_paths(&path).collect())
742            .unwrap_or_default();
743        let derive = |cache: &HostFactsCache| {
744            let started = std::time::Instant::now();
745            let derived = derive_in(
746                &allow,
747                &[],
748                &[],
749                &path_dirs,
750                INTERPRETER_KINDS,
751                QUERY_TIMEOUT,
752                cache,
753            );
754            (derived, started.elapsed())
755        };
756        let (cold, cold_elapsed) = derive(&cache);
757        let (kept, kept_elapsed) = derive(&cache);
758        // A cache whose directory is a file is unusable, so this derives with nothing kept.
759        std::fs::write(root.join("not-a-cache"), b"").unwrap();
760        let (uncached, _) = derive(&HostFactsCache::at(root.join("not-a-cache")));
761
762        let mut found = 0;
763        for name in &allow {
764            let Some(path) = crate::sandbox::find_on_path(name) else {
765                println!("[SKIP] no {name} on PATH");
766                continue;
767            };
768            found += 1;
769            let binary = std::fs::canonicalize(path).unwrap();
770            assert!(
771                cold.runtimes
772                    .iter()
773                    .any(|runtime| runtime.binary == binary && !runtime.dirs.is_empty()),
774                "{name} ({}) derived nothing: {cold:?}",
775                binary.display()
776            );
777            let source = |derived: &InterpreterRuntimes| {
778                derived
779                    .host_facts
780                    .iter()
781                    .find(|used| used.binary == binary)
782                    .map(|used| used.source.clone())
783            };
784            assert_eq!(
785                source(&cold),
786                Some(HostFactsSource::Derived {
787                    reason: HostFactsReason::Absent,
788                    changed_path: None
789                }),
790                "{name}"
791            );
792            assert_eq!(source(&kept), Some(HostFactsSource::Kept), "{name}");
793            assert_eq!(
794                source(&uncached),
795                Some(HostFactsSource::Derived {
796                    reason: HostFactsReason::NotKept,
797                    changed_path: None
798                }),
799                "{name}"
800            );
801        }
802        assert_eq!(kept.runtimes, cold.runtimes);
803        assert_eq!(uncached.runtimes, cold.runtimes);
804        assert_eq!(cold.host_facts.len(), found);
805        for runtime in &cold.runtimes {
806            println!("{}", runtime.binary.display());
807            for dir in &runtime.dirs {
808                println!("  dir {}", dir.display());
809            }
810            for library in &runtime.libraries {
811                println!("  library {}", library.display());
812            }
813            for dir in &runtime.dirs {
814                assert!(!runtime.binary.parent().unwrap().starts_with(dir));
815            }
816        }
817        println!("derive_in([python3, perl]) took {cold_elapsed:?} cold and {kept_elapsed:?} kept");
818    }
819}