use crate::policy::Policy;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct LandlockCompatReport {
pub allowed_roots: Vec<PathBuf>,
pub conflicts: Vec<(PathBuf, PathBuf)>, }
impl LandlockCompatReport {
pub fn is_compatible(&self) -> bool {
self.conflicts.is_empty()
}
}
pub fn check_compatibility(policy: &Policy, cwd: &Path, tmp: &Path) -> LandlockCompatReport {
let mut allowed_roots = Vec::new();
let mut deny_paths: Vec<PathBuf> = Vec::new();
for path_str in &policy.fs.allow {
if let Some(path) = resolve_path(path_str, cwd) {
allowed_roots.push(path);
}
}
if let Some(p) = resolve_path_buf(cwd, cwd) {
allowed_roots.push(p);
}
if let Some(p) = resolve_path_buf(tmp, cwd) {
allowed_roots.push(p);
}
for path_str in &policy.fs.deny {
if let Some(path) = resolve_path(path_str, cwd) {
deny_paths.push(path);
}
}
let mut conflicts = Vec::new();
for deny in &deny_paths {
for allow in &allowed_roots {
if is_subpath_or_equal(allow, deny) {
conflicts.push((allow.clone(), deny.clone()));
}
}
}
conflicts.sort();
conflicts.dedup();
LandlockCompatReport {
allowed_roots,
conflicts,
}
}
fn resolve_path(raw: &str, cwd: &Path) -> Option<PathBuf> {
let path_str = raw.replace("${CWD}", &cwd.to_string_lossy());
let path_buf = if let Some(stripped) = path_str.strip_prefix("~") {
let home = std::env::var("HOME").unwrap_or_else(|_| "/".to_string());
if stripped.is_empty() {
PathBuf::from(home)
} else if stripped.starts_with(std::path::MAIN_SEPARATOR) {
PathBuf::from(home).join(stripped.trim_start_matches(std::path::MAIN_SEPARATOR))
} else {
PathBuf::from(&path_str)
}
} else {
PathBuf::from(&path_str)
};
let path = if path_buf.is_relative() {
cwd.join(path_buf)
} else {
path_buf
};
if let Ok(p) = std::fs::canonicalize(&path) {
return Some(p);
}
let mut current = path.clone();
let mut components_to_append = Vec::new();
while !current.exists() && current.parent().is_some() {
if let Some(file_name) = current.file_name() {
components_to_append.push(file_name.to_os_string());
current.pop();
} else {
break;
}
}
let base = if current.exists() {
std::fs::canonicalize(¤t).ok().unwrap_or(current)
} else {
current
};
let mut final_path = base;
for component in components_to_append.into_iter().rev() {
final_path.push(component);
}
normalize_path_lexially(&final_path)
}
fn resolve_path_buf(p: &Path, cwd: &Path) -> Option<PathBuf> {
resolve_path(&p.to_string_lossy(), cwd)
}
fn normalize_path_lexially(path: &Path) -> Option<PathBuf> {
use std::path::{Component, PathBuf};
let mut clean = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
clean.pop();
}
_ => clean.push(component),
}
}
Some(clean)
}
fn is_subpath_or_equal(parent: &Path, child: &Path) -> bool {
child.starts_with(parent)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::policy::Policy;
#[test]
fn test_conflict_detection() {
let mut policy = Policy::default();
policy.fs.allow.push("/tmp".to_string());
policy.fs.deny.push("/tmp/secrets".to_string());
let cwd = Path::new("/tmp"); let tmp = Path::new("/tmp/assay-sandbox");
let report = check_compatibility(&policy, cwd, tmp);
assert!(!report.is_compatible());
assert!(!report.conflicts.is_empty());
let has_conflict = report
.conflicts
.iter()
.any(|(a, d)| a.ends_with("tmp") && d.ends_with("secrets"));
assert!(
has_conflict,
"Should detect conflict between /tmp and /tmp/secrets"
);
}
#[test]
fn test_no_conflict_disjoint() {
let mut policy = Policy::default();
policy.fs.allow.push("/usr".to_string());
policy.fs.deny.push("/etc/shadow".to_string());
let cwd = Path::new("/home/user");
let tmp = Path::new("/tmp/sandbox");
let report = check_compatibility(&policy, cwd, tmp);
assert!(report.is_compatible());
}
}