use super::*;
use crate::stdlib::sandbox::{effective_fallback, handler_sandbox_test_guard};
const WRITE_BITS: u64 = LANDLOCK_ACCESS_FS_WRITE_FILE
| LANDLOCK_ACCESS_FS_REMOVE_DIR
| LANDLOCK_ACCESS_FS_REMOVE_FILE
| LANDLOCK_ACCESS_FS_MAKE_CHAR
| LANDLOCK_ACCESS_FS_MAKE_DIR
| LANDLOCK_ACCESS_FS_MAKE_REG
| LANDLOCK_ACCESS_FS_MAKE_SOCK
| LANDLOCK_ACCESS_FS_MAKE_FIFO
| LANDLOCK_ACCESS_FS_MAKE_BLOCK
| LANDLOCK_ACCESS_FS_MAKE_SYM
| LANDLOCK_ACCESS_FS_REFER
| LANDLOCK_ACCESS_FS_TRUNCATE;
const REQUIRE_LIVE_LANDLOCK_ENV: &str = "HARN_REQUIRE_LANDLOCK_TESTS";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LiveLandlock {
Enforcing,
AbsentOnHost,
DisabledBySelector,
}
impl LiveLandlock {
fn probe() -> Self {
if landlock_abi_version() == 0 {
return Self::AbsentOnHost;
}
if matches!(
effective_fallback(SandboxProfile::Worktree),
SandboxFallback::Off
) {
return Self::DisabledBySelector;
}
Self::Enforcing
}
fn reason(self) -> &'static str {
match self {
Self::Enforcing => "Landlock is enforcing",
Self::AbsentOnHost => {
"Landlock unavailable on this host, sandbox boundary not exercised"
}
Self::DisabledBySelector => {
"Landlock disabled by the fallback selector, sandbox boundary not exercised"
}
}
}
}
fn active_lsm_list() -> String {
std::fs::read_to_string("/sys/kernel/security/lsm")
.map(|text| text.trim().to_string())
.unwrap_or_else(|error| format!("<unreadable: {error}>"))
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum LandlockGate {
Proceed,
Skip(String),
Fail(String),
}
fn live_landlock_required() -> bool {
std::env::var(REQUIRE_LIVE_LANDLOCK_ENV)
.map(|value| {
let value = value.trim().to_ascii_lowercase();
!matches!(value.as_str(), "" | "0" | "false" | "off" | "no")
})
.unwrap_or(false)
}
fn landlock_gate(state: LiveLandlock, required: bool, test: &str, lsm: &str) -> LandlockGate {
if state == LiveLandlock::Enforcing {
return LandlockGate::Proceed;
}
if required {
return LandlockGate::Fail(format!(
"[{test}] {}; {REQUIRE_LIVE_LANDLOCK_ENV} declares this host must enforce it. active security modules: {lsm}",
state.reason()
));
}
LandlockGate::Skip(format!(
"[{test}] SKIPPED: {}. active security modules: {lsm}",
state.reason()
))
}
#[must_use]
fn live_landlock_available(test: &str) -> bool {
let lsm = active_lsm_list();
match landlock_gate(LiveLandlock::probe(), live_landlock_required(), test, &lsm) {
LandlockGate::Proceed => {
eprintln!(
"[{test}] Landlock ABI {} reported enforcing. active security modules: {lsm}",
landlock_abi_version()
);
true
}
LandlockGate::Skip(reason) => {
eprintln!("{reason}");
false
}
LandlockGate::Fail(reason) => panic!("{reason}"),
}
}
fn linux_policy_with_workspace_ops(ops: &[&str]) -> CapabilityPolicy {
CapabilityPolicy {
tools: Vec::new(),
capabilities: std::collections::BTreeMap::from([(
"workspace".to_string(),
ops.iter().map(|op| op.to_string()).collect(),
)]),
workspace_roots: vec!["/ws".to_string()],
read_only_roots: Vec::new(),
side_effect_level: Some("read_only".to_string()),
recursion_limit: None,
tool_arg_constraints: Vec::new(),
tool_annotations: std::collections::BTreeMap::new(),
sandbox_profile: SandboxProfile::Worktree,
process_sandbox: Default::default(),
process_network_proxy: None,
}
}
#[test]
fn managed_proxy_fails_closed_without_proxy_only_network_namespace() {
let mut policy = linux_policy_with_workspace_ops(&["read_text"]);
policy.side_effect_level = Some("network".to_string());
policy.process_network_proxy = Some(crate::orchestration::ProcessNetworkProxy {
http_port: 3128,
socks_port: 1080,
});
let error = match profile_setup("ignored", &policy, SandboxProfile::Worktree) {
Ok(_) => panic!("managed proxy must not widen to unrestricted Linux sockets"),
Err(error) => error,
};
assert!(
error
.to_string()
.contains("requires a proxy-only Linux network namespace"),
"{error}"
);
}
#[test]
fn no_network_excludes_addressable_sockets_but_allows_local_socketpair() {
let policy = linux_policy_with_workspace_ops(&["read_text"]);
assert_eq!(
policy.side_effect_level.as_deref(),
Some("read_only"),
"fixture must be below the network ceiling",
);
let allowed = allowed_syscalls(&policy);
assert!(
!allowed.contains(&libc::SYS_socket),
"addressable socket() must not be allowlisted without network",
);
assert!(
!allowed.contains(&libc::SYS_connect),
"connect() must not be allowlisted without network",
);
assert!(
allowed.contains(&libc::SYS_socketpair),
"socketpair() (local IPC) must be allowlisted — Cargo's jobserver needs it",
);
for call in [
libc::SYS_recvfrom,
libc::SYS_recvmsg,
libc::SYS_sendmsg,
libc::SYS_sendto,
] {
assert!(
allowed.contains(&call),
"send/recv syscall {call} must be allowlisted — local socketpair IPC (Cargo jobserver) needs it",
);
}
for call in [
libc::SYS_socket,
libc::SYS_connect,
libc::SYS_bind,
libc::SYS_listen,
libc::SYS_accept,
libc::SYS_accept4,
] {
assert!(
!allowed.contains(&call),
"egress opener {call} must stay absent without network",
);
}
}
#[test]
fn network_ceiling_allows_all_socket_syscalls() {
let mut policy = linux_policy_with_workspace_ops(&["read_text"]);
policy.side_effect_level = Some("network".to_string());
let allowed = allowed_syscalls(&policy);
for call in [
libc::SYS_socket,
libc::SYS_socketpair,
libc::SYS_connect,
libc::SYS_bind,
] {
assert!(
allowed.contains(&call),
"network ceiling must allowlist socket-family syscall {call}",
);
}
}
#[test]
fn filesystem_metadata_syscalls_include_fchmodat2() {
let policy = linux_policy_with_workspace_ops(&["read_text", "write_text"]);
let allowed = allowed_syscalls(&policy);
assert!(
allowed.contains(&SYS_FCHMODAT2),
"modern tools use fchmodat2 to preserve symlink metadata",
);
}
#[test]
fn sandboxed_tar_extracts_symlinks_without_widening_the_write_root() {
if !live_landlock_available("sandboxed-tar") {
return;
}
let workspace = tempfile::tempdir().expect("workspace");
let source = workspace.path().join("archive-source");
let extract = workspace.path().join("extract");
std::fs::create_dir(&source).expect("archive source");
std::fs::create_dir(&extract).expect("extract root");
std::fs::write(source.join("harn"), "fixture binary").expect("fixture binary");
std::os::unix::fs::symlink("harn", source.join("harn-dap")).expect("fixture symlink");
let archive = workspace.path().join("harn.tar.gz");
let create = Command::new("tar")
.args(["-czf"])
.arg(&archive)
.arg("-C")
.arg(&source)
.arg(".")
.output()
.expect("create archive");
assert!(
create.status.success(),
"create archive: {}",
String::from_utf8_lossy(&create.stderr),
);
let mut policy = linux_policy_with_workspace_ops(&["read_text", "write_text"]);
policy.workspace_roots = vec![workspace.path().display().to_string()];
policy.side_effect_level = Some("process_exec".to_string());
let run_tar = |destination: &Path| {
let args = vec![
"-xzf".to_string(),
archive.display().to_string(),
"-C".to_string(),
destination.display().to_string(),
];
let mut command = Command::new("tar");
command.args(&args).current_dir(workspace.path());
let preparation = Backend::prepare_std_command(
"tar",
&args,
&mut command,
&policy,
SandboxProfile::Worktree,
)
.expect("prepare sandboxed tar");
assert!(matches!(preparation, PrepareOutcome::Direct));
command.output().expect("run sandboxed tar")
};
let extracted = run_tar(&extract);
assert!(
extracted.status.success(),
"extract archive: {}",
String::from_utf8_lossy(&extracted.stderr),
);
assert_eq!(
std::fs::read_link(extract.join("harn-dap")).expect("extracted symlink"),
Path::new("harn"),
);
if landlock_abi_version() == 0 {
eprintln!("[fchmodat2-boundary] SKIPPED: no Landlock on this kernel");
return;
}
let outside = tempfile::tempdir().expect("outside workspace");
let refused = run_tar(outside.path());
assert!(
!refused.status.success(),
"fchmodat2 must not weaken the Landlock write boundary",
);
assert!(
!outside.path().join("harn").exists(),
"an out-of-scope extraction must write nothing",
);
}
#[test]
fn network_ceiling_grants_exact_name_service_files_without_opening_run() {
let mut policy = linux_policy_with_workspace_ops(&["read_text"]);
assert!(network_name_service_read_roots(&policy).is_empty());
policy.side_effect_level = Some("network".to_string());
let roots = network_name_service_read_roots(&policy);
assert_eq!(
roots,
[
"/etc/resolv.conf",
"/etc/hosts",
"/etc/nsswitch.conf",
"/etc/gai.conf",
"/etc/host.conf",
]
.into_iter()
.map(PathBuf::from)
.collect::<Vec<_>>(),
);
assert!(
roots.iter().all(|root| !root.starts_with("/run")),
"the repair must grant canonical resolver files, never the mutable /run tree",
);
}
#[test]
fn process_network_ceiling_controls_real_child_socket() {
let workspace = tempfile::tempdir().expect("workspace");
let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("loopback listener");
let address = listener.local_addr().expect("listener address");
let args = vec![
"-c".to_string(),
format!("exec 3<>/dev/tcp/127.0.0.1/{}", address.port()),
];
let run_probe = |policy: &CapabilityPolicy| {
let mut command = Command::new("/bin/bash");
command.args(&args).current_dir(workspace.path());
let preparation = Backend::prepare_std_command(
"/bin/bash",
&args,
&mut command,
policy,
SandboxProfile::Worktree,
)
.expect("prepare sandboxed child");
assert!(matches!(preparation, PrepareOutcome::Direct));
command.output().expect("run sandboxed child")
};
let mut denied = linux_policy_with_workspace_ops(&["read_text"]);
denied.workspace_roots = vec![workspace.path().display().to_string()];
denied.side_effect_level = Some("process_exec".to_string());
let denied_output = run_probe(&denied);
assert!(
!denied_output.status.success(),
"the default process-exec ceiling must deny an addressable child socket",
);
let mut allowed = denied;
allowed.side_effect_level = Some("network".to_string());
let allowed_output = run_probe(&allowed);
assert!(
allowed_output.status.success(),
"the network ceiling must permit the child loopback socket: {}",
String::from_utf8_lossy(&allowed_output.stderr),
);
listener
.set_nonblocking(true)
.expect("set listener nonblocking");
listener
.accept()
.expect("the listener must observe the allowed child connection");
}
#[test]
fn seccomp_filter_is_default_deny_allowlist() {
let filter = compile_seccomp_program(&[libc::SYS_read, libc::SYS_write])
.expect("compile the probe filter");
assert_eq!(
filter.last().map(|entry| entry.k),
Some(libc::SECCOMP_RET_ERRNO | libc::EPERM as u32),
"seccomp fallthrough must deny unknown syscalls",
);
assert!(
filter
.iter()
.any(|entry| entry.k == libc::SECCOMP_RET_ALLOW),
"allowlisted syscalls must jump to an allow action",
);
}
#[test]
fn seccomp_filter_validates_architecture_before_syscall_number() {
let filter = compile_seccomp_program(&[libc::SYS_msync]).expect("compile the probe filter");
let arch_load = filter.first().expect("filter must not be empty");
assert_eq!(
arch_load.code,
(libc::BPF_LD | libc::BPF_W | libc::BPF_ABS) as u16,
"the first instruction must be an absolute word load",
);
assert_eq!(
arch_load.k, 4,
"the first load must read seccomp_data.arch (offset 4), not .nr (offset 0)",
);
assert_eq!(
filter.get(2).map(|entry| entry.k),
Some(libc::SECCOMP_RET_KILL_PROCESS),
"an architecture mismatch must kill the process, never return EPERM: \
EPERM would let a caller probe the whole syscall space for free",
);
assert_eq!(
filter.get(3).map(|entry| (entry.code, entry.k)),
Some(((libc::BPF_LD | libc::BPF_W | libc::BPF_ABS) as u16, 0)),
"the syscall number load must follow the architecture check",
);
}
#[test]
fn allowlist_excludes_process_introspection_and_io_uring() {
let policy = linux_policy_with_workspace_ops(&["read_text", "write_text"]);
let allowed = allowed_syscalls(&policy);
for call in [
libc::SYS_ptrace,
libc::SYS_process_vm_readv,
libc::SYS_process_vm_writev,
libc::SYS_io_uring_setup,
libc::SYS_io_uring_enter,
libc::SYS_io_uring_register,
] {
assert!(
!allowed.contains(&call),
"dangerous syscall {call} must stay outside the seccomp allowlist",
);
}
}
#[test]
fn read_only_access_grants_read_and_execute_but_never_write() {
let access = read_only_access();
assert_ne!(access & LANDLOCK_ACCESS_FS_READ_FILE, 0, "read file");
assert_ne!(access & LANDLOCK_ACCESS_FS_READ_DIR, 0, "read dir");
assert_ne!(access & LANDLOCK_ACCESS_FS_EXECUTE, 0, "execute");
assert_eq!(
access & WRITE_BITS,
0,
"read-only access must not carry any write/create/remove right",
);
}
#[test]
fn read_only_access_is_independent_of_workspace_write_capability() {
let writable = linux_policy_with_workspace_ops(&["read_text", "write_text", "delete"]);
assert_ne!(
workspace_access(&writable) & LANDLOCK_ACCESS_FS_WRITE_FILE,
0,
"writable workspace root should carry write",
);
assert_eq!(
read_only_access() & WRITE_BITS,
0,
"read-only roots stay unwritable regardless of workspace write capability",
);
}
#[test]
fn package_manager_config_roots_are_read_only() {
let temp_home = tempfile::tempdir().expect("temp home");
std::fs::write(
temp_home.path().join(".npmrc"),
"registry=https://registry.example\n",
)
.expect("write npmrc");
let roots = super::super::package_manager_config_read_roots_for_home(temp_home.path());
assert!(
roots.iter().any(|path| path.ends_with(".npmrc")),
"npmrc should be part of the package-manager preset"
);
assert!(
roots
.iter()
.any(|path| path.ends_with(".cargo/config.toml")),
"cargo config should be part of the package-manager preset"
);
assert!(
roots.iter().all(|path| path.starts_with(temp_home.path())),
"package-manager roots must stay under HOME"
);
assert_eq!(
read_only_access() & WRITE_BITS,
0,
"package-manager Landlock rules use read-only access bits"
);
}
#[test]
fn developer_toolchain_roots_are_read_only() {
let temp_home = tempfile::tempdir().expect("temp home");
let roots = super::super::developer_toolchain_read_roots_for_home(temp_home.path());
assert!(
roots.iter().any(|path| path.ends_with(".local/share/uv")),
"uv runtimes should be part of the developer-toolchain preset"
);
assert!(
roots.iter().any(|path| path.ends_with(".rustup")),
"rustup should be part of the developer-toolchain preset"
);
assert!(
roots.iter().all(|path| path.starts_with(temp_home.path())),
"developer-toolchain roots must stay under HOME"
);
assert_eq!(
read_only_access() & WRITE_BITS,
0,
"developer-toolchain Landlock rules use read-only access bits"
);
}
#[test]
fn developer_toolchains_admit_linux_vendor_installations() {
let enabled = CapabilityPolicy {
process_sandbox: crate::orchestration::ProcessSandboxPolicy {
presets: Some(vec![ProcessSandboxPreset::DeveloperToolchains]),
..Default::default()
},
..Default::default()
};
assert_eq!(
developer_toolchain_system_read_roots(&enabled),
vec![PathBuf::from("/opt")]
);
let disabled = CapabilityPolicy {
process_sandbox: crate::orchestration::ProcessSandboxPolicy {
presets: Some(Vec::new()),
..Default::default()
},
..Default::default()
};
assert!(developer_toolchain_system_read_roots(&disabled).is_empty());
}
#[test]
fn standard_device_rules_allow_common_device_files_only() {
let rules = standard_device_rules();
assert_eq!(rules.len(), 4);
assert!(rules.iter().any(
|(path, access)| path.as_path() == std::path::Path::new("/dev/null")
&& access & LANDLOCK_ACCESS_FS_READ_FILE != 0
&& access & LANDLOCK_ACCESS_FS_WRITE_FILE != 0
&& access & LANDLOCK_ACCESS_FS_IOCTL_DEV == 0
));
for device in ["/dev/zero", "/dev/random", "/dev/urandom"] {
let Some((_, access)) = rules
.iter()
.find(|(path, _)| path.as_path() == std::path::Path::new(device))
else {
panic!("missing standard device rule for {device}");
};
assert_ne!(
*access & LANDLOCK_ACCESS_FS_READ_FILE,
0,
"{device} should be readable"
);
assert_eq!(
*access & LANDLOCK_ACCESS_FS_WRITE_FILE,
0,
"{device} must not be writable"
);
assert_eq!(
*access & LANDLOCK_ACCESS_FS_IOCTL_DEV,
0,
"{device} must not receive device ioctl access"
);
}
}
#[test]
fn directory_only_access_excludes_file_applicable_rights() {
for right in [
LANDLOCK_ACCESS_FS_READ_FILE,
LANDLOCK_ACCESS_FS_WRITE_FILE,
LANDLOCK_ACCESS_FS_EXECUTE,
LANDLOCK_ACCESS_FS_TRUNCATE,
LANDLOCK_ACCESS_FS_IOCTL_DEV,
] {
assert_eq!(
DIRECTORY_ONLY_ACCESS_FS & right,
0,
"file-applicable right {right:#x} must not be directory-only",
);
}
assert_ne!(
DIRECTORY_ONLY_ACCESS_FS & LANDLOCK_ACCESS_FS_READ_DIR,
0,
"READ_DIR must be classified as directory-only",
);
}
#[test]
fn read_only_access_on_a_regular_file_drops_directory_only_bits() {
let masked = read_only_access() & !DIRECTORY_ONLY_ACCESS_FS;
assert_eq!(
masked & LANDLOCK_ACCESS_FS_READ_DIR,
0,
"READ_DIR must be stripped for non-directory rules",
);
assert_ne!(
masked & LANDLOCK_ACCESS_FS_READ_FILE,
0,
"READ_FILE must survive for non-directory rules",
);
assert_ne!(
masked & LANDLOCK_ACCESS_FS_EXECUTE,
0,
"EXECUTE must survive for non-directory rules",
);
}
#[test]
fn landlock_handled_access_tracks_device_ioctl_abi() {
assert_eq!(
landlock_handled_access(4) & LANDLOCK_ACCESS_FS_IOCTL_DEV,
0,
"ABI 4 kernels do not support device ioctl mediation",
);
assert_ne!(
landlock_handled_access(5) & LANDLOCK_ACCESS_FS_IOCTL_DEV,
0,
"ABI 5+ kernels should explicitly mediate device ioctls",
);
}
#[test]
fn proc_runtime_reads_require_restricted_yama_scope() {
for safe in ["1", "2\n", "3"] {
assert!(yama_scope_contains_process_reads(safe), "scope {safe}");
}
for unsafe_or_unknown in ["0", "", "disabled", "256"] {
assert!(
!yama_scope_contains_process_reads(unsafe_or_unknown),
"scope {unsafe_or_unknown} must not grant procfs reads",
);
}
}
fn tree(root: &std::path::Path, names: &[&str]) {
for name in names {
std::fs::create_dir_all(root.join(name)).expect("tree");
}
}
#[test]
fn report_default_denylist_expansion_cost() {
let Some(home) = crate::user_dirs::home_dir() else {
eprintln!("[landlock-cost] no home dir on this host; nothing to measure");
return;
};
let denied: Vec<PathBuf> = crate::orchestration::default_read_deny_home_paths()
.iter()
.map(|relative| home.join(relative))
.collect();
let home = home.canonicalize().unwrap_or(home);
let granted = expand_around_denied(&home, &denied).expect("expand around home");
let entries = std::fs::read_dir(&home).map(|dir| dir.count()).unwrap_or(0);
eprintln!(
"[landlock-cost] home={} home_entries={} denied={} expanded_rules={} cap={}",
home.display(),
entries,
denied.len(),
granted.len(),
MAX_DENY_EXPANSION_RULES,
);
assert!(
granted.len() <= MAX_DENY_EXPANSION_RULES,
"the product default must not trip the cap on a real home: {} rules",
granted.len()
);
assert!(
entries >= 10,
"[landlock-cost] measured '{}' with only {entries} entries, which is not a real home; \
the cap check would pass vacuously. Something rewrote HOME under this test.",
home.display()
);
}
#[test]
fn a_root_with_no_denial_inside_it_is_granted_whole() {
let temp = tempfile::TempDir::new().expect("temp");
let root = temp.path().canonicalize().expect("canonical");
tree(&root, &["a", "b"]);
let unrelated = tempfile::TempDir::new().expect("unrelated");
let unrelated = unrelated.path().canonicalize().expect("canonical");
let granted = expand_around_denied(&root, &[unrelated]).expect("expand");
assert_eq!(
granted,
vec![root],
"a denial that is not inside the root must cost nothing and leave it intact"
);
}
#[test]
fn a_denied_child_is_replaced_by_its_siblings_and_never_granted() {
let temp = tempfile::TempDir::new().expect("temp");
let root = temp.path().canonicalize().expect("canonical");
tree(&root, &["projects", "documents", ".ssh"]);
let denied = root.join(".ssh");
let granted = expand_around_denied(&root, std::slice::from_ref(&denied)).expect("expand");
assert!(
!granted.contains(&root),
"the root itself must NOT be granted; granting it would include the denied \
subtree, which is the whole failure this function exists to prevent: {granted:?}"
);
assert!(
!granted.iter().any(|path| path.starts_with(&denied)),
"no grant may lead into the denied subtree: {granted:?}"
);
assert!(
granted.contains(&root.join("projects")) && granted.contains(&root.join("documents")),
"the siblings must still be reachable, or the subtraction has silently removed \
access the policy granted: {granted:?}"
);
}
#[test]
fn a_nested_denial_keeps_siblings_at_every_level() {
let temp = tempfile::TempDir::new().expect("temp");
let root = temp.path().canonicalize().expect("canonical");
tree(&root, &["keep-me", ".config/gh", ".config/keep-this"]);
let denied = root.join(".config/gh");
let granted = expand_around_denied(&root, std::slice::from_ref(&denied)).expect("expand");
assert!(
granted.contains(&root.join("keep-me")),
"a sibling at the top level must survive: {granted:?}"
);
assert!(
granted.contains(&root.join(".config/keep-this")),
"a sibling INSIDE the denied path's parent must survive, which is what makes this \
a subtraction rather than denying the whole parent: {granted:?}"
);
assert!(
!granted.contains(&denied) && !granted.contains(&root.join(".config")),
"neither the denial nor any ancestor that contains it may be granted: {granted:?}"
);
}
#[test]
fn a_root_that_is_itself_denied_grants_nothing() {
let temp = tempfile::TempDir::new().expect("temp");
let root = temp.path().canonicalize().expect("canonical");
tree(&root, &["inside"]);
let granted = expand_around_denied(&root, std::slice::from_ref(&root)).expect("expand");
assert!(
granted.is_empty(),
"a root that IS the denial must grant nothing at all: {granted:?}"
);
}
#[test]
fn an_unreadable_ancestor_grants_nothing_beneath_it_and_never_itself() {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::TempDir::new().expect("temp");
let root = temp.path().canonicalize().expect("canonical");
tree(&root, &["locked/.ssh", "visible/keep"]);
let locked = root.join("locked");
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).expect("lock dir");
let result = expand_around_denied(&root, &[locked.join(".ssh")]);
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).expect("unlock");
let granted = result.expect("an unreadable ancestor must not refuse the spawn");
assert!(
!granted
.iter()
.any(|path| path == &locked || locked.starts_with(path)),
"granting the unreadable ancestor (or anything above it) would expose the \
subtree we could not enumerate around: {granted:?}"
);
assert!(
granted.contains(&root.join("visible")),
"a sibling outside the unreadable subtree must still be granted: {granted:?}"
);
}
#[test]
fn a_live_landlock_child_is_refused_a_denied_file_and_allowed_its_sibling() {
if !live_landlock_available("landlock-live") {
return;
}
let home = tempfile::TempDir::new().expect("temp home");
let home_path = home.path().canonicalize().expect("canonical home");
let secrets = home_path.join("secrets");
std::fs::create_dir_all(&secrets).expect("secrets dir");
let denied_file = secrets.join("id_ed25519");
std::fs::write(&denied_file, "NOT-A-REAL-KEY\n").expect("dummy key");
let allowed_file = home_path.join("readable.txt");
std::fs::write(&allowed_file, "READABLE\n").expect("control file");
let read_under = |deny: &[String], target: &std::path::Path| -> std::io::Result<bool> {
let policy = CapabilityPolicy {
workspace_roots: vec![home_path.display().to_string()],
sandbox_profile: SandboxProfile::Worktree,
process_sandbox: crate::orchestration::ProcessSandboxPolicy {
read_deny_roots: deny.to_vec(),
..Default::default()
},
..CapabilityPolicy::default()
};
crate::orchestration::push_execution_policy(policy);
let output = crate::stdlib::sandbox::command_output(
"/bin/cat",
&[target.display().to_string()],
&crate::stdlib::sandbox::ProcessCommandConfig::default(),
);
crate::orchestration::pop_execution_policy();
Ok(matches!(output, Ok(out) if out.status.success()))
};
let deny = vec![secrets.display().to_string()];
let control = read_under(&deny, &allowed_file).expect("control read");
assert!(
control,
"the control file inside the same workspace root must be readable, or the denial \
below proves nothing"
);
let denied = read_under(&deny, &denied_file).expect("denied read");
assert!(
!denied,
"a denied file must be refused even though its parent root is granted; it was read"
);
let ungated = read_under(&[], &denied_file).expect("ungated read");
assert!(
ungated,
"with the denial removed the same file must become readable, which is what proves \
the refusal was the denylist and not an unrelated accident"
);
eprintln!("[landlock-live] denied refused, sibling readable, revert readable");
}
#[test]
fn a_denial_under_a_missing_directory_costs_nothing() {
let temp = tempfile::TempDir::new().expect("temp");
let root = temp.path().canonicalize().expect("canonical");
tree(&root, &["present"]);
let granted = expand_around_denied(&root, &[root.join("absent/config")])
.expect("a denial under a missing directory must not refuse the spawn");
assert!(
granted.contains(&root.join("present")),
"the siblings must still be granted: {granted:?}"
);
}
#[test]
fn an_unexpected_enumeration_error_still_refuses_the_spawn() {
let temp = tempfile::TempDir::new().expect("temp");
let root = temp.path().canonicalize().expect("canonical");
std::fs::write(root.join("notadir"), b"x").expect("write file");
let result = expand_around_denied(&root, &[root.join("notadir/inner/secret")]);
assert!(
result.is_err(),
"an enumeration error that is neither missing nor forbidden must fail closed, \
got {result:?}"
);
}
#[test]
fn an_unreadable_optional_root_is_skipped_and_a_required_one_still_fails() {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::TempDir::new().expect("temp");
let root = temp.path().canonicalize().expect("canonical");
let locked = root.join("locked");
std::fs::create_dir_all(&locked).expect("mkdir");
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).expect("lock");
let mut profile = LandlockProfile {
ruleset_fd: -1,
rules: Vec::new(),
handled_access_fs: 0,
read_deny_roots: Vec::new(),
};
let optional = push_rule_exact(
&mut profile,
locked.clone(),
LANDLOCK_ACCESS_FS_READ_FILE,
true,
);
let required = push_rule_exact(
&mut profile,
locked.clone(),
LANDLOCK_ACCESS_FS_READ_FILE,
false,
);
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).expect("unlock");
assert!(
optional.is_ok(),
"an unreadable OPTIONAL root must be skipped, not refuse the spawn: {optional:?}"
);
assert!(
required.is_err(),
"an unreadable REQUIRED root must still fail closed; something asked for it by name"
);
}
#[test]
fn a_confined_child_still_spawns_when_a_preset_root_exists_but_cannot_be_read() {
use std::os::unix::fs::PermissionsExt;
if !live_landlock_available("unreadable-root") {
return;
}
let _env_lock = crate::runtime_paths::test_env_lock()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let home = tempfile::TempDir::new().expect("temp home");
let home_path = home.path().canonicalize().expect("canonical");
let unreadable = home_path.join(".asdf");
std::fs::create_dir_all(unreadable.join("shims")).expect("mkdir");
std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).expect("lock");
let previous_home = std::env::var_os("HOME");
std::env::set_var("HOME", &home_path);
let policy = CapabilityPolicy {
workspace_roots: vec![home_path.display().to_string()],
sandbox_profile: SandboxProfile::Worktree,
..CapabilityPolicy::default()
};
crate::orchestration::push_execution_policy(policy);
let output = crate::stdlib::sandbox::command_output(
"/bin/echo",
&["UNREADABLE-ROOT-PROBE-ALIVE".to_string()],
&crate::stdlib::sandbox::ProcessCommandConfig::default(),
);
crate::orchestration::pop_execution_policy();
std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o755)).expect("unlock");
match previous_home {
Some(value) => std::env::set_var("HOME", value),
None => std::env::remove_var("HOME"),
}
let output = output.expect(
"an unreadable preset root must not refuse the spawn; this is the /root-under-hardened \
control and its failure is the exact outage it exists to catch",
);
assert!(
output.status.success(),
"the confined child must run: {output:?}"
);
assert!(
String::from_utf8_lossy(&output.stdout).contains("UNREADABLE-ROOT-PROBE-ALIVE"),
"the child must actually have executed, not merely exited zero"
);
eprintln!("[unreadable-root] confined child ran with an unreadable preset root present");
}
#[test]
fn an_unenforced_host_skips_with_the_named_reason_when_nothing_requires_it() {
for state in [LiveLandlock::AbsentOnHost, LiveLandlock::DisabledBySelector] {
let decision = landlock_gate(state, false, "probe", "capability,yama");
let LandlockGate::Skip(reason) = decision else {
panic!("an unenforced host must skip, not {decision:?}");
};
assert!(
reason.contains("sandbox boundary not exercised"),
"the skip names what did not happen: {reason}"
);
assert!(
reason.contains("capability,yama"),
"the skip names the host's security modules so the class is identifiable: {reason}"
);
}
}
#[test]
fn a_host_declared_to_enforce_reds_when_it_does_not() {
let decision = landlock_gate(LiveLandlock::AbsentOnHost, true, "probe", "capability,yama");
let LandlockGate::Fail(reason) = decision else {
panic!("a declared-enforcing host must fail, not {decision:?}");
};
assert!(
reason.contains("Landlock unavailable on this host, sandbox boundary not exercised"),
"the failure names the absence verbatim: {reason}"
);
assert!(
reason.contains(REQUIRE_LIVE_LANDLOCK_ENV),
"the failure names the declaration it answers to: {reason}"
);
}
#[test]
fn an_enforcing_host_always_proceeds() {
for required in [true, false] {
assert_eq!(
landlock_gate(LiveLandlock::Enforcing, required, "probe", "landlock"),
LandlockGate::Proceed,
"an enforcing host runs the boundary test, required={required}"
);
}
}
#[test]
fn the_selector_can_disable_enforcement_on_a_landlock_capable_kernel() {
if landlock_abi_version() == 0 {
eprintln!(
"[selector-probe] SKIPPED: no Landlock on this kernel, so the selector cannot be \
the deciding factor. active security modules: {}",
active_lsm_list()
);
return;
}
let guard = handler_sandbox_test_guard();
guard.set("off");
assert_eq!(
LiveLandlock::probe(),
LiveLandlock::DisabledBySelector,
"a disabled selector must not read as an enforcing host"
);
drop(guard);
let _restored = handler_sandbox_test_guard();
assert_eq!(
LiveLandlock::probe(),
LiveLandlock::Enforcing,
"the default selector enforces on a Landlock-capable kernel"
);
}