use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::{Caveats, NormalizationPolicy, RootfsPolicy, Scope};
fn search_dirs(fallback: &[String]) -> Vec<PathBuf> {
if let Ok(path) = std::env::var("PATH") {
let dirs: Vec<PathBuf> = path
.split(':')
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.collect();
if !dirs.is_empty() {
return dirs;
}
}
fallback.iter().map(PathBuf::from).collect()
}
fn resolve_program(entry: &str, search_fallback: &[String]) -> Option<PathBuf> {
let candidate = if entry.contains('/') {
let p = PathBuf::from(entry);
p.is_file().then_some(p)
} else {
search_dirs(search_fallback)
.into_iter()
.map(|d| d.join(entry))
.find(|c| c.is_file())
}?;
candidate.canonicalize().ok()
}
fn ldd_closure(bin: &Path) -> Vec<PathBuf> {
let out = match Command::new("ldd").arg(bin).output() {
Ok(o) if o.status.success() => o.stdout,
_ => return Vec::new(),
};
let text = String::from_utf8_lossy(&out);
let mut libs = BTreeSet::new();
for line in text.lines() {
let line = line.trim();
if let Some(rhs) = line.split(" => ").nth(1) {
let path = rhs.split(" (").next().unwrap_or("").trim();
if path.starts_with('/') {
let pb = PathBuf::from(path);
if pb.exists() {
libs.insert(pb);
}
}
} else if line.starts_with('/') {
let path = line.split(" (").next().unwrap_or("").trim();
if let Ok(c) = PathBuf::from(path).canonicalize() {
libs.insert(c);
}
}
}
libs.into_iter().collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RootfsEntry {
pub src: PathBuf,
pub writable: bool,
pub is_dir: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RootfsPlan {
pub entries: Vec<RootfsEntry>,
}
fn add_runtime_closure_fallback(
programs: &BTreeSet<String>,
files: &mut BTreeSet<PathBuf>,
ro_dirs: &mut BTreeSet<PathBuf>,
nss_fallback: bool,
python_fallback: bool,
) {
if nss_fallback {
let libc_dirs: BTreeSet<PathBuf> = files
.iter()
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("libc.so"))
})
.filter_map(|p| p.parent().map(Path::to_path_buf))
.collect();
for dir in &libc_dirs {
if let Ok(rd) = std::fs::read_dir(dir) {
for entry in rd.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with("libnss_") && name.contains(".so") {
if let Ok(c) = entry.path().canonicalize() {
files.insert(c);
}
}
}
}
}
}
let wants_python = python_fallback
&& programs.iter().any(|p| {
Path::new(p)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(p)
.starts_with("python")
});
if wants_python {
for base in ["/usr/lib", "/usr/local/lib", "/usr/lib64"] {
if let Ok(rd) = std::fs::read_dir(base) {
for entry in rd.flatten() {
if entry.file_name().to_string_lossy().starts_with("python3")
&& entry.path().is_dir()
{
ro_dirs.insert(entry.path());
}
}
}
}
}
}
pub fn build_rootfs_plan(
effective: &Caveats,
rootfs: &RootfsPolicy,
norm: &NormalizationPolicy,
) -> Result<RootfsPlan, String> {
let programs = match &effective.exec {
Scope::All => {
return Err("minimal rootfs requires a confined exec scope (exec: Only)".to_string())
}
Scope::Only(set) => set,
};
let search_fallback = &rootfs.search_dirs;
let mut files: BTreeSet<PathBuf> = BTreeSet::new(); let mut ro_dirs: BTreeSet<PathBuf> = BTreeSet::new();
let mut rw_dirs: BTreeSet<PathBuf> = BTreeSet::new();
for prog in programs {
let bin = resolve_program(prog, search_fallback)
.ok_or_else(|| format!("granted program not found: {prog}"))?;
if norm.ldd_closure {
for so in ldd_closure(&bin) {
files.insert(so);
}
}
files.insert(bin);
}
add_runtime_closure_fallback(
programs,
&mut files,
&mut ro_dirs,
norm.nss_closure_fallback,
norm.python_closure_fallback,
);
for d in rootfs.data_paths.resolve() {
let p = PathBuf::from(&d);
match p.metadata() {
Ok(m) if m.is_dir() => {
ro_dirs.insert(p);
}
Ok(_) => {
files.insert(p);
}
Err(_) => {} }
}
if let Scope::Only(rd) = &effective.fs_read {
for p in rd {
let pb = PathBuf::from(p);
if pb.is_dir() {
ro_dirs.insert(pb);
} else if pb.exists() {
files.insert(pb);
}
}
}
if let Scope::Only(wr) = &effective.fs_write {
for p in wr {
let pb = PathBuf::from(p);
if pb.is_dir() {
ro_dirs.remove(&pb);
rw_dirs.insert(pb);
} else if pb.exists() {
files.insert(pb);
}
}
}
let mut entries: Vec<RootfsEntry> = Vec::new();
entries.extend(files.into_iter().map(|src| RootfsEntry {
src,
writable: false,
is_dir: false,
}));
entries.extend(ro_dirs.into_iter().map(|src| RootfsEntry {
src,
writable: false,
is_dir: true,
}));
entries.extend(rw_dirs.into_iter().map(|src| RootfsEntry {
src,
writable: true,
is_dir: true,
}));
entries.sort_by(|a, b| a.src.cmp(&b.src));
Ok(RootfsPlan { entries })
}
pub fn materialize_copy(plan: &RootfsPlan, dest: &Path) -> std::io::Result<()> {
for e in &plan.entries {
let rel = e.src.strip_prefix("/").unwrap_or(&e.src);
let target = dest.join(rel);
if e.is_dir {
std::fs::create_dir_all(&target)?; } else {
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
if e.src.is_file() {
std::fs::copy(&e.src, &target)?;
}
}
}
Ok(())
}
pub struct RootfsCache {
root: PathBuf,
}
impl RootfsCache {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
#[must_use]
pub fn key(plan: &RootfsPlan) -> String {
let mut buf = String::new();
for e in &plan.entries {
buf.push_str(&e.src.to_string_lossy());
buf.push('\u{0}');
buf.push_str(if e.writable { "rw" } else { "ro" });
buf.push('\u{0}');
buf.push_str(if e.is_dir { "d" } else { "f" });
if !e.is_dir {
if let Ok(m) = e.src.metadata() {
buf.push_str(&format!("\u{0}{}", m.len()));
if let Ok(mtime) = m.modified() {
if let Ok(d) = mtime.duration_since(std::time::UNIX_EPOCH) {
buf.push_str(&format!("\u{0}{}", d.as_nanos()));
}
}
}
}
buf.push('\n');
}
crate::ContentId::of_bytes(buf.as_bytes())
.as_bytes()
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
pub fn get_or_materialize(&self, plan: &RootfsPlan) -> std::io::Result<(PathBuf, bool)> {
let dir = self.root.join(Self::key(plan));
let marker = dir.join(".bridle-rootfs-complete");
if marker.is_file() {
return Ok((dir, true));
}
if dir.exists() {
std::fs::remove_dir_all(&dir)?; }
materialize_copy(plan, &dir)?;
std::fs::create_dir_all(&dir)?; std::fs::write(&marker, b"")?;
Ok((dir, false))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn unique_dir(tag: &str) -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let mut d = std::env::temp_dir();
d.push(format!(
"agent-bridle-rootfs-{}-{}-{}",
tag,
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn ambient_exec_is_rejected() {
let err = build_rootfs_plan(
&Caveats::top(),
&crate::RootfsPolicy::default(),
&crate::NormalizationPolicy::default(),
)
.unwrap_err();
assert!(err.contains("confined exec scope"), "{err}");
}
#[test]
fn rootfs_contains_granted_program_and_not_ungranted_tools() {
let work = unique_dir("work");
let cav = Caveats {
exec: Scope::only(["cat".to_string()]),
fs_read: Scope::only([work.to_string_lossy().into_owned()]),
fs_write: Scope::only([work.to_string_lossy().into_owned()]),
..Caveats::top()
};
let plan = build_rootfs_plan(
&cav,
&crate::RootfsPolicy::default(),
&crate::NormalizationPolicy::default(),
)
.expect("plan");
let has_cat = plan
.entries
.iter()
.any(|e| e.src.file_name().map(|n| n == "cat").unwrap_or(false) && !e.is_dir);
let has_lib = plan
.entries
.iter()
.any(|e| e.src.to_string_lossy().contains("/libc.so"));
assert!(
has_cat,
"granted `cat` must be in the plan: {:?}",
plan.entries
);
assert!(
has_lib,
"cat's libc closure must be in the plan: {:?}",
plan.entries
);
assert!(plan.entries.iter().any(|e| e.writable
&& e.is_dir
&& e.src == work.canonicalize().unwrap_or(work.clone())));
assert!(plan
.entries
.iter()
.any(|e| e.src.to_string_lossy().contains("ld-")));
for tool in [
"/curl", "/sh", "/bash", "/python3", "/perl", "/head", "/wget", "/nc",
] {
assert!(
!plan.entries.iter().any(|e| {
let s = e.src.to_string_lossy();
s.ends_with(tool) || s.contains(&format!("/bin{tool}"))
}),
"un-granted tool `{tool}` must NOT be in the minimal rootfs: {:?}",
plan.entries
);
}
let dest = unique_dir("root");
materialize_copy(&plan, &dest).expect("materialize");
let cat_present = dest.join("usr/bin/cat").exists()
|| dest.join("bin/cat").exists()
|| dest.join("usr/local/bin/cat").exists();
assert!(cat_present, "materialized tree must contain cat");
for tool in [
"usr/bin/sh",
"bin/sh",
"usr/bin/curl",
"bin/bash",
"usr/bin/head",
] {
assert!(
!dest.join(tool).exists(),
"materialized minimal rootfs must NOT contain un-granted `{tool}`"
);
}
let _ = std::fs::remove_dir_all(&work);
let _ = std::fs::remove_dir_all(&dest);
}
#[test]
fn rootfs_data_paths_are_config_driven() {
let cav = Caveats {
exec: Scope::only(["cat".to_string()]),
..Caveats::top()
};
let has_usr_share =
|p: &RootfsPlan| p.entries.iter().any(|e| e.src == Path::new("/usr/share"));
let default_plan = build_rootfs_plan(
&cav,
&crate::RootfsPolicy::default(),
&crate::NormalizationPolicy::default(),
)
.expect("plan");
assert!(
has_usr_share(&default_plan),
"default plan must include the built-in /usr/share data dir"
);
let stripped = crate::RootfsPolicy {
data_paths: crate::PathList {
base: vec![],
extra: vec![],
replace: true,
},
..crate::RootfsPolicy::default()
};
let stripped_plan =
build_rootfs_plan(&cav, &stripped, &crate::NormalizationPolicy::default())
.expect("plan");
assert!(
!has_usr_share(&stripped_plan),
"a replace'd empty data_paths must drop /usr/share from the plan"
);
}
#[test]
fn rootfs_ldd_closure_is_toggleable() {
let cav = Caveats {
exec: Scope::only(["cat".to_string()]),
..Caveats::top()
};
let has_lib = |p: &RootfsPlan| {
p.entries
.iter()
.any(|e| e.src.to_string_lossy().contains("/libc.so"))
};
let on = build_rootfs_plan(
&cav,
&crate::RootfsPolicy::default(),
&crate::NormalizationPolicy::default(),
)
.expect("plan");
assert!(has_lib(&on), "default plan must include cat's libc closure");
let off = crate::NormalizationPolicy {
ldd_closure: false,
..crate::NormalizationPolicy::default()
};
let plan = build_rootfs_plan(&cav, &crate::RootfsPolicy::default(), &off).expect("plan");
assert!(
!has_lib(&plan),
"disabling ldd_closure must drop the .so closure from the plan"
);
}
#[test]
fn cache_key_is_stable_and_varies_with_grant() {
let work = unique_dir("ck");
let mk = |prog: &str| Caveats {
exec: Scope::only([prog.to_string()]),
fs_read: Scope::only([work.to_string_lossy().into_owned()]),
..Caveats::top()
};
let k_cat = RootfsCache::key(
&build_rootfs_plan(
&mk("cat"),
&crate::RootfsPolicy::default(),
&crate::NormalizationPolicy::default(),
)
.unwrap(),
);
let k_cat2 = RootfsCache::key(
&build_rootfs_plan(
&mk("cat"),
&crate::RootfsPolicy::default(),
&crate::NormalizationPolicy::default(),
)
.unwrap(),
);
let k_grep = RootfsCache::key(
&build_rootfs_plan(
&mk("grep"),
&crate::RootfsPolicy::default(),
&crate::NormalizationPolicy::default(),
)
.unwrap(),
);
assert_eq!(k_cat, k_cat2, "same grant ⇒ stable key");
assert_ne!(k_cat, k_grep, "different exec scope ⇒ different key");
assert_eq!(k_cat.len(), 64, "hex of a 32-byte BLAKE3 content id");
let _ = std::fs::remove_dir_all(&work);
}
#[test]
fn cache_materializes_once_then_hits() {
let work = unique_dir("cm");
std::fs::write(work.join("data"), b"x").unwrap();
let cav = Caveats {
exec: Scope::only(["cat".to_string()]),
fs_read: Scope::only([work.to_string_lossy().into_owned()]),
fs_write: Scope::only([work.to_string_lossy().into_owned()]),
..Caveats::top()
};
let plan = build_rootfs_plan(
&cav,
&crate::RootfsPolicy::default(),
&crate::NormalizationPolicy::default(),
)
.expect("plan");
let cache_root = unique_dir("cache");
let cache = RootfsCache::new(&cache_root);
let (dir1, hit1) = cache.get_or_materialize(&plan).expect("build");
assert!(!hit1, "first build is a miss");
assert!(
dir1.join(".bridle-rootfs-complete").is_file(),
"completion marker written"
);
assert!(
dir1.join("usr/bin/cat").exists() || dir1.join("bin/cat").exists(),
"cached tree contains the granted program"
);
let (dir2, hit2) = cache.get_or_materialize(&plan).expect("reuse");
assert!(hit2, "second build is a cache hit");
assert_eq!(dir1, dir2, "same keyed directory");
let _ = std::fs::remove_dir_all(&work);
let _ = std::fs::remove_dir_all(&cache_root);
}
#[test]
fn python_fallback_adds_stdlib_without_executables() {
let work = unique_dir("py");
let cav = Caveats {
exec: Scope::only(["python3".to_string()]),
fs_read: Scope::only([work.to_string_lossy().into_owned()]),
..Caveats::top()
};
let plan = match build_rootfs_plan(
&cav,
&crate::RootfsPolicy::default(),
&crate::NormalizationPolicy::default(),
) {
Ok(p) => p,
Err(_) => return, };
let has_py_stdlib = plan
.entries
.iter()
.any(|e| e.is_dir && !e.writable && e.src.to_string_lossy().contains("/python3"));
assert!(
has_py_stdlib,
"python stdlib dir must be in the plan: {:?}",
plan.entries
);
for e in &plan.entries {
if e.is_dir {
continue;
}
let s = e.src.to_string_lossy();
if s.starts_with("/usr/bin") || s.starts_with("/bin") || s.contains("/sbin/") {
let base = e.src.file_name().and_then(|n| n.to_str()).unwrap_or("");
assert!(
base.starts_with("python"),
"fallback must not add an un-granted executable: {s}"
);
}
}
let _ = std::fs::remove_dir_all(&work);
}
#[test]
fn nss_modules_added_to_closure() {
let work = unique_dir("nss");
let cav = Caveats {
exec: Scope::only(["cat".to_string()]),
fs_read: Scope::only([work.to_string_lossy().into_owned()]),
..Caveats::top()
};
let plan = build_rootfs_plan(
&cav,
&crate::RootfsPolicy::default(),
&crate::NormalizationPolicy::default(),
)
.expect("plan");
let nss_in_plan = |p: &RootfsPlan| {
p.entries.iter().any(|e| {
e.src
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("libnss_"))
})
};
if let Some(libc) = plan.entries.iter().find(|e| {
e.src
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("libc.so"))
}) {
if let Some(dir) = libc.src.parent() {
let host_has_nss = dir
.read_dir()
.into_iter()
.flatten()
.flatten()
.any(|e| e.file_name().to_string_lossy().starts_with("libnss_"));
if host_has_nss {
assert!(
nss_in_plan(&plan),
"NSS modules must be added to the closure: {:?}",
plan.entries
);
}
}
}
let _ = std::fs::remove_dir_all(&work);
}
}