use std::path::{Path, PathBuf};
use crate::host_facts::{HostFactsCache, HostFactsUse};
#[cfg(target_os = "linux")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct InterpreterKind<'a> {
pub(crate) name: &'a str,
pub(crate) marker_symbol: &'a str,
pub(crate) args: &'a [&'a str],
}
#[cfg(target_os = "linux")]
pub(crate) const INTERPRETER_KINDS: &[InterpreterKind<'static>] = &[
InterpreterKind {
name: "CPython",
marker_symbol: "Py_BytesMain",
args: &[
"-I",
"-S",
"-c",
"import sys, site; sys.stdout.write('\\0'.join(sys.path + site.getsitepackages()))",
],
},
InterpreterKind {
name: "Perl",
marker_symbol: "perl_parse",
args: &["-f", "-e", "print join qq{\\0}, @INC"],
},
];
#[cfg(target_os = "linux")]
pub(crate) const QUERY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct DerivedInterpreterRuntime {
pub binary: PathBuf,
pub dirs: Vec<PathBuf>,
pub libraries: Vec<PathBuf>,
}
impl DerivedInterpreterRuntime {
pub(crate) fn entries(&self) -> impl Iterator<Item = (&Path, bool)> {
self.dirs.iter().map(|path| {
(
path.as_path(),
std::fs::metadata(path).is_ok_and(|m| m.is_dir()),
)
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct InterpreterRuntimes {
pub runtimes: Vec<DerivedInterpreterRuntime>,
pub host_facts: Vec<HostFactsUse>,
}
pub fn derive_interpreter_runtimes(
shell_allow: &[String],
staged: &[murmur_artifact::StagedRuntimeGrant],
capsule_writable: &[&Path],
cache: &HostFactsCache,
) -> InterpreterRuntimes {
#[cfg(target_os = "linux")]
{
let path_dirs: Vec<PathBuf> = std::env::var_os("PATH")
.map(|path| std::env::split_paths(&path).collect())
.unwrap_or_default();
derive_in(
shell_allow,
staged,
capsule_writable,
&path_dirs,
INTERPRETER_KINDS,
QUERY_TIMEOUT,
cache,
)
}
#[cfg(not(target_os = "linux"))]
{
let _ = (shell_allow, staged, capsule_writable, cache);
InterpreterRuntimes::default()
}
}
pub fn capsule_writable_roots(placement: &crate::runtime_dir::RuntimeDirPlacement) -> [&Path; 2] {
[&placement.workdir, &placement.records_group]
}
#[cfg(target_os = "linux")]
pub(crate) fn derive_in(
shell_allow: &[String],
staged: &[murmur_artifact::StagedRuntimeGrant],
capsule_writable: &[&Path],
path_dirs: &[PathBuf],
kinds: &[InterpreterKind<'_>],
timeout: std::time::Duration,
cache: &HostFactsCache,
) -> InterpreterRuntimes {
use crate::sandbox::resolve_exec_allowlist_in;
let roots: Vec<PathBuf> = capsule_writable
.iter()
.map(|root| crate::protected_paths::canonicalize_existing_prefix(root))
.collect();
let staged_names: Vec<String> = staged.iter().map(|grant| grant.binary.clone()).collect();
let staged_binaries = resolve_exec_allowlist_in(&staged_names, path_dirs);
let queried: Vec<String> = shell_allow
.iter()
.filter(|name| !staged_names.contains(name))
.cloned()
.collect();
let examined: Vec<PathBuf> = resolve_exec_allowlist_in(&queried, path_dirs)
.into_iter()
.filter(|binary| {
!staged_binaries.contains(binary) && !crate::reachability::under_any(binary, &roots)
})
.collect();
let mut derived = InterpreterRuntimes::default();
if examined.is_empty() {
return derived;
}
let shelf = cache.open(&roots);
for binary in examined {
let (runtime, source) =
crate::host_facts::runtime_of(shelf.as_ref(), &binary, kinds, &roots, timeout);
derived.runtimes.extend(runtime);
derived.host_facts.push(HostFactsUse { binary, source });
}
derived
}
#[cfg(target_os = "linux")]
pub(crate) fn report_not_derived(binary: &Path, kind: InterpreterKind<'_>, reason: &str) {
crate::runtime_err!(
"[capsule-runtime] the runtime directories of {} interpreter {} were not derived: {reason}",
kind.name,
binary.display()
);
}
#[cfg(target_os = "linux")]
pub(crate) fn recognise<'a>(
binary: &Path,
kinds: &[InterpreterKind<'a>],
) -> Option<InterpreterKind<'a>> {
let image = crate::sandbox::ElfFile::open_host(binary).ok()?;
let names = crate::sandbox::elf_dynamic_symbol_names(&image)?;
kinds
.iter()
.find(|kind| names.iter().any(|name| name == kind.marker_symbol))
.copied()
}
#[cfg(target_os = "linux")]
pub(crate) fn query(
binary: &Path,
kind: InterpreterKind<'_>,
timeout: std::time::Duration,
) -> Result<Vec<String>, String> {
use std::process::{Command, Stdio};
use std::time::Instant;
let started = Instant::now();
let mut child = Command::new(binary)
.args(kind.args)
.env_clear()
.current_dir("/")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|error| format!("it could not be started: {error}"))?;
let stdout = drain(child.stdout.take());
let stderr = drain(child.stderr.take());
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) if started.elapsed() < timeout => {
std::thread::sleep(std::time::Duration::from_millis(2));
}
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
return Err(format!(
"it did not exit within {} s and was killed",
timeout.as_secs_f64()
));
}
Err(error) => return Err(format!("waiting for it failed: {error}")),
}
};
let remaining = || timeout.saturating_sub(started.elapsed());
if !status.success() {
let detail = stderr
.recv_timeout(remaining())
.ok()
.and_then(|bytes| {
String::from_utf8_lossy(&bytes)
.lines()
.find(|line| !line.trim().is_empty())
.map(|line| format!(": {}", line.trim()))
})
.unwrap_or_default();
return Err(format!("it exited with {status}{detail}"));
}
let printed = stdout
.recv_timeout(remaining())
.map_err(|_| "its output did not close before the deadline".to_string())?;
let printed = String::from_utf8(printed)
.map_err(|_| "it printed output that is not UTF-8".to_string())?;
Ok(printed
.split('\0')
.filter(|entry| !entry.is_empty())
.map(str::to_string)
.collect())
}
#[cfg(target_os = "linux")]
fn drain<R: std::io::Read + Send + 'static>(pipe: Option<R>) -> std::sync::mpsc::Receiver<Vec<u8>> {
let (sender, receiver) = std::sync::mpsc::channel();
if let Some(mut pipe) = pipe {
std::thread::spawn(move || {
let mut bytes = Vec::new();
let _ = pipe.read_to_end(&mut bytes);
let _ = sender.send(bytes);
});
}
receiver
}
#[cfg(target_os = "linux")]
pub(crate) fn keep_entries(binary: &Path, reported: &[String], roots: &[PathBuf]) -> Vec<PathBuf> {
let own_dir = binary.parent().unwrap_or_else(|| Path::new("/"));
let mut kept: Vec<PathBuf> = Vec::new();
for entry in reported {
let path = Path::new(entry);
if !path.is_absolute() {
continue;
}
let Ok(canonical) = std::fs::canonicalize(path) else {
continue;
};
let Ok(metadata) = std::fs::metadata(&canonical) else {
continue;
};
if !(metadata.is_dir() || metadata.is_file()) {
continue;
}
if own_dir.starts_with(&canonical) || crate::reachability::under_any(&canonical, roots) {
continue;
}
if !kept.contains(&canonical) {
kept.push(canonical);
}
}
kept
}
#[cfg(target_os = "linux")]
pub(crate) trait Facts {
type Changed;
fn shared_objects_in(&mut self, dir: &Path) -> Result<Vec<PathBuf>, Self::Changed>;
fn dependencies(
&mut self,
path: &Path,
) -> Result<Option<crate::sandbox::ElfDependencies>, Self::Changed>;
}
#[cfg(target_os = "linux")]
pub(crate) fn runtime_from_report<F: Facts>(
binary: &Path,
kind: InterpreterKind<'_>,
reported: &[String],
roots: &[PathBuf],
facts: &mut F,
) -> Result<Option<DerivedInterpreterRuntime>, F::Changed> {
let dirs = keep_entries(binary, reported, roots);
if dirs.is_empty() {
report_not_derived(binary, kind, "it reported no existing directory to grant");
return Ok(None);
}
let libraries = extension_module_closure(binary, &dirs, roots, facts)?;
Ok(Some(DerivedInterpreterRuntime {
binary: binary.to_path_buf(),
dirs,
libraries,
}))
}
#[cfg(target_os = "linux")]
fn extension_module_closure<F: Facts>(
binary: &Path,
dirs: &[PathBuf],
roots: &[PathBuf],
facts: &mut F,
) -> Result<Vec<PathBuf>, F::Changed> {
let mut seeds = Vec::new();
for dir in dirs {
seeds.extend(facts.shared_objects_in(dir)?);
}
if seeds.is_empty() {
return Ok(Vec::new());
}
let search_dirs: Vec<PathBuf> = crate::sandbox::DEFAULT_LIBRARY_SEARCH_DIRS
.iter()
.map(PathBuf::from)
.collect();
let own_closure = crate::sandbox::resolve_landlock_grants(&[binary.to_path_buf()]);
let closure = crate::sandbox::walk_dependency_closure(&seeds, &search_dirs, roots, |path| {
facts.dependencies(path)
})?;
Ok(closure
.into_iter()
.filter(|path| !dirs.iter().any(|dir| path.starts_with(dir)))
.filter(|path| !own_closure.contains(path))
.collect())
}
#[cfg(all(test, target_os = "linux"))]
#[allow(clippy::print_stdout)]
pub(crate) mod tests {
use super::*;
use crate::host_facts::{HostFactsReason, HostFactsSource};
use std::time::Duration;
pub(crate) fn dash_kind<'a>(args: &'a [&'a str]) -> InterpreterKind<'a> {
InterpreterKind {
name: "test",
marker_symbol: "execve",
args,
}
}
pub(crate) fn dash_copy(parent: &Path, name: &str) -> Option<PathBuf> {
let dash = std::fs::canonicalize("/bin/dash")
.or_else(|_| std::fs::canonicalize("/usr/bin/dash"))
.ok();
let Some(dash) = dash else {
println!("[SKIP] no dash on this host to stand in for an interpreter");
return None;
};
let bin = parent.join("bin");
std::fs::create_dir_all(&bin).unwrap();
let copy = bin.join(name);
std::fs::copy(&dash, ©).unwrap();
Some(copy)
}
pub(crate) fn canonical_tempdir() -> (tempfile::TempDir, PathBuf) {
let temp = tempfile::tempdir().unwrap();
let root = std::fs::canonicalize(temp.path()).unwrap();
(temp, root)
}
pub(crate) fn thread_rchar() -> u64 {
let io = std::fs::read_to_string("/proc/thread-self/io")
.expect("/proc/thread-self/io is readable");
io.lines()
.find_map(|line| line.strip_prefix("rchar: "))
.and_then(|value| value.trim().parse().ok())
.expect("/proc/thread-self/io has an rchar line")
}
pub(crate) fn pad_with_zeros(path: &Path, bytes: usize) {
use std::io::Write;
let mut file = std::fs::OpenOptions::new().append(true).open(path).unwrap();
let chunk = vec![0u8; 1 << 20];
for _ in 0..bytes / chunk.len() {
file.write_all(&chunk).unwrap();
}
file.write_all(&chunk[..bytes % chunk.len()]).unwrap();
}
pub(crate) fn cache_in(root: &Path) -> HostFactsCache {
HostFactsCache::at(root.join("host_facts"))
}
#[test]
fn cpython_is_recognised_by_py_bytes_main() {
let Some(python) = crate::sandbox::find_on_path("python3") else {
println!("[SKIP] no python3 on PATH");
return;
};
let binary = std::fs::canonicalize(python).unwrap();
assert_eq!(
recognise(&binary, INTERPRETER_KINDS).unwrap().name,
"CPython"
);
}
#[test]
fn perl_is_recognised_by_perl_parse() {
let Some(perl) = crate::sandbox::find_on_path("perl") else {
println!("[SKIP] no perl on PATH");
return;
};
let binary = std::fs::canonicalize(perl).unwrap();
assert_eq!(recognise(&binary, INTERPRETER_KINDS).unwrap().name, "Perl");
}
#[test]
fn a_plain_elf_and_a_script_are_not_interpreters() {
let test_binary = std::env::current_exe().unwrap();
assert!(
crate::sandbox::elf_dynamic_symbol_names(
&crate::sandbox::ElfFile::open_host(&test_binary).unwrap()
)
.is_some(),
"the test binary is a dynamic ELF with a .dynsym to read"
);
assert_eq!(recognise(&test_binary, INTERPRETER_KINDS), None);
assert_eq!(recognise(Path::new("/bin/true"), INTERPRETER_KINDS), None);
let (_temp, root) = canonical_tempdir();
let script = root.join("script");
std::fs::write(
&script,
b"#!/usr/bin/python3\nimport sys\n# Py_BytesMain perl_parse\n",
)
.unwrap();
assert_eq!(recognise(&script, INTERPRETER_KINDS), None);
assert_eq!(recognise(&root.join("missing"), INTERPRETER_KINDS), None);
}
#[test]
fn reported_entries_keep_only_existing_absolute_paths_outside_the_prefix() {
let (_temp, root) = canonical_tempdir();
let prefix = root.join("prefix");
let stdlib = prefix.join("lib/python");
std::fs::create_dir_all(prefix.join("bin")).unwrap();
std::fs::create_dir_all(&stdlib).unwrap();
let zip = prefix.join("lib/python312.zip");
std::fs::write(&zip, b"PK").unwrap();
let excluded = root.join("work");
std::fs::create_dir_all(excluded.join("lib")).unwrap();
let binary = prefix.join("bin/python3");
let string = |path: &Path| path.to_string_lossy().into_owned();
let reported = vec![
"relative/lib".to_string(),
String::new(),
string(&root.join("missing")),
"/".to_string(),
string(&root),
string(&prefix),
string(&prefix.join("bin")),
string(&stdlib),
format!("{}/../python", stdlib.display()),
string(&zip),
string(&excluded.join("lib")),
];
assert_eq!(
keep_entries(&binary, &reported, &[excluded]),
vec![stdlib, zip.clone()]
);
let derived = DerivedInterpreterRuntime {
binary,
dirs: vec![zip.clone()],
libraries: Vec::new(),
};
assert_eq!(
derived.entries().collect::<Vec<_>>(),
vec![(zip.as_path(), false)],
"a zip archive is kept as a non-listable file"
);
}
#[test]
fn a_binary_under_an_excluded_root_is_never_run() {
let (_temp, root) = canonical_tempdir();
let excluded = root.join("work");
let Some(fake) = dash_copy(&excluded, "fakeinterp") else {
return;
};
let marker = root.join("ran");
let script = format!("touch {}", marker.display());
let args = ["-c", script.as_str()];
let kinds = [dash_kind(&args)];
let path_dirs = vec![fake.parent().unwrap().to_path_buf()];
let allow = vec!["fakeinterp".to_string()];
let derived = derive_in(
&allow,
&[],
&[excluded.as_path()],
&path_dirs,
&kinds,
QUERY_TIMEOUT,
&cache_in(&root),
);
assert_eq!(derived, InterpreterRuntimes::default());
assert!(!marker.exists(), "the excluded binary was executed");
derive_in(
&allow,
&[],
&[],
&path_dirs,
&kinds,
QUERY_TIMEOUT,
&cache_in(&root),
);
assert!(
marker.exists(),
"the stand-in did not run when nothing excluded it"
);
}
#[test]
fn a_staged_runtime_binary_is_not_queried() {
let (_temp, root) = canonical_tempdir();
let Some(fake) = dash_copy(&root.join("staged"), "stagedinterp") else {
return;
};
let marker = root.join("ran");
let script = format!("touch {}", marker.display());
let args = ["-c", script.as_str()];
let kinds = [dash_kind(&args)];
let path_dirs = vec![fake.parent().unwrap().to_path_buf()];
let staged = vec![murmur_artifact::StagedRuntimeGrant {
binary: "stagedinterp".to_string(),
source_path: root.join("staged").to_string_lossy().into_owned(),
pin: "test".to_string(),
}];
let derived = derive_in(
&["stagedinterp".to_string()],
&staged,
&[],
&path_dirs,
&kinds,
QUERY_TIMEOUT,
&cache_in(&root),
);
assert_eq!(derived, InterpreterRuntimes::default());
assert!(!marker.exists(), "the staged binary was queried");
}
#[test]
fn a_query_that_times_out_fails_or_prints_non_utf8_derives_nothing() {
let (_temp, root) = canonical_tempdir();
let Some(fake) = dash_copy(&root, "fakeinterp") else {
return;
};
let path_dirs = vec![fake.parent().unwrap().to_path_buf()];
let allow = vec!["fakeinterp".to_string()];
let cache = cache_in(&root);
let lib = root.join("lib");
std::fs::create_dir_all(&lib).unwrap();
let good = format!("printf '%s' {}", lib.display());
let cases: [(&str, &str, Duration); 4] = [
("answers", good.as_str(), QUERY_TIMEOUT),
("times out", "sleep 10", Duration::from_millis(300)),
("exits non-zero", "printf /; exit 3", QUERY_TIMEOUT),
("prints non-UTF-8", "printf '/\\377\\376'", QUERY_TIMEOUT),
];
for (case, script, timeout) in cases {
let args = ["-c", script];
let kinds = [dash_kind(&args)];
let started = std::time::Instant::now();
let derived = derive_in(&allow, &[], &[], &path_dirs, &kinds, timeout, &cache);
if case == "answers" {
assert_eq!(derived.runtimes.len(), 1, "{case}");
assert_eq!(derived.runtimes[0].dirs, vec![lib.clone()], "{case}");
} else {
assert!(derived.runtimes.is_empty(), "{case}: {derived:?}");
let again = derive_in(&allow, &[], &[], &path_dirs, &kinds, timeout, &cache);
assert!(again.runtimes.is_empty(), "{case}: {again:?}");
assert!(
!matches!(again.host_facts[0].source, HostFactsSource::Kept),
"{case}: {again:?}"
);
}
assert!(
started.elapsed() < Duration::from_secs(5),
"{case} took {:?}",
started.elapsed()
);
}
}
#[test]
fn derived_grants_read_and_list_directories_and_never_execute() {
let (_temp, root) = canonical_tempdir();
let stdlib = root.join("lib/python");
std::fs::create_dir_all(&stdlib).unwrap();
let zip = root.join("lib/python312.zip");
std::fs::write(&zip, b"PK").unwrap();
let library = PathBuf::from("/opt/test/lib/libsqlite3.so.0");
let derived = vec![DerivedInterpreterRuntime {
binary: PathBuf::from("/opt/test/bin/python3"),
dirs: vec![stdlib.clone(), zip.clone()],
libraries: vec![library.clone()],
}];
let grant = |path: &Path, list_dir| crate::sandbox::LandlockGrant {
path: path.to_path_buf(),
list_dir,
executable: false,
pinned: None,
};
assert_eq!(
crate::sandbox::resolve_derived_interpreter_grants(&derived),
vec![
grant(&stdlib, true),
grant(&zip, false),
grant(&library, false)
]
);
let policy = crate::types::CapabilityPolicy {
shell_derived_interpreter_runtime: derived,
..Default::default()
};
let binds = crate::sandbox::resolve_sealed_bind_dirs(&[], &policy);
assert!(binds.contains(&stdlib), "{binds:?}");
assert!(binds.contains(&PathBuf::from("/opt/test/lib")), "{binds:?}");
}
#[test]
fn this_hosts_interpreters_derive_their_runtimes() {
let allow = vec!["python3".to_string(), "perl".to_string()];
let (_temp, root) = canonical_tempdir();
let cache = cache_in(&root);
let path_dirs: Vec<PathBuf> = std::env::var_os("PATH")
.map(|path| std::env::split_paths(&path).collect())
.unwrap_or_default();
let derive = |cache: &HostFactsCache| {
let started = std::time::Instant::now();
let derived = derive_in(
&allow,
&[],
&[],
&path_dirs,
INTERPRETER_KINDS,
QUERY_TIMEOUT,
cache,
);
(derived, started.elapsed())
};
let (cold, cold_elapsed) = derive(&cache);
let (kept, kept_elapsed) = derive(&cache);
std::fs::write(root.join("not-a-cache"), b"").unwrap();
let (uncached, _) = derive(&HostFactsCache::at(root.join("not-a-cache")));
let mut found = 0;
for name in &allow {
let Some(path) = crate::sandbox::find_on_path(name) else {
println!("[SKIP] no {name} on PATH");
continue;
};
found += 1;
let binary = std::fs::canonicalize(path).unwrap();
assert!(
cold.runtimes
.iter()
.any(|runtime| runtime.binary == binary && !runtime.dirs.is_empty()),
"{name} ({}) derived nothing: {cold:?}",
binary.display()
);
let source = |derived: &InterpreterRuntimes| {
derived
.host_facts
.iter()
.find(|used| used.binary == binary)
.map(|used| used.source.clone())
};
assert_eq!(
source(&cold),
Some(HostFactsSource::Derived {
reason: HostFactsReason::Absent,
changed_path: None
}),
"{name}"
);
assert_eq!(source(&kept), Some(HostFactsSource::Kept), "{name}");
assert_eq!(
source(&uncached),
Some(HostFactsSource::Derived {
reason: HostFactsReason::NotKept,
changed_path: None
}),
"{name}"
);
}
assert_eq!(kept.runtimes, cold.runtimes);
assert_eq!(uncached.runtimes, cold.runtimes);
assert_eq!(cold.host_facts.len(), found);
for runtime in &cold.runtimes {
println!("{}", runtime.binary.display());
for dir in &runtime.dirs {
println!(" dir {}", dir.display());
}
for library in &runtime.libraries {
println!(" library {}", library.display());
}
for dir in &runtime.dirs {
assert!(!runtime.binary.parent().unwrap().starts_with(dir));
}
}
println!("derive_in([python3, perl]) took {cold_elapsed:?} cold and {kept_elapsed:?} kept");
}
}