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
151pub fn capsule_writable_roots(placement: &crate::runtime_dir::RuntimeDirPlacement) -> [&Path; 2] {
155 [&placement.workdir, &placement.records_group]
156}
157
158#[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#[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#[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#[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#[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#[cfg(target_os = "linux")]
349pub(crate) trait Facts {
350 type Changed;
352
353 fn shared_objects_in(&mut self, dir: &Path) -> Result<Vec<PathBuf>, Self::Changed>;
356
357 fn dependencies(
360 &mut self,
361 path: &Path,
362 ) -> Result<Option<crate::sandbox::ElfDependencies>, Self::Changed>;
363}
364
365#[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#[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 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 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, ©).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 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 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 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 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 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 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 #[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 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}