#[cfg(all(test, target_os = "linux"))]
mod tests {
use anyhow::Result;
use nix::sched::{unshare, CloneFlags};
use nix::sys::signal::Signal;
use nix::sys::wait::{waitpid, WaitStatus};
use nix::unistd::{fork, ForkResult};
use std::fs::File;
use std::io::Write;
use tempfile::tempdir;
use smith_jailer::landlock::{self, LandlockConfig};
use smith_jailer::seccomp::{self, SeccompConfig};
fn run_in_subprocess<F>(f: F) -> Result<WaitStatus>
where
F: FnOnce() -> Result<(), i32>,
{
match unsafe { fork()? } {
ForkResult::Child => {
let exit_code = match f() {
Ok(()) => 0,
Err(code) => code,
};
std::process::exit(exit_code);
}
ForkResult::Parent { child } => Ok(waitpid(child, None)?),
}
}
fn require_root(reason: &str) -> bool {
if unsafe { libc::geteuid() == 0 } {
true
} else {
eprintln!("Skipping test ({reason}): requires root privileges");
false
}
}
#[test]
fn landlock_blocks_forbidden_paths() -> Result<()> {
if !landlock::is_landlock_available() {
eprintln!("Skipping Landlock test: kernel does not support Landlock");
return Ok(());
}
if !require_root("Landlock needs CAP_SYS_ADMIN for path setup") {
return Ok(());
}
let allowed = tempdir()?;
let forbidden = tempdir()?;
let allowed_file = allowed.path().join("allowed.txt");
let forbidden_file = forbidden.path().join("forbidden.txt");
File::create(&allowed_file)?.write_all(b"allowed")?;
File::create(&forbidden_file)?.write_all(b"forbidden")?;
let status = run_in_subprocess(|| {
let mut config = LandlockConfig::default();
let allowed_path = allowed
.path()
.to_str()
.expect("tempdir path should be UTF-8");
config.allow_read(allowed_path);
config.allow_read("/proc");
config.allow_read("/etc");
config.allow_read("/run");
config.allow_read("/tmp");
landlock::apply_landlock_rules(&config).expect("apply landlock");
if std::fs::read(&allowed_file).is_err() {
eprintln!("Landlock unexpectedly denied allowed read");
return Err(3);
}
match std::fs::read(&forbidden_file) {
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => Ok(()),
Ok(_) => {
eprintln!("Landlock unexpectedly allowed forbidden read");
Err(1)
}
Err(err) => {
eprintln!("Unexpected error reading forbidden file: {err}");
Err(2)
}
}
})?;
match status {
WaitStatus::Exited(_, code) => assert_eq!(code, 0, "Landlock child exited with {code}"),
other => panic!("Unexpected child status: {other:?}"),
}
Ok(())
}
#[test]
fn seccomp_traps_disallowed_syscalls() -> Result<()> {
let status = run_in_subprocess(|| {
let mut config = SeccompConfig::default();
config.allow_syscalls(&[
libc::SYS_read as i32,
libc::SYS_write as i32,
libc::SYS_exit as i32,
libc::SYS_exit_group as i32,
]);
seccomp::apply_seccomp_filter(&config).expect("apply seccomp");
unsafe {
libc::syscall(libc::SYS_getpid);
}
Err(1)
})?;
match status {
WaitStatus::Signaled(_, Signal::SIGSYS, _) => Ok(()),
WaitStatus::Exited(_, code) if code == 77 => {
Ok(())
}
other => panic!("Seccomp filter did not trigger as expected: {other:?}"),
}
}
#[test]
fn network_namespace_blocks_outbound_connections() -> Result<()> {
if !require_root("creating a network namespace") {
return Ok(());
}
let status = run_in_subprocess(|| {
unshare(CloneFlags::CLONE_NEWNET).expect("unshare network namespace");
match std::net::TcpStream::connect("127.0.0.1:80") {
Err(err)
if matches!(
err.kind(),
std::io::ErrorKind::ConnectionRefused
| std::io::ErrorKind::NetworkUnreachable
| std::io::ErrorKind::AddrNotAvailable
) =>
{
Ok(())
}
Err(err) => {
eprintln!("Unexpected network error: {err}");
Err(1)
}
Ok(_) => {
eprintln!("Connection succeeded unexpectedly inside isolated namespace");
Err(2)
}
}
})?;
match status {
WaitStatus::Exited(_, code) => {
assert_eq!(code, 0, "Network namespace child exited with {code}")
}
other => panic!("Unexpected child status: {other:?}"),
}
Ok(())
}
}