1use std::path::{Path, PathBuf};
23
24use crate::host_facts::{HostFactsCache, HostFactsUse};
25
26#[cfg(target_os = "linux")]
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub(crate) struct InterpreterKind<'a> {
30 pub(crate) name: &'a str,
32 pub(crate) marker_symbol: &'a str,
35 pub(crate) args: &'a [&'a str],
38}
39
40#[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#[cfg(target_os = "linux")]
69pub(crate) const QUERY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
70
71#[derive(Debug, Clone, PartialEq, Eq, Default)]
73pub struct DerivedInterpreterRuntime {
74 pub binary: PathBuf,
76 pub dirs: Vec<PathBuf>,
79 pub libraries: Vec<PathBuf>,
82}
83
84impl DerivedInterpreterRuntime {
85 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#[derive(Debug, Clone, PartialEq, Eq, Default)]
99pub struct InterpreterRuntimes {
100 pub runtimes: Vec<DerivedInterpreterRuntime>,
102 pub host_facts: Vec<HostFactsUse>,
105}
106
107pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
154pub struct HostFactsKept {
155 pub examined: usize,
158 pub kept: usize,
161}
162
163pub 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
202pub fn capsule_writable_roots(placement: &crate::runtime_dir::RuntimeDirPlacement) -> [&Path; 2] {
206 [&placement.workdir, &placement.records_group]
207}
208
209#[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#[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#[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#[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#[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#[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#[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#[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#[cfg(target_os = "linux")]
475pub(crate) trait Facts {
476 type Changed;
478
479 fn shared_objects_in(&mut self, dir: &Path) -> Result<Vec<PathBuf>, Self::Changed>;
482
483 fn dependencies(
486 &mut self,
487 path: &Path,
488 ) -> Result<Option<crate::sandbox::ElfDependencies>, Self::Changed>;
489}
490
491#[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#[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 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 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, ©).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 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 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 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 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 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 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 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 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 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 #[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 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}