use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
use super::*;
use crate::stdlib::sandbox::enforcement::MechanismLimit;
use ::landlock::Access;
const EVERY_ABI: [ABI; 6] = [ABI::V1, ABI::V2, ABI::V3, ABI::V4, ABI::V5, ABI::V6];
#[test]
fn limits_name_each_gap_an_older_abi_leaves() {
use MechanismLimit::*;
let limits = |abi| LandlockSupport::assume(abi).limits();
assert_eq!(
limits(ABI::V1),
vec![
CrossDirectoryRenameRefused,
TruncationFilteredBySyscall,
DeviceIoctlUnscoped,
UnixSocketGrantUnavailable
]
);
assert_eq!(
limits(ABI::V2),
vec![
TruncationFilteredBySyscall,
DeviceIoctlUnscoped,
UnixSocketGrantUnavailable
]
);
assert_eq!(
limits(ABI::V4),
vec![DeviceIoctlUnscoped, UnixSocketGrantUnavailable]
);
assert_eq!(limits(ABI::V5), vec![UnixSocketGrantUnavailable]);
assert_eq!(limits(ABI::V6), Vec::new());
}
#[test]
fn negotiation_selects_the_newest_abi_the_kernel_accepts() {
let kernel = |newest: Option<ABI>| move |abi: ABI| newest.is_some_and(|newest| abi <= newest);
let selected = |newest, ceiling| {
LandlockSupport::negotiate_with(ceiling, kernel(newest)).map(LandlockSupport::abi)
};
assert_eq!(selected(Some(ABI::V1), ABI::V6), Some(ABI::V1));
assert_eq!(selected(Some(ABI::V5), ABI::V6), Some(ABI::V5));
assert_eq!(selected(Some(ABI::V6), ABI::V6), Some(ABI::V6));
assert_eq!(selected(Some(ABI::V9), ABI::V6), Some(ABI::V6));
assert_eq!(selected(None, ABI::V6), None, "no Landlock selects nothing");
assert_eq!(selected(Some(ABI::V6), ABI::V2), Some(ABI::V2));
let gappy = |abi: ABI| abi != ABI::V6 && abi <= ABI::V5;
assert_eq!(
LandlockSupport::negotiate_with(ABI::V6, gappy).map(LandlockSupport::abi),
Some(ABI::V5)
);
}
#[test]
fn the_negotiated_abi_is_the_one_the_live_kernel_creates() {
if !live_landlock_available("landlock-abi-negotiation") {
return;
}
let support = LandlockSupport::negotiate(ABI::V6).expect("Landlock is available");
for abi in EVERY_ABI {
assert_eq!(
capped(abi).is_some(),
abi <= support.abi(),
"ABI {abi} against negotiated ABI {}",
support.abi()
);
}
}
#[test]
fn handled_rights_follow_the_abi_and_stop_at_harns_vocabulary() {
let handled = |abi| LandlockSupport::assume(abi).handled_access();
for abi in EVERY_ABI {
assert!(
handled(abi).contains(AccessFs::from_all(ABI::V1)),
"ABI {abi} must handle the whole ABI 1 vocabulary"
);
}
assert!(!handled(ABI::V1).contains(AccessFs::Refer));
assert!(handled(ABI::V2).contains(AccessFs::Refer));
assert!(!handled(ABI::V2).contains(AccessFs::Truncate));
assert!(handled(ABI::V3).contains(AccessFs::Truncate));
assert!(!handled(ABI::V4).contains(AccessFs::IoctlDev));
assert!(handled(ABI::V5).contains(AccessFs::IoctlDev));
assert_eq!(handled(ABI::V6), AccessFs::from_all(ABI::V5));
assert!(!handled(ABI::V6).contains(AccessFs::ResolveUnix));
}
#[test]
fn the_unix_socket_grant_alone_demands_abi_6() {
let workspace = tempfile::tempdir().expect("workspace");
let mut policy = abi_policy(workspace.path());
policy.process_sandbox.unix_socket_roots = vec![workspace.path().display().to_string()];
let refusal = landlock_profile_for("/bin/true", &policy, LandlockSupport::assume(ABI::V5))
.err()
.expect("a serve-only socket grant must refuse below ABI 6");
assert!(
refusal.to_string().contains("ABI 6") && refusal.to_string().contains("ABI 5"),
"the refusal names both ABIs: {refusal}"
);
if let Some(support) = capped(ABI::V5) {
landlock_profile_for("/bin/true", &abi_policy(workspace.path()), support)
.expect("ABI 5 renders the default grants");
}
}
#[test]
fn only_an_abi_without_truncation_control_changes_the_syscall_filter() {
let policy = abi_policy(Path::new("/ws"));
let bounded = compile_seccomp_program(&policy, true).expect("bounded filter");
let filtered = compile_seccomp_program(&policy, false).expect("filtered filter");
assert_ne!(bounded, filtered);
let mut rules: std::collections::BTreeMap<libc::c_long, Vec<SeccompRule>> =
allowed_syscalls(&policy)
.into_iter()
.map(|syscall| (syscall, Vec::new()))
.collect();
landlock_support::filter_path_truncation(&mut rules).expect("truncation rules");
assert!(!rules.contains_key(&libc::SYS_truncate));
assert!(!rules.contains_key(&libc::SYS_openat2));
assert_eq!(rules[&libc::SYS_openat].len(), 3);
}
#[test]
fn every_negotiated_abi_confines_reads_and_writes_to_the_grants() {
if !live_landlock_available("landlock-abi-boundary") {
return;
}
let workspace = tempfile::tempdir().expect("workspace");
let outside = tempfile::tempdir().expect("outside");
std::fs::write(outside.path().join("secret"), "outside").expect("seed secret");
let policy = abi_policy(workspace.path());
let inside = c_path(&workspace.path().join("inside"));
let escaped = c_path(&outside.path().join("escaped"));
let secret = c_path(&outside.path().join("secret"));
for abi in EVERY_ABI {
let Some(support) = capped(abi) else { continue };
let profile = profile_setup_for("/bin/true", &policy, support).expect("profile");
let created = |path: &CString| {
let fd = unsafe { libc::open(path.as_ptr(), libc::O_WRONLY | libc::O_CREAT, 0o600) };
fd >= 0
};
let inside_written = run_confined(&profile, || created(&inside));
let outside_written = run_confined(&profile, || created(&escaped));
let outside_read = run_confined(&profile, || unsafe {
libc::open(secret.as_ptr(), libc::O_RDONLY) >= 0
});
assert!(inside_written, "ABI {abi}: a workspace write must land");
assert!(
!outside_written,
"ABI {abi}: an outside write must be refused"
);
assert!(!outside_read, "ABI {abi}: an outside read must be refused");
assert!(!outside.path().join("escaped").exists());
}
}
#[test]
fn the_syscall_filter_closes_truncation_below_abi_3() {
if !live_landlock_available("landlock-abi-truncation") {
return;
}
let workspace = tempfile::tempdir().expect("workspace");
let outside = tempfile::tempdir().expect("outside");
let target = outside.path().join("victim");
let policy = abi_policy(workspace.path());
let path = c_path(&target);
let truncate_by_path = || unsafe { libc::truncate(path.as_ptr(), 0) == 0 };
let truncate_on_read_open =
|| unsafe { libc::open(path.as_ptr(), libc::O_RDONLY | libc::O_TRUNC) >= 0 };
for abi in [ABI::V1, ABI::V2, ABI::V3] {
let Some(support) = capped(abi) else { continue };
let profile = profile_setup_for("/bin/true", &policy, support).expect("profile");
for (name, attempt) in [
("truncate(2)", &truncate_by_path as &dyn Fn() -> bool),
("O_RDONLY|O_TRUNC", &truncate_on_read_open),
] {
std::fs::write(&target, "intact").expect("seed");
assert!(
!run_confined(&profile, attempt),
"ABI {abi}: {name} succeeded"
);
assert_eq!(
std::fs::read(&target).unwrap(),
b"intact",
"ABI {abi}: {name}"
);
}
}
let Some(support) = capped(ABI::V2) else {
return;
};
let mut unfiltered = profile_setup_for("/bin/true", &policy, support).expect("profile");
unfiltered.seccomp = compile_seccomp_program(&policy, true).expect("unfiltered program");
std::fs::write(&target, "intact").expect("seed");
assert!(run_confined(&unfiltered, truncate_by_path));
assert_eq!(std::fs::read(&target).unwrap(), b"");
}
#[test]
fn cross_directory_rename_is_refused_only_below_abi_2() {
if !live_landlock_available("landlock-abi-refer") {
return;
}
let workspace = tempfile::tempdir().expect("workspace");
std::fs::create_dir(workspace.path().join("a")).unwrap();
std::fs::create_dir(workspace.path().join("b")).unwrap();
let policy = abi_policy(workspace.path());
let from = c_path(&workspace.path().join("a/file"));
let to = c_path(&workspace.path().join("b/file"));
let rename = || unsafe { libc::rename(from.as_ptr(), to.as_ptr()) == 0 };
for (abi, expected) in [(ABI::V1, false), (ABI::V2, true)] {
let Some(support) = capped(abi) else { continue };
std::fs::write(workspace.path().join("a/file"), "moved").unwrap();
let _ = std::fs::remove_file(workspace.path().join("b/file"));
let profile = profile_setup_for("/bin/true", &policy, support).expect("profile");
assert_eq!(run_confined(&profile, rename), expected, "ABI {abi}");
assert_eq!(
support
.limits()
.contains(&MechanismLimit::CrossDirectoryRenameRefused),
!expected,
"ABI {abi}: the limit is reported exactly when the kernel refuses"
);
}
}
fn capped(abi: ABI) -> Option<LandlockSupport> {
LandlockSupport::negotiate(abi).filter(|support| support.abi() == abi)
}
fn abi_policy(workspace: &Path) -> CapabilityPolicy {
let mut policy = linux_policy_with_workspace_ops(&["read_text", "write_text", "delete"]);
policy.workspace_roots = vec![workspace.display().to_string()];
policy.side_effect_level = Some("process_exec".to_string());
*policy.process_sandbox = crate::orchestration::ProcessSandboxPolicy {
presets: Some(Vec::new()),
..Default::default()
};
policy
}
fn c_path(path: &Path) -> CString {
CString::new(path.as_os_str().as_bytes()).expect("path without NUL")
}
fn run_confined(profile: &ProcessProfile, attempt: impl Fn() -> bool) -> bool {
let child = unsafe { libc::fork() };
assert!(child >= 0, "fork failed");
if child == 0 {
let code = match apply_profile(profile) {
Ok(()) => i32::from(!attempt()),
Err(_) => 2,
};
unsafe { libc::_exit(code) };
}
let mut status = 0;
assert_eq!(unsafe { libc::waitpid(child, &raw mut status, 0) }, child);
assert!(libc::WIFEXITED(status), "child did not exit: {status}");
match libc::WEXITSTATUS(status) {
0 => true,
1 => false,
code => panic!("the child could not enter the profile (exit {code})"),
}
}