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/// How many allowlisted binaries [`keep_host_facts`] examined, and how many of those the cache
152/// holds an entry for afterwards.
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
154pub struct HostFactsKept {
155    /// Every resolved `shell.allow` binary that no `staged` grant names and that lies under no
156    /// writable root, as [`InterpreterRuntimes::host_facts`] counts them.
157    pub examined: usize,
158    /// The examined binaries whose entry `cache` holds when the call returns: one that still
159    /// matched, or one just worked out and stored. A binary whose query failed has none.
160    pub kept: usize,
161}
162
163/// Fills `cache` with the entries [`derive_interpreter_runtimes`] would keep for the same
164/// arguments, ahead of the launch that would otherwise work them out, and counts them.
165///
166/// The derivation is the launch's own, with the same `PATH`, interpreter table and query timeout,
167/// so an entry written here is the one that launch would write and the launch reports it kept. A
168/// staging passing other `capsule_writable` trees still keeps every entry whose recorded paths lie
169/// outside both sets. Nothing is written to standard error: a query that fails stores no entry,
170/// and the launch that works it out again reports it.
171///
172/// Off Linux this keeps nothing and runs nothing.
173pub fn keep_host_facts(
174    shell_allow: &[String],
175    staged: &[murmur_artifact::StagedRuntimeGrant],
176    capsule_writable: &[&Path],
177    cache: &HostFactsCache,
178) -> HostFactsKept {
179    #[cfg(target_os = "linux")]
180    {
181        let path_dirs: Vec<PathBuf> = std::env::var_os("PATH")
182            .map(|path| std::env::split_paths(&path).collect())
183            .unwrap_or_default();
184        fill_in(
185            shell_allow,
186            staged,
187            capsule_writable,
188            &path_dirs,
189            INTERPRETER_KINDS,
190            QUERY_TIMEOUT,
191            cache,
192            &mut |_| {},
193        )
194    }
195    #[cfg(not(target_os = "linux"))]
196    {
197        let _ = (shell_allow, staged, capsule_writable, cache);
198        HostFactsKept::default()
199    }
200}
201
202/// The trees a session placed at `placement` lets its commands write, as
203/// [`derive_interpreter_runtimes`] takes them: the workdir root and the records group, which holds
204/// every session's temporary folder.
205pub fn capsule_writable_roots(placement: &crate::runtime_dir::RuntimeDirPlacement) -> [&Path; 2] {
206    [&placement.workdir, &placement.records_group]
207}
208
209/// [`derive_interpreter_runtimes`] with `PATH`, the kind table and the timeout injected.
210#[cfg(target_os = "linux")]
211pub(crate) fn derive_in(
212    shell_allow: &[String],
213    staged: &[murmur_artifact::StagedRuntimeGrant],
214    capsule_writable: &[&Path],
215    path_dirs: &[PathBuf],
216    kinds: &[InterpreterKind<'_>],
217    timeout: std::time::Duration,
218    cache: &HostFactsCache,
219) -> InterpreterRuntimes {
220    examine(
221        shell_allow,
222        staged,
223        capsule_writable,
224        path_dirs,
225        kinds,
226        timeout,
227        cache,
228        &mut |line| crate::runtime_err!("{line}"),
229    )
230    .0
231}
232
233/// [`keep_host_facts`] with `PATH`, the kind table and the timeout injected, and each line saying
234/// an interpreter's runtime was not derived handed to `report`.
235#[cfg(target_os = "linux")]
236#[allow(clippy::too_many_arguments)]
237pub(crate) fn fill_in(
238    shell_allow: &[String],
239    staged: &[murmur_artifact::StagedRuntimeGrant],
240    capsule_writable: &[&Path],
241    path_dirs: &[PathBuf],
242    kinds: &[InterpreterKind<'_>],
243    timeout: std::time::Duration,
244    cache: &HostFactsCache,
245    report: &mut dyn FnMut(&str),
246) -> HostFactsKept {
247    let (derived, kept) = examine(
248        shell_allow,
249        staged,
250        capsule_writable,
251        path_dirs,
252        kinds,
253        timeout,
254        cache,
255        report,
256    );
257    HostFactsKept {
258        examined: derived.host_facts.len(),
259        kept,
260    }
261}
262
263/// The derivation both [`derive_in`] and [`fill_in`] run, and how many examined binaries the cache
264/// holds an entry for when it returns.
265#[cfg(target_os = "linux")]
266#[allow(clippy::too_many_arguments)]
267fn examine(
268    shell_allow: &[String],
269    staged: &[murmur_artifact::StagedRuntimeGrant],
270    capsule_writable: &[&Path],
271    path_dirs: &[PathBuf],
272    kinds: &[InterpreterKind<'_>],
273    timeout: std::time::Duration,
274    cache: &HostFactsCache,
275    report: &mut dyn FnMut(&str),
276) -> (InterpreterRuntimes, usize) {
277    use crate::sandbox::resolve_exec_allowlist_in;
278
279    let roots: Vec<PathBuf> = capsule_writable
280        .iter()
281        .map(|root| crate::protected_paths::canonicalize_existing_prefix(root))
282        .collect();
283    let staged_names: Vec<String> = staged.iter().map(|grant| grant.binary.clone()).collect();
284    let staged_binaries = resolve_exec_allowlist_in(&staged_names, path_dirs);
285    let queried: Vec<String> = shell_allow
286        .iter()
287        .filter(|name| !staged_names.contains(name))
288        .cloned()
289        .collect();
290    let examined: Vec<PathBuf> = resolve_exec_allowlist_in(&queried, path_dirs)
291        .into_iter()
292        .filter(|binary| {
293            !staged_binaries.contains(binary) && !crate::reachability::under_any(binary, &roots)
294        })
295        .collect();
296
297    let mut derived = InterpreterRuntimes::default();
298    let mut held = 0;
299    if examined.is_empty() {
300        return (derived, held);
301    }
302    let shelf = cache.open(&roots);
303    for binary in examined {
304        let found = crate::host_facts::runtime_of(
305            shelf.as_ref(),
306            &binary,
307            kinds,
308            &roots,
309            timeout,
310            &mut *report,
311        );
312        derived.runtimes.extend(found.runtime);
313        held += usize::from(found.held);
314        derived.host_facts.push(HostFactsUse {
315            binary,
316            source: found.source,
317        });
318    }
319    (derived, held)
320}
321
322/// Hands `report` the line saying `binary`'s runtime was not derived and why. A launch writes it
323/// to standard error; [`keep_host_facts`] drops it.
324#[cfg(target_os = "linux")]
325pub(crate) fn report_not_derived(
326    binary: &Path,
327    kind: InterpreterKind<'_>,
328    reason: &str,
329    report: &mut dyn FnMut(&str),
330) {
331    report(&format!(
332        "[capsule-runtime] the runtime directories of {} interpreter {} were not derived: {reason}",
333        kind.name,
334        binary.display()
335    ));
336}
337
338/// The first kind whose marker symbol the `.dynsym` of the file at `binary` lists. `None` for an
339/// ELF that lists none of them, for anything that is not ELF, a `#!` script included, and for a
340/// file that cannot be opened. Reads the ELF and section headers, `.dynsym` and its string table,
341/// never the whole file.
342#[cfg(target_os = "linux")]
343pub(crate) fn recognise<'a>(
344    binary: &Path,
345    kinds: &[InterpreterKind<'a>],
346) -> Option<InterpreterKind<'a>> {
347    let image = crate::sandbox::ElfFile::open_host(binary).ok()?;
348    let names = crate::sandbox::elf_dynamic_symbol_names(&image)?;
349    kinds
350        .iter()
351        .find(|kind| names.iter().any(|name| name == kind.marker_symbol))
352        .copied()
353}
354
355/// Runs `binary` with `kind`'s arguments, an empty environment, `/dev/null` as standard input and
356/// `/` as working directory, and returns the NUL-separated entries it printed. Killed at
357/// `timeout`. Standard error is read only to explain a failure.
358#[cfg(target_os = "linux")]
359pub(crate) fn query(
360    binary: &Path,
361    kind: InterpreterKind<'_>,
362    timeout: std::time::Duration,
363) -> Result<Vec<String>, String> {
364    use std::process::{Command, Stdio};
365    use std::time::Instant;
366
367    let started = Instant::now();
368    let mut child = Command::new(binary)
369        .args(kind.args)
370        .env_clear()
371        .current_dir("/")
372        .stdin(Stdio::null())
373        .stdout(Stdio::piped())
374        .stderr(Stdio::piped())
375        .spawn()
376        .map_err(|error| format!("it could not be started: {error}"))?;
377
378    let stdout = drain(child.stdout.take());
379    let stderr = drain(child.stderr.take());
380
381    let status = loop {
382        match child.try_wait() {
383            Ok(Some(status)) => break status,
384            Ok(None) if started.elapsed() < timeout => {
385                std::thread::sleep(std::time::Duration::from_millis(2));
386            }
387            Ok(None) => {
388                let _ = child.kill();
389                let _ = child.wait();
390                return Err(format!(
391                    "it did not exit within {} s and was killed",
392                    timeout.as_secs_f64()
393                ));
394            }
395            Err(error) => return Err(format!("waiting for it failed: {error}")),
396        }
397    };
398    let remaining = || timeout.saturating_sub(started.elapsed());
399    if !status.success() {
400        let detail = stderr
401            .recv_timeout(remaining())
402            .ok()
403            .and_then(|bytes| {
404                String::from_utf8_lossy(&bytes)
405                    .lines()
406                    .find(|line| !line.trim().is_empty())
407                    .map(|line| format!(": {}", line.trim()))
408            })
409            .unwrap_or_default();
410        return Err(format!("it exited with {status}{detail}"));
411    }
412    let printed = stdout
413        .recv_timeout(remaining())
414        .map_err(|_| "its output did not close before the deadline".to_string())?;
415    let printed = String::from_utf8(printed)
416        .map_err(|_| "it printed output that is not UTF-8".to_string())?;
417    Ok(printed
418        .split('\0')
419        .filter(|entry| !entry.is_empty())
420        .map(str::to_string)
421        .collect())
422}
423
424/// Reads `pipe` to its end on a thread of its own, so a full pipe cannot stall the child, and
425/// hands the bytes over a channel the caller reads with a deadline, so a descendant still holding
426/// the pipe cannot stall staging.
427#[cfg(target_os = "linux")]
428fn drain<R: std::io::Read + Send + 'static>(pipe: Option<R>) -> std::sync::mpsc::Receiver<Vec<u8>> {
429    let (sender, receiver) = std::sync::mpsc::channel();
430    if let Some(mut pipe) = pipe {
431        std::thread::spawn(move || {
432            let mut bytes = Vec::new();
433            let _ = pipe.read_to_end(&mut bytes);
434            let _ = sender.send(bytes);
435        });
436    }
437    receiver
438}
439
440/// The entries of `reported` that name an existing directory or regular file, canonical, in
441/// order and deduplicated. An entry is dropped when it is relative, does not exist, is `/`, the
442/// interpreter's own directory or an ancestor of it (never an install prefix), or lies under one
443/// of the canonical `roots`.
444#[cfg(target_os = "linux")]
445pub(crate) fn keep_entries(binary: &Path, reported: &[String], roots: &[PathBuf]) -> Vec<PathBuf> {
446    let own_dir = binary.parent().unwrap_or_else(|| Path::new("/"));
447    let mut kept: Vec<PathBuf> = Vec::new();
448    for entry in reported {
449        let path = Path::new(entry);
450        if !path.is_absolute() {
451            continue;
452        }
453        let Ok(canonical) = std::fs::canonicalize(path) else {
454            continue;
455        };
456        let Ok(metadata) = std::fs::metadata(&canonical) else {
457            continue;
458        };
459        if !(metadata.is_dir() || metadata.is_file()) {
460            continue;
461        }
462        if own_dir.starts_with(&canonical) || crate::reachability::under_any(&canonical, roots) {
463            continue;
464        }
465        if !kept.contains(&canonical) {
466            kept.push(canonical);
467        }
468    }
469    kept
470}
471
472/// Where a derivation takes the facts about directories and ELF files it reads: from the host,
473/// or from a kept entry that hands each out only while its recorded stamp still matches.
474#[cfg(target_os = "linux")]
475pub(crate) trait Facts {
476    /// The path a kept entry did not record, or recorded with another stamp.
477    type Changed;
478
479    /// The ELF shared objects directly inside `dir`, which is a canonical kept import-path entry.
480    /// Empty for one that cannot be listed.
481    fn shared_objects_in(&mut self, dir: &Path) -> Result<Vec<PathBuf>, Self::Changed>;
482
483    /// The dependencies of the canonical file at `path`, `None` when it cannot be read or is not
484    /// ELF64.
485    fn dependencies(
486        &mut self,
487        path: &Path,
488    ) -> Result<Option<crate::sandbox::ElfDependencies>, Self::Changed>;
489}
490
491/// The runtime of interpreter `binary` from the entries its completed query `reported`, filtered
492/// against the canonical `roots` and with its extension modules' closure walked through `facts`.
493/// `None`, after one line to `report`, when no entry is left to grant.
494#[cfg(target_os = "linux")]
495pub(crate) fn runtime_from_report<F: Facts>(
496    binary: &Path,
497    kind: InterpreterKind<'_>,
498    reported: &[String],
499    roots: &[PathBuf],
500    facts: &mut F,
501    report: &mut dyn FnMut(&str),
502) -> Result<Option<DerivedInterpreterRuntime>, F::Changed> {
503    let dirs = keep_entries(binary, reported, roots);
504    if dirs.is_empty() {
505        report_not_derived(
506            binary,
507            kind,
508            "it reported no existing directory to grant",
509            report,
510        );
511        return Ok(None);
512    }
513    let libraries = extension_module_closure(binary, &dirs, roots, facts)?;
514    Ok(Some(DerivedInterpreterRuntime {
515        binary: binary.to_path_buf(),
516        dirs,
517        libraries,
518    }))
519}
520
521/// The `DT_NEEDED` closure of every ELF shared object directly inside one of the directories in
522/// `dirs` (no recursion), less the files inside `dirs` themselves and those in `binary`'s own ELF
523/// closure, which `shell.allow` already grants. Nothing under `roots` is read or returned.
524#[cfg(target_os = "linux")]
525fn extension_module_closure<F: Facts>(
526    binary: &Path,
527    dirs: &[PathBuf],
528    roots: &[PathBuf],
529    facts: &mut F,
530) -> Result<Vec<PathBuf>, F::Changed> {
531    let mut seeds = Vec::new();
532    for dir in dirs {
533        seeds.extend(facts.shared_objects_in(dir)?);
534    }
535    if seeds.is_empty() {
536        return Ok(Vec::new());
537    }
538    let search_dirs: Vec<PathBuf> = crate::sandbox::DEFAULT_LIBRARY_SEARCH_DIRS
539        .iter()
540        .map(PathBuf::from)
541        .collect();
542    let own_closure = crate::sandbox::resolve_landlock_grants(&[binary.to_path_buf()]);
543    let closure = crate::sandbox::walk_dependency_closure(&seeds, &search_dirs, roots, |path| {
544        facts.dependencies(path)
545    })?;
546    Ok(closure
547        .into_iter()
548        .filter(|path| !dirs.iter().any(|dir| path.starts_with(dir)))
549        .filter(|path| !own_closure.contains(path))
550        .collect())
551}
552
553#[cfg(all(test, target_os = "linux"))]
554#[allow(clippy::print_stdout)]
555pub(crate) mod tests {
556    use super::*;
557    use crate::host_facts::{HostFactsReason, HostFactsSource};
558    use std::time::Duration;
559
560    /// A kind that recognises `dash` (whose `.dynsym` lists `execve`) and runs `args` with it.
561    pub(crate) fn dash_kind<'a>(args: &'a [&'a str]) -> InterpreterKind<'a> {
562        InterpreterKind {
563            name: "test",
564            marker_symbol: "execve",
565            args,
566        }
567    }
568
569    /// A copy of the host's `dash` named `name` in a fresh directory under `parent`, or `None`
570    /// with a skip line when the host has none.
571    pub(crate) fn dash_copy(parent: &Path, name: &str) -> Option<PathBuf> {
572        let dash = std::fs::canonicalize("/bin/dash")
573            .or_else(|_| std::fs::canonicalize("/usr/bin/dash"))
574            .ok();
575        let Some(dash) = dash else {
576            println!("[SKIP] no dash on this host to stand in for an interpreter");
577            return None;
578        };
579        let bin = parent.join("bin");
580        std::fs::create_dir_all(&bin).unwrap();
581        let copy = bin.join(name);
582        std::fs::copy(&dash, &copy).unwrap();
583        Some(copy)
584    }
585
586    pub(crate) fn canonical_tempdir() -> (tempfile::TempDir, PathBuf) {
587        let temp = tempfile::tempdir().unwrap();
588        let root = std::fs::canonicalize(temp.path()).unwrap();
589        (temp, root)
590    }
591
592    /// This thread's `rchar` from `/proc/thread-self/io`: the bytes its `read`-family calls have
593    /// returned, page cache hits included. Panics when the file cannot be read.
594    pub(crate) fn thread_rchar() -> u64 {
595        let io = std::fs::read_to_string("/proc/thread-self/io")
596            .expect("/proc/thread-self/io is readable");
597        io.lines()
598            .find_map(|line| line.strip_prefix("rchar: "))
599            .and_then(|value| value.trim().parse().ok())
600            .expect("/proc/thread-self/io has an rchar line")
601    }
602
603    /// Appends `bytes` zero bytes to the file at `path`.
604    pub(crate) fn pad_with_zeros(path: &Path, bytes: usize) {
605        use std::io::Write;
606        let mut file = std::fs::OpenOptions::new().append(true).open(path).unwrap();
607        let chunk = vec![0u8; 1 << 20];
608        for _ in 0..bytes / chunk.len() {
609            file.write_all(&chunk).unwrap();
610        }
611        file.write_all(&chunk[..bytes % chunk.len()]).unwrap();
612    }
613
614    /// A host facts cache at `<root>/host_facts`, never the developer's own.
615    pub(crate) fn cache_in(root: &Path) -> HostFactsCache {
616        HostFactsCache::at(root.join("host_facts"))
617    }
618
619    #[test]
620    fn cpython_is_recognised_by_py_bytes_main() {
621        let Some(python) = crate::sandbox::find_on_path("python3") else {
622            println!("[SKIP] no python3 on PATH");
623            return;
624        };
625        let binary = std::fs::canonicalize(python).unwrap();
626        assert_eq!(
627            recognise(&binary, INTERPRETER_KINDS).unwrap().name,
628            "CPython"
629        );
630    }
631
632    #[test]
633    fn perl_is_recognised_by_perl_parse() {
634        let Some(perl) = crate::sandbox::find_on_path("perl") else {
635            println!("[SKIP] no perl on PATH");
636            return;
637        };
638        let binary = std::fs::canonicalize(perl).unwrap();
639        assert_eq!(recognise(&binary, INTERPRETER_KINDS).unwrap().name, "Perl");
640    }
641
642    #[test]
643    fn a_plain_elf_and_a_script_are_not_interpreters() {
644        let test_binary = std::env::current_exe().unwrap();
645        assert!(
646            crate::sandbox::elf_dynamic_symbol_names(
647                &crate::sandbox::ElfFile::open_host(&test_binary).unwrap()
648            )
649            .is_some(),
650            "the test binary is a dynamic ELF with a .dynsym to read"
651        );
652        assert_eq!(recognise(&test_binary, INTERPRETER_KINDS), None);
653        assert_eq!(recognise(Path::new("/bin/true"), INTERPRETER_KINDS), None);
654        let (_temp, root) = canonical_tempdir();
655        let script = root.join("script");
656        std::fs::write(
657            &script,
658            b"#!/usr/bin/python3\nimport sys\n# Py_BytesMain perl_parse\n",
659        )
660        .unwrap();
661        assert_eq!(recognise(&script, INTERPRETER_KINDS), None);
662        assert_eq!(recognise(&root.join("missing"), INTERPRETER_KINDS), None);
663    }
664
665    #[test]
666    fn reported_entries_keep_only_existing_absolute_paths_outside_the_prefix() {
667        let (_temp, root) = canonical_tempdir();
668        let prefix = root.join("prefix");
669        let stdlib = prefix.join("lib/python");
670        std::fs::create_dir_all(prefix.join("bin")).unwrap();
671        std::fs::create_dir_all(&stdlib).unwrap();
672        let zip = prefix.join("lib/python312.zip");
673        std::fs::write(&zip, b"PK").unwrap();
674        let excluded = root.join("work");
675        std::fs::create_dir_all(excluded.join("lib")).unwrap();
676        let binary = prefix.join("bin/python3");
677        let string = |path: &Path| path.to_string_lossy().into_owned();
678        let reported = vec![
679            "relative/lib".to_string(),
680            String::new(),
681            string(&root.join("missing")),
682            "/".to_string(),
683            string(&root),
684            string(&prefix),
685            string(&prefix.join("bin")),
686            string(&stdlib),
687            format!("{}/../python", stdlib.display()),
688            string(&zip),
689            string(&excluded.join("lib")),
690        ];
691        assert_eq!(
692            keep_entries(&binary, &reported, &[excluded]),
693            vec![stdlib, zip.clone()]
694        );
695        let derived = DerivedInterpreterRuntime {
696            binary,
697            dirs: vec![zip.clone()],
698            libraries: Vec::new(),
699        };
700        assert_eq!(
701            derived.entries().collect::<Vec<_>>(),
702            vec![(zip.as_path(), false)],
703            "a zip archive is kept as a non-listable file"
704        );
705    }
706
707    #[test]
708    fn a_binary_under_an_excluded_root_is_never_run() {
709        let (_temp, root) = canonical_tempdir();
710        let excluded = root.join("work");
711        let Some(fake) = dash_copy(&excluded, "fakeinterp") else {
712            return;
713        };
714        let marker = root.join("ran");
715        let script = format!("touch {}", marker.display());
716        let args = ["-c", script.as_str()];
717        let kinds = [dash_kind(&args)];
718        let path_dirs = vec![fake.parent().unwrap().to_path_buf()];
719        let allow = vec!["fakeinterp".to_string()];
720
721        let derived = derive_in(
722            &allow,
723            &[],
724            &[excluded.as_path()],
725            &path_dirs,
726            &kinds,
727            QUERY_TIMEOUT,
728            &cache_in(&root),
729        );
730        assert_eq!(derived, InterpreterRuntimes::default());
731        assert!(!marker.exists(), "the excluded binary was executed");
732
733        // The same stand-in outside every excluded root is recognised and run, so the absence
734        // of the marker above is the exclusion and not a stand-in that never runs.
735        derive_in(
736            &allow,
737            &[],
738            &[],
739            &path_dirs,
740            &kinds,
741            QUERY_TIMEOUT,
742            &cache_in(&root),
743        );
744        assert!(
745            marker.exists(),
746            "the stand-in did not run when nothing excluded it"
747        );
748    }
749
750    #[test]
751    fn a_staged_runtime_binary_is_not_queried() {
752        let (_temp, root) = canonical_tempdir();
753        let Some(fake) = dash_copy(&root.join("staged"), "stagedinterp") else {
754            return;
755        };
756        let marker = root.join("ran");
757        let script = format!("touch {}", marker.display());
758        let args = ["-c", script.as_str()];
759        let kinds = [dash_kind(&args)];
760        let path_dirs = vec![fake.parent().unwrap().to_path_buf()];
761        let staged = vec![murmur_artifact::StagedRuntimeGrant {
762            binary: "stagedinterp".to_string(),
763            source_path: root.join("staged").to_string_lossy().into_owned(),
764            pin: "test".to_string(),
765        }];
766        let derived = derive_in(
767            &["stagedinterp".to_string()],
768            &staged,
769            &[],
770            &path_dirs,
771            &kinds,
772            QUERY_TIMEOUT,
773            &cache_in(&root),
774        );
775        assert_eq!(derived, InterpreterRuntimes::default());
776        assert!(!marker.exists(), "the staged binary was queried");
777    }
778
779    #[test]
780    fn a_query_that_times_out_fails_or_prints_non_utf8_derives_nothing() {
781        let (_temp, root) = canonical_tempdir();
782        let Some(fake) = dash_copy(&root, "fakeinterp") else {
783            return;
784        };
785        let path_dirs = vec![fake.parent().unwrap().to_path_buf()];
786        let allow = vec!["fakeinterp".to_string()];
787        let cache = cache_in(&root);
788        // The positive control: the same stand-in printing an existing directory derives it.
789        let lib = root.join("lib");
790        std::fs::create_dir_all(&lib).unwrap();
791        let good = format!("printf '%s' {}", lib.display());
792        let cases: [(&str, &str, Duration); 4] = [
793            ("answers", good.as_str(), QUERY_TIMEOUT),
794            ("times out", "sleep 10", Duration::from_millis(300)),
795            ("exits non-zero", "printf /; exit 3", QUERY_TIMEOUT),
796            ("prints non-UTF-8", "printf '/\\377\\376'", QUERY_TIMEOUT),
797        ];
798        for (case, script, timeout) in cases {
799            let args = ["-c", script];
800            let kinds = [dash_kind(&args)];
801            let started = std::time::Instant::now();
802            let derived = derive_in(&allow, &[], &[], &path_dirs, &kinds, timeout, &cache);
803            if case == "answers" {
804                assert_eq!(derived.runtimes.len(), 1, "{case}");
805                assert_eq!(derived.runtimes[0].dirs, vec![lib.clone()], "{case}");
806            } else {
807                assert!(derived.runtimes.is_empty(), "{case}: {derived:?}");
808                // Never kept: the same query a second time runs again and is not taken from
809                // the cache.
810                let again = derive_in(&allow, &[], &[], &path_dirs, &kinds, timeout, &cache);
811                assert!(again.runtimes.is_empty(), "{case}: {again:?}");
812                assert!(
813                    !matches!(again.host_facts[0].source, HostFactsSource::Kept),
814                    "{case}: {again:?}"
815                );
816            }
817            assert!(
818                started.elapsed() < Duration::from_secs(5),
819                "{case} took {:?}",
820                started.elapsed()
821            );
822        }
823    }
824
825    /// The file names in `cache`'s directory, each with its bytes, sorted.
826    fn entries_of(root: &Path) -> Vec<(std::ffi::OsString, Vec<u8>)> {
827        let mut entries: Vec<_> = std::fs::read_dir(root.join("host_facts"))
828            .unwrap()
829            .map(|entry| {
830                let entry = entry.unwrap();
831                (entry.file_name(), std::fs::read(entry.path()).unwrap())
832            })
833            .collect();
834        entries.sort();
835        entries
836    }
837
838    #[test]
839    fn kept_host_facts_are_the_entries_a_launch_writes() {
840        let (_temp, root) = canonical_tempdir();
841        let Some(fake) = dash_copy(&root.join("prefix"), "fakeinterp") else {
842            return;
843        };
844        let lib = root.join("lib");
845        std::fs::create_dir_all(&lib).unwrap();
846        let script = format!("printf '%s' {}", lib.display());
847        let args = ["-c", script.as_str()];
848        let kinds = [dash_kind(&args)];
849        let path_dirs = vec![fake.parent().unwrap().to_path_buf()];
850        let allow = vec!["fakeinterp".to_string()];
851        let filled = root.join("filled");
852        let launched = root.join("launched");
853
854        let mut lines = Vec::new();
855        let kept = fill_in(
856            &allow,
857            &[],
858            &[],
859            &path_dirs,
860            &kinds,
861            QUERY_TIMEOUT,
862            &cache_in(&filled),
863            &mut |line| lines.push(line.to_string()),
864        );
865        assert_eq!(
866            kept,
867            HostFactsKept {
868                examined: 1,
869                kept: 1
870            }
871        );
872        assert_eq!(lines, Vec::<String>::new());
873        let fresh = derive_in(
874            &allow,
875            &[],
876            &[],
877            &path_dirs,
878            &kinds,
879            QUERY_TIMEOUT,
880            &cache_in(&launched),
881        );
882        assert_eq!(entries_of(&filled).len(), 1);
883        assert_eq!(entries_of(&filled), entries_of(&launched));
884
885        let after_fill = derive_in(
886            &allow,
887            &[],
888            &[],
889            &path_dirs,
890            &kinds,
891            QUERY_TIMEOUT,
892            &cache_in(&filled),
893        );
894        assert_eq!(after_fill.host_facts[0].source, HostFactsSource::Kept);
895        assert_eq!(after_fill.runtimes, fresh.runtimes);
896        assert_eq!(fresh.runtimes[0].dirs, vec![lib]);
897
898        // A second fill keeps the entry it finds.
899        assert_eq!(
900            fill_in(
901                &allow,
902                &[],
903                &[],
904                &path_dirs,
905                &kinds,
906                QUERY_TIMEOUT,
907                &cache_in(&filled),
908                &mut |_| {},
909            ),
910            kept
911        );
912    }
913
914    #[test]
915    fn keeping_host_facts_keeps_nothing_for_a_failing_query_and_reports_it_to_its_sink() {
916        let (_temp, root) = canonical_tempdir();
917        let Some(fake) = dash_copy(&root.join("prefix"), "fakeinterp") else {
918            return;
919        };
920        let args = ["-c", "exit 3"];
921        let kinds = [dash_kind(&args)];
922        let path_dirs = vec![fake.parent().unwrap().to_path_buf()];
923        let allow = vec!["fakeinterp".to_string()];
924        let cache = cache_in(&root);
925        let fill = || {
926            let mut lines = Vec::new();
927            let kept = fill_in(
928                &allow,
929                &[],
930                &[],
931                &path_dirs,
932                &kinds,
933                QUERY_TIMEOUT,
934                &cache,
935                &mut |line| lines.push(line.to_string()),
936            );
937            (kept, lines)
938        };
939
940        let (kept, lines) = fill();
941        assert_eq!(
942            kept,
943            HostFactsKept {
944                examined: 1,
945                kept: 0
946            }
947        );
948        assert_eq!(
949            lines,
950            vec![format!(
951                "[capsule-runtime] the runtime directories of test interpreter {} were not \
952                 derived: it exited with exit status: 3",
953                fake.display()
954            )]
955        );
956        assert_eq!(entries_of(&root), Vec::new());
957
958        // The launch after it finds no entry and works the binary out again, as does a later fill.
959        let launched = derive_in(&allow, &[], &[], &path_dirs, &kinds, QUERY_TIMEOUT, &cache);
960        assert_eq!(
961            launched.host_facts,
962            vec![HostFactsUse {
963                binary: fake.clone(),
964                source: HostFactsSource::Derived {
965                    reason: HostFactsReason::Absent,
966                    changed_path: None
967                }
968            }]
969        );
970        assert!(launched.runtimes.is_empty());
971        assert_eq!(entries_of(&root), Vec::new());
972        assert_eq!(fill(), (kept, lines));
973    }
974
975    #[test]
976    fn keeping_host_facts_with_nothing_to_examine_creates_no_cache() {
977        let (_temp, root) = canonical_tempdir();
978        let kinds = [dash_kind(&["-c", "exit 0"])];
979        let kept = fill_in(
980            &["murmur-test-no-such-binary".to_string()],
981            &[],
982            &[],
983            std::slice::from_ref(&root),
984            &kinds,
985            QUERY_TIMEOUT,
986            &cache_in(&root),
987            &mut |_| {},
988        );
989        assert_eq!(kept, HostFactsKept::default());
990        assert!(!root.join("host_facts").exists());
991    }
992
993    #[test]
994    fn derived_grants_read_and_list_directories_and_never_execute() {
995        let (_temp, root) = canonical_tempdir();
996        let stdlib = root.join("lib/python");
997        std::fs::create_dir_all(&stdlib).unwrap();
998        let zip = root.join("lib/python312.zip");
999        std::fs::write(&zip, b"PK").unwrap();
1000        let library = PathBuf::from("/opt/test/lib/libsqlite3.so.0");
1001        let derived = vec![DerivedInterpreterRuntime {
1002            binary: PathBuf::from("/opt/test/bin/python3"),
1003            dirs: vec![stdlib.clone(), zip.clone()],
1004            libraries: vec![library.clone()],
1005        }];
1006        let grant = |path: &Path, list_dir| crate::sandbox::LandlockGrant {
1007            path: path.to_path_buf(),
1008            list_dir,
1009            executable: false,
1010        };
1011        assert_eq!(
1012            crate::sandbox::resolve_derived_interpreter_grants(&derived),
1013            vec![
1014                grant(&stdlib, true),
1015                grant(&zip, false),
1016                grant(&library, false)
1017            ]
1018        );
1019
1020        let policy = crate::types::CapabilityPolicy {
1021            shell_derived_interpreter_runtime: derived,
1022            ..Default::default()
1023        };
1024        let binds = crate::sandbox::resolve_sealed_bind_dirs(&[], &policy);
1025        assert!(binds.contains(&stdlib), "{binds:?}");
1026        assert!(binds.contains(&PathBuf::from("/opt/test/lib")), "{binds:?}");
1027    }
1028
1029    /// The derivation for this host's `python3` and `perl`, through a temporary cache: each one
1030    /// on `PATH` is derived, none of its entries is its own directory or an ancestor of it, the
1031    /// second staging keeps every entry the first stored, and both equal a derivation through a
1032    /// cache that cannot be used. The cold and kept times are printed for the staging cost each
1033    /// adds.
1034    #[test]
1035    fn this_hosts_interpreters_derive_their_runtimes() {
1036        let allow = vec!["python3".to_string(), "perl".to_string()];
1037        let (_temp, root) = canonical_tempdir();
1038        let cache = cache_in(&root);
1039        let path_dirs: Vec<PathBuf> = std::env::var_os("PATH")
1040            .map(|path| std::env::split_paths(&path).collect())
1041            .unwrap_or_default();
1042        let derive = |cache: &HostFactsCache| {
1043            let started = std::time::Instant::now();
1044            let derived = derive_in(
1045                &allow,
1046                &[],
1047                &[],
1048                &path_dirs,
1049                INTERPRETER_KINDS,
1050                QUERY_TIMEOUT,
1051                cache,
1052            );
1053            (derived, started.elapsed())
1054        };
1055        let (cold, cold_elapsed) = derive(&cache);
1056        let (kept, kept_elapsed) = derive(&cache);
1057        // A cache whose directory is a file is unusable, so this derives with nothing kept.
1058        std::fs::write(root.join("not-a-cache"), b"").unwrap();
1059        let (uncached, _) = derive(&HostFactsCache::at(root.join("not-a-cache")));
1060
1061        let mut found = 0;
1062        for name in &allow {
1063            let Some(path) = crate::sandbox::find_on_path(name) else {
1064                println!("[SKIP] no {name} on PATH");
1065                continue;
1066            };
1067            found += 1;
1068            let binary = std::fs::canonicalize(path).unwrap();
1069            assert!(
1070                cold.runtimes
1071                    .iter()
1072                    .any(|runtime| runtime.binary == binary && !runtime.dirs.is_empty()),
1073                "{name} ({}) derived nothing: {cold:?}",
1074                binary.display()
1075            );
1076            let source = |derived: &InterpreterRuntimes| {
1077                derived
1078                    .host_facts
1079                    .iter()
1080                    .find(|used| used.binary == binary)
1081                    .map(|used| used.source.clone())
1082            };
1083            assert_eq!(
1084                source(&cold),
1085                Some(HostFactsSource::Derived {
1086                    reason: HostFactsReason::Absent,
1087                    changed_path: None
1088                }),
1089                "{name}"
1090            );
1091            assert_eq!(source(&kept), Some(HostFactsSource::Kept), "{name}");
1092            assert_eq!(
1093                source(&uncached),
1094                Some(HostFactsSource::Derived {
1095                    reason: HostFactsReason::NotKept,
1096                    changed_path: None
1097                }),
1098                "{name}"
1099            );
1100        }
1101        assert_eq!(kept.runtimes, cold.runtimes);
1102        assert_eq!(uncached.runtimes, cold.runtimes);
1103        assert_eq!(cold.host_facts.len(), found);
1104        for runtime in &cold.runtimes {
1105            println!("{}", runtime.binary.display());
1106            for dir in &runtime.dirs {
1107                println!("  dir {}", dir.display());
1108            }
1109            for library in &runtime.libraries {
1110                println!("  library {}", library.display());
1111            }
1112            for dir in &runtime.dirs {
1113                assert!(!runtime.binary.parent().unwrap().starts_with(dir));
1114            }
1115        }
1116        println!("derive_in([python3, perl]) took {cold_elapsed:?} cold and {kept_elapsed:?} kept");
1117    }
1118}