use pelagos::cgroup::ResourceStats;
use pelagos::container::{
Capability, Command, GidMap, Namespace, SeccompProfile, Stdio, UidMap, Volume,
};
use pelagos::network::NetworkMode;
use serial_test::serial;
use std::path::PathBuf;
fn is_root() -> bool {
unsafe { libc::getuid() == 0 }
}
fn get_test_rootfs() -> Option<PathBuf> {
let current_dir = std::env::current_dir().ok()?;
let alpine_path = current_dir.join("alpine-rootfs");
if alpine_path.exists() && alpine_path.join("bin/busybox").exists() {
Some(alpine_path)
} else {
None
}
}
const ALPINE_PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
mod api {
use super::*;
#[test]
fn test_uid_gid_api() {
let _cmd = Command::new("/bin/ash")
.with_uid(1000)
.with_gid(1000)
.with_uid_maps(&[UidMap {
inside: 0,
outside: 1000,
count: 1,
}])
.with_gid_maps(&[GidMap {
inside: 0,
outside: 1000,
count: 1,
}]);
}
#[test]
fn test_namespace_bitflags() {
let ns1 = Namespace::UTS;
let ns2 = Namespace::MOUNT;
let combined = ns1 | ns2;
assert!(combined.contains(Namespace::UTS));
assert!(combined.contains(Namespace::MOUNT));
assert!(!combined.contains(Namespace::PID));
}
#[test]
fn test_capability_bitflags() {
let cap1 = Capability::CHOWN;
let cap2 = Capability::NET_BIND_SERVICE;
let combined = cap1 | cap2;
assert!(combined.contains(Capability::CHOWN));
assert!(combined.contains(Capability::NET_BIND_SERVICE));
assert!(!combined.contains(Capability::SYS_ADMIN));
}
#[test]
fn test_command_builder_pattern() {
let rootfs = PathBuf::from("/tmp/test");
let _cmd = Command::new("/bin/ash")
.args(["-c", "echo test", "-x"])
.stdin(Stdio::Inherit)
.stdout(Stdio::Piped)
.stderr(Stdio::Null)
.with_namespaces(Namespace::UTS)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_proc_mount()
.with_max_fds(1024);
}
#[test]
fn test_seccomp_profile_api() {
let rootfs = PathBuf::from("/tmp/test");
let _cmd1 = Command::new("/bin/sh")
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_seccomp_default();
let _cmd2 = Command::new("/bin/sh")
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_seccomp_minimal();
let _cmd3 = Command::new("/bin/sh")
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_seccomp_profile(SeccompProfile::Docker);
let _cmd4 = Command::new("/bin/sh")
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.without_seccomp();
}
}
mod core {
use super::*;
#[test]
fn test_basic_namespace_creation() {
if !is_root() {
eprintln!("Skipping test_basic_namespace_creation: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_basic_namespace_creation: alpine-rootfs not found");
return;
};
let result = Command::new("/bin/ash")
.args(["-c", "exit 0"])
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn();
match result {
Ok(mut child) => {
let status = child.wait().unwrap();
assert!(status.success(), "Child process failed");
}
Err(e) => {
panic!("Failed to spawn with namespaces: {:?}", e);
}
}
}
#[test]
fn test_proc_mount() {
if !is_root() {
eprintln!("Skipping test_proc_mount: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test: alpine-rootfs not found");
return;
};
let result = Command::new("/bin/ash")
.args(["-c", "test -f /proc/self/status"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_proc_mount()
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn();
match result {
Ok(mut child) => {
let status = child.wait().unwrap();
assert!(status.success(), "Proc was not mounted correctly");
}
Err(e) => panic!("Failed to spawn with proc mount: {:?}", e),
}
}
#[test]
fn test_combined_features() {
if !is_root() {
eprintln!("Skipping test_combined_features: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test: alpine-rootfs not found");
return;
};
let result = Command::new("/bin/ash")
.args(["-c", "exit 0"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::CGROUP)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_proc_mount()
.with_capabilities(Capability::NET_BIND_SERVICE)
.with_max_fds(500)
.with_memory_limit(256 * 1024 * 1024)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn();
match result {
Ok(mut child) => {
let status = child.wait().unwrap();
assert!(
status.success(),
"Child process failed with combined features"
);
}
Err(e) => panic!("Failed to spawn with combined features: {:?}", e),
}
}
#[test]
#[serial]
fn test_pid_namespace_repeated_fork() {
if !is_root() {
eprintln!("Skipping test_pid_namespace_repeated_fork: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_pid_namespace_repeated_fork: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/sh")
.args([
"-c",
r#"i=0; while [ $i -lt 5 ]; do sleep 0; i=$((i+1)); done; echo "FORKS_OK""#,
])
.with_chroot(&rootfs)
.with_namespaces(Namespace::UTS | Namespace::MOUNT | Namespace::PID)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("Failed to spawn with PID namespace");
let (status, stdout, stderr) = child.wait_with_output().expect("wait failed");
let out = String::from_utf8_lossy(&stdout);
let err = String::from_utf8_lossy(&stderr);
assert!(
status.success(),
"Container with PID namespace failed (exit {:?}).\nstdout: {}\nstderr: {}",
status.code(),
out,
err
);
assert!(
out.contains("FORKS_OK"),
"Container could not fork() repeatedly in PID namespace (defunct namespace bug).\nstdout: {}\nstderr: {}",
out, err
);
}
}
mod capabilities {
use super::*;
#[test]
fn test_capability_dropping() {
if !is_root() {
eprintln!("Skipping test_capability_dropping: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test: alpine-rootfs not found");
return;
};
let result = Command::new("/bin/ash")
.args(["-c", "exit 0"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.drop_all_capabilities()
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn();
match result {
Ok(mut child) => {
let status = child.wait().unwrap();
assert!(status.success(), "Child process failed with dropped caps");
}
Err(e) => panic!("Failed to spawn with dropped capabilities: {:?}", e),
}
}
#[test]
fn test_selective_capabilities() {
if !is_root() {
eprintln!("Skipping test_selective_capabilities: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test: alpine-rootfs not found");
return;
};
let result = Command::new("/bin/ash")
.args(["-c", "exit 0"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_capabilities(Capability::NET_BIND_SERVICE | Capability::CHOWN)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn();
match result {
Ok(mut child) => {
let status = child.wait().unwrap();
assert!(status.success(), "Child process failed with selective caps");
}
Err(e) => panic!("Failed to spawn with selective capabilities: {:?}", e),
}
}
}
mod resources {
use super::*;
#[test]
fn test_resource_limits_fds() {
if !is_root() {
eprintln!("Skipping test_resource_limits_fds: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test: alpine-rootfs not found");
return;
};
let result = Command::new("/bin/ash")
.args(["-c", "test \"$(ulimit -n)\" = 100"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_max_fds(100)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn();
match result {
Ok(mut child) => {
let status = child.wait().unwrap();
assert!(status.success(), "FD limit was not set correctly");
}
Err(e) => panic!("Failed to spawn with fd limit: {:?}", e),
}
}
#[test]
fn test_resource_limits_memory() {
if !is_root() {
eprintln!("Skipping test_resource_limits_memory: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test: alpine-rootfs not found");
return;
};
let result = Command::new("/bin/ash")
.args(["-c", "exit 0"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_memory_limit(512 * 1024 * 1024) .stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn();
match result {
Ok(mut child) => {
let status = child.wait().unwrap();
assert!(status.success(), "Child process failed with memory limit");
}
Err(e) => panic!("Failed to spawn with memory limit: {:?}", e),
}
}
#[test]
fn test_resource_limits_cpu() {
if !is_root() {
eprintln!("Skipping test_resource_limits_cpu: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test: alpine-rootfs not found");
return;
};
let result = Command::new("/bin/ash")
.args(["-c", "exit 0"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_cpu_time_limit(60) .stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn();
match result {
Ok(mut child) => {
let status = child.wait().unwrap();
assert!(status.success(), "Child process failed with CPU limit");
}
Err(e) => panic!("Failed to spawn with CPU time limit: {:?}", e),
}
}
}
mod security {
use super::*;
#[test]
fn test_seccomp_docker_blocks_reboot() {
if !is_root() {
eprintln!("Skipping test_seccomp_docker_blocks_reboot: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_seccomp_docker_blocks_reboot: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "reboot 2>&1; echo reboot_exit_code=$?"])
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_proc_mount()
.with_seccomp_default() .spawn()
.expect("Failed to spawn with seccomp");
let status = child.wait().expect("Failed to wait for child");
assert!(
status.success() || status.code() == Some(1),
"Process should complete (reboot syscall blocked by seccomp)"
);
}
#[test]
fn test_seccomp_docker_allows_normal_syscalls() {
if !is_root() {
eprintln!("Skipping test_seccomp_docker_allows_normal_syscalls: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!(
"Skipping test_seccomp_docker_allows_normal_syscalls: alpine-rootfs not found"
);
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "echo 'Seccomp allows normal operations'"])
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_proc_mount()
.with_seccomp_default() .spawn()
.expect("Failed to spawn with seccomp");
let status = child.wait().expect("Failed to wait for child");
assert!(status.success(), "Normal syscalls should be allowed");
}
#[test]
fn test_seccomp_minimal_is_restrictive() {
if !is_root() {
eprintln!("Skipping test_seccomp_minimal_is_restrictive: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_seccomp_minimal_is_restrictive: alpine-rootfs not found");
return;
};
let result = Command::new("/bin/ash")
.args(["-c", "exit 0"])
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_proc_mount()
.with_seccomp_minimal() .spawn();
match result {
Ok(mut child) => {
let status = child.wait().expect("Failed to wait for child");
eprintln!("Minimal seccomp: process exited with status {:?}", status);
}
Err(e) => {
eprintln!("Minimal seccomp: spawn failed (expected): {}", e);
}
}
}
#[test]
fn test_seccomp_without_flag_works() {
if !is_root() {
eprintln!("Skipping test_seccomp_without_flag_works: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_seccomp_without_flag_works: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "echo 'No seccomp'"])
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_proc_mount()
.spawn()
.expect("Failed to spawn without seccomp");
let status = child.wait().expect("Failed to wait for child");
assert!(status.success(), "Container should work without seccomp");
}
#[test]
fn test_no_new_privileges() {
if !is_root() {
eprintln!("Skipping test_no_new_privileges: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_no_new_privileges: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "/bin/grep 'NoNewPrivs:.*1' /proc/self/status"])
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_proc_mount()
.with_no_new_privileges(true)
.spawn()
.expect("Failed to spawn with no-new-privileges");
let status = child.wait().expect("Failed to wait for child");
assert!(
status.success(),
"NoNewPrivs should be set to 1 in /proc/self/status"
);
}
#[test]
fn test_readonly_rootfs() {
if !is_root() {
eprintln!("Skipping test_readonly_rootfs: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_readonly_rootfs: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "touch /test_file 2>&1; echo exit_code=$?"])
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_proc_mount()
.with_readonly_rootfs(true)
.spawn()
.expect("Failed to spawn with read-only rootfs");
let status = child.wait().expect("Failed to wait for child");
assert!(
status.success(),
"Container should run despite read-only fs"
);
}
#[test]
fn test_masked_paths_default() {
if !is_root() {
eprintln!("Skipping test_masked_paths_default: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_masked_paths_default: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "cat /proc/kcore 2>&1 | head -c 10 || echo 'masked'"])
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_proc_mount()
.with_masked_paths_default()
.spawn()
.expect("Failed to spawn with masked paths");
let status = child.wait().expect("Failed to wait for child");
assert!(status.success(), "Masked paths should not cause failures");
}
#[test]
fn test_masked_paths_custom() {
if !is_root() {
eprintln!("Skipping test_masked_paths_custom: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_masked_paths_custom: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "echo 'Custom masked paths test'"])
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_proc_mount()
.with_masked_paths(&["/proc/kcore", "/sys/firmware"])
.spawn()
.expect("Failed to spawn with custom masked paths");
let status = child.wait().expect("Failed to wait for child");
assert!(status.success(), "Custom masked paths should work");
}
#[test]
fn test_combined_phase1_security() {
if !is_root() {
eprintln!("Skipping test_combined_phase1_security: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_combined_phase1_security: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "echo 'All Phase 1 security features enabled'"])
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_proc_mount()
.with_seccomp_default() .with_no_new_privileges(true) .with_readonly_rootfs(true) .with_masked_paths_default() .drop_all_capabilities() .spawn()
.expect("Failed to spawn with all Phase 1 security");
let status = child.wait().expect("Failed to wait for child");
assert!(
status.success(),
"Container with all Phase 1 security should work"
);
}
#[test]
fn test_landlock_read_only_allows_read() {
use pelagos::landlock::get_abi_version;
if !is_root() {
eprintln!("Skipping test_landlock_read_only_allows_read: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_landlock_read_only_allows_read: alpine-rootfs not found");
return;
};
if get_abi_version() == 0 {
eprintln!("Skipping test_landlock_read_only_allows_read: kernel < 5.13, no Landlock");
return;
}
let mut child = Command::new("/bin/cat")
.args(["/etc/hostname"])
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_proc_mount()
.with_no_new_privileges(true)
.with_landlock_ro("/")
.spawn()
.expect("spawn failed");
let (status, _stdout, stderr) = child.wait_with_output().expect("wait failed");
assert!(
status.success(),
"read under landlock_ro(/) failed: stderr={}",
String::from_utf8_lossy(&stderr)
);
}
#[test]
fn test_landlock_denies_write() {
use pelagos::landlock::get_abi_version;
if !is_root() {
eprintln!("Skipping test_landlock_denies_write: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_landlock_denies_write: alpine-rootfs not found");
return;
};
if get_abi_version() == 0 {
eprintln!("Skipping test_landlock_denies_write: kernel < 5.13, no Landlock");
return;
}
let mut child = Command::new("/bin/sh")
.args(["-c", "touch /tmp/landlock_test; echo exit=$?"])
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_proc_mount()
.with_tmpfs("/tmp", "")
.with_no_new_privileges(true)
.with_landlock_ro("/")
.spawn()
.expect("spawn failed");
let (status, stdout_bytes, stderr_bytes) = child.wait_with_output().expect("wait failed");
let stdout = String::from_utf8_lossy(&stdout_bytes);
assert!(
stdout.contains("exit=1") || !status.success(),
"write should be denied under landlock_ro(/), got: stdout={} stderr={}",
stdout,
String::from_utf8_lossy(&stderr_bytes)
);
}
#[test]
fn test_landlock_rw_allows_write() {
use pelagos::landlock::get_abi_version;
if !is_root() {
eprintln!("Skipping test_landlock_rw_allows_write: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_landlock_rw_allows_write: alpine-rootfs not found");
return;
};
if get_abi_version() == 0 {
eprintln!("Skipping test_landlock_rw_allows_write: kernel < 5.13, no Landlock");
return;
}
let mut child = Command::new("/bin/sh")
.args(["-c", "touch /tmp/landlock_rw_test && echo ok"])
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_proc_mount()
.with_tmpfs("/tmp", "")
.with_no_new_privileges(true)
.with_landlock_rw("/")
.spawn()
.expect("spawn failed");
let (_status, stdout_bytes, stderr_bytes) = child.wait_with_output().expect("wait failed");
let stdout = String::from_utf8_lossy(&stdout_bytes);
assert!(
stdout.contains("ok"),
"write under landlock_rw(/) should succeed: stdout={} stderr={}",
stdout,
String::from_utf8_lossy(&stderr_bytes)
);
}
#[test]
fn test_landlock_no_rules_no_effect() {
if !is_root() {
eprintln!("Skipping test_landlock_no_rules_no_effect: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_landlock_no_rules_no_effect: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/sh")
.args(["-c", "cat /etc/hostname && touch /tmp/noll && echo ok"])
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_proc_mount()
.with_tmpfs("/tmp", "")
.spawn()
.expect("spawn failed");
let (_status, stdout_bytes, stderr_bytes) = child.wait_with_output().expect("wait failed");
let stdout = String::from_utf8_lossy(&stdout_bytes);
assert!(
stdout.contains("ok"),
"no-rules container should read and write freely: stdout={} stderr={}",
stdout,
String::from_utf8_lossy(&stderr_bytes)
);
}
#[test]
fn test_landlock_partial_path_allow() {
use pelagos::landlock::get_abi_version;
if !is_root() {
eprintln!("Skipping test_landlock_partial_path_allow: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_landlock_partial_path_allow: alpine-rootfs not found");
return;
};
if get_abi_version() == 0 {
eprintln!("Skipping test_landlock_partial_path_allow: kernel < 5.13");
return;
}
let mut child = Command::new("/bin/sh")
.args([
"-c",
"cat /etc/hostname && touch /tmp/partial_test; echo write_exit=$?",
])
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_proc_mount()
.with_tmpfs("/tmp", "")
.with_no_new_privileges(true)
.with_landlock_ro("/etc")
.with_landlock_ro("/bin")
.with_landlock_ro("/lib")
.with_landlock_ro("/usr")
.spawn()
.expect("spawn failed");
let (_status, stdout_bytes, stderr_bytes) = child.wait_with_output().expect("wait failed");
let stdout = String::from_utf8_lossy(&stdout_bytes);
assert!(
stdout.contains("write_exit=1"),
"write to /tmp should be denied when only /etc has landlock_ro: stdout={} stderr={}",
stdout,
String::from_utf8_lossy(&stderr_bytes)
);
}
}
mod user_notif {
use super::*;
use pelagos::notif::{SyscallHandler, SyscallNotif, SyscallResponse};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
struct CountingAllow {
count: Arc<AtomicUsize>,
}
impl SyscallHandler for CountingAllow {
fn handle(&self, _n: &SyscallNotif) -> SyscallResponse {
self.count.fetch_add(1, Ordering::Relaxed);
SyscallResponse::Allow
}
}
struct DenyAll;
impl SyscallHandler for DenyAll {
fn handle(&self, _n: &SyscallNotif) -> SyscallResponse {
SyscallResponse::Deny(libc::EPERM)
}
}
#[test]
fn test_user_notif_handler_invoked() {
if !is_root() {
eprintln!("Skipping test_user_notif_handler_invoked: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_user_notif_handler_invoked: alpine-rootfs not found");
return;
};
let count = Arc::new(AtomicUsize::new(0));
let handler = CountingAllow {
count: count.clone(),
};
let mut child = Command::new("/usr/bin/id")
.args(["-u"])
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_proc_mount()
.with_seccomp_user_notif(vec![libc::SYS_getuid], handler)
.spawn()
.expect("spawn failed");
let (status, stdout_bytes, _) = child.wait_with_output().expect("wait failed");
let stdout = String::from_utf8_lossy(&stdout_bytes);
assert!(
status.success(),
"id -u should succeed when getuid is allowed: stdout={}",
stdout
);
assert!(
stdout.trim() == "0",
"expected uid 0, got: {}",
stdout.trim()
);
assert!(
count.load(Ordering::Relaxed) >= 1,
"handler should have been called at least once for getuid"
);
}
#[test]
fn test_user_notif_deny_syscall() {
if !is_root() {
eprintln!("Skipping test_user_notif_deny_syscall: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_user_notif_deny_syscall: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/sh")
.args(["-c", "touch /tmp/x && chmod 700 /tmp/x; echo exit=$?"])
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_proc_mount()
.with_tmpfs("/tmp", "")
.with_seccomp_user_notif(vec![libc::SYS_chmod], DenyAll)
.spawn()
.expect("spawn failed");
let (_status, stdout_bytes, _) = child.wait_with_output().expect("wait failed");
let stdout = String::from_utf8_lossy(&stdout_bytes);
assert!(
stdout.contains("exit=1"),
"chmod should fail (EPERM) when fchmodat is denied by supervisor: stdout={}",
stdout
);
}
#[test]
fn test_user_notif_allow_passthrough() {
if !is_root() {
eprintln!("Skipping test_user_notif_allow_passthrough: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_user_notif_allow_passthrough: alpine-rootfs not found");
return;
};
let count = Arc::new(AtomicUsize::new(0));
let handler = CountingAllow {
count: count.clone(),
};
let mut child = Command::new("/bin/sh")
.args(["-c", "touch /tmp/x && chmod 700 /tmp/x; echo exit=$?"])
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_proc_mount()
.with_tmpfs("/tmp", "")
.with_seccomp_user_notif(vec![libc::SYS_chmod], handler)
.spawn()
.expect("spawn failed");
let (_status, stdout_bytes, _) = child.wait_with_output().expect("wait failed");
let stdout = String::from_utf8_lossy(&stdout_bytes);
assert!(
stdout.contains("exit=0"),
"chmod should succeed when fchmodat is allowed by supervisor: stdout={}",
stdout
);
assert!(
count.load(Ordering::Relaxed) >= 1,
"handler should have been called at least once for fchmodat"
);
}
}
mod filesystem {
use super::*;
#[test]
fn test_bind_mount_rw() {
if !is_root() {
eprintln!("Skipping test_bind_mount_rw: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_bind_mount_rw: alpine-rootfs not found");
return;
};
let host_dir = tempfile::tempdir().expect("failed to create temp dir");
std::fs::write(host_dir.path().join("hello.txt"), b"hello from host")
.expect("failed to write host file");
let mut child = Command::new("/bin/ash")
.args(["-c", "cat /mnt/hostdir/hello.txt"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_bind_mount(host_dir.path(), "/mnt/hostdir")
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("Failed to spawn with bind mount");
let status = child.wait().expect("Failed to wait for child");
assert!(
status.success(),
"Container should read host file via bind mount"
);
}
#[test]
fn test_bind_mount_ro() {
if !is_root() {
eprintln!("Skipping test_bind_mount_ro: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_bind_mount_ro: alpine-rootfs not found");
return;
};
let host_dir = tempfile::tempdir().expect("failed to create temp dir");
let mut child = Command::new("/bin/ash")
.args(["-c", "touch /mnt/ro/newfile 2>/dev/null; echo exit=$?"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_bind_mount_ro(host_dir.path(), "/mnt/ro")
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("Failed to spawn with read-only bind mount");
let (status, stdout, _) = child.wait_with_output().expect("Failed to collect output");
assert!(status.success(), "Shell should exit cleanly");
let out = String::from_utf8_lossy(&stdout);
assert!(
out.contains("exit=1"),
"Write to read-only bind mount should fail, got: {}",
out
);
}
#[test]
fn test_cli_volume_flag_ro() {
if !is_root() {
eprintln!("Skipping test_cli_volume_flag_ro: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_cli_volume_flag_ro: alpine-rootfs not found");
return;
};
let host_dir = tempfile::tempdir().expect("temp dir");
let vol_spec = format!("{}:/mnt/ro:ro", host_dir.path().display());
let out = std::process::Command::new(env!("CARGO_BIN_EXE_remora"))
.args([
"run",
"--rootfs",
rootfs.to_str().unwrap(),
"-v",
&vol_spec,
"/bin/ash",
"-c",
"touch /mnt/ro/x 2>/dev/null; echo exit=$?",
])
.output()
.expect("remora run");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("exit=1"),
"Write into :ro mount should fail (exit=1), got: {}",
stdout
);
let rw_spec = format!("{}:/mnt/rw:rw", host_dir.path().display());
let out2 = std::process::Command::new(env!("CARGO_BIN_EXE_remora"))
.args([
"run",
"--rootfs",
rootfs.to_str().unwrap(),
"-v",
&rw_spec,
"/bin/ash",
"-c",
"touch /mnt/rw/x 2>/dev/null; echo exit=$?",
])
.output()
.expect("remora run rw");
let stdout2 = String::from_utf8_lossy(&out2.stdout);
assert!(
stdout2.contains("exit=0"),
"Write into :rw mount should succeed (exit=0), got: {}",
stdout2
);
}
#[test]
fn test_tmpfs_mount() {
if !is_root() {
eprintln!("Skipping test_tmpfs_mount: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_tmpfs_mount: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "touch /tmp/testfile && echo ok"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_readonly_rootfs(true)
.with_tmpfs("/tmp", "size=10m,mode=1777")
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("Failed to spawn with tmpfs mount");
let (status, stdout, _) = child.wait_with_output().expect("Failed to collect output");
assert!(status.success(), "Container should succeed with tmpfs /tmp");
let out = String::from_utf8_lossy(&stdout);
assert!(
out.contains("ok"),
"touch on tmpfs should succeed, got: {}",
out
);
}
#[test]
fn test_named_volume() {
if !is_root() {
eprintln!("Skipping test_named_volume: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_named_volume: alpine-rootfs not found");
return;
};
let _ = Volume::delete("testvol");
let vol = Volume::create("testvol").expect("Failed to create volume");
let mut child = Command::new("/bin/ash")
.args(["-c", "echo persistent > /data/file.txt"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_volume(&vol, "/data")
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn with named volume");
let status = child.wait().expect("Failed to wait for child");
assert!(status.success(), "Container should write to volume");
let host_file = vol.path().join("file.txt");
assert!(
host_file.exists(),
"Volume file should exist on host after container exits"
);
let contents = std::fs::read_to_string(&host_file).expect("Failed to read volume file");
assert!(
contents.contains("persistent"),
"Volume file should contain expected content"
);
Volume::delete("testvol").expect("Failed to delete volume");
}
#[test]
fn test_overlay_writes_to_upper() {
if !is_root() {
eprintln!("Skipping test_overlay_writes_to_upper: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_overlay_writes_to_upper: alpine-rootfs not found");
return;
}
};
let scratch = tempfile::tempdir().expect("failed to create tempdir");
let upper = scratch.path().join("upper");
let work = scratch.path().join("work");
std::fs::create_dir_all(&upper).unwrap();
std::fs::create_dir_all(&work).unwrap();
let mut child = Command::new("/bin/sh")
.args(["-c", "echo hello > /newfile"])
.with_chroot(&rootfs)
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_overlay(&upper, &work)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("failed to spawn overlay container");
child.wait().expect("failed to wait");
assert!(
!rootfs.join("newfile").exists(),
"lower dir (alpine-rootfs) should not contain newfile — overlay leaked write to lower"
);
let upper_file = upper.join("newfile");
assert!(
upper_file.exists(),
"upper_dir/newfile should exist after container wrote /newfile"
);
let content = std::fs::read_to_string(&upper_file).expect("failed to read upper/newfile");
assert_eq!(
content, "hello\n",
"upper_dir/newfile should contain 'hello\\n'"
);
}
#[test]
fn test_overlay_with_volume() {
if !is_root() {
eprintln!("Skipping test_overlay_with_volume: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_overlay_with_volume: alpine-rootfs not found");
return;
}
};
let scratch = tempfile::tempdir().expect("failed to create tempdir");
let upper = scratch.path().join("upper");
let work = scratch.path().join("work");
std::fs::create_dir_all(&upper).unwrap();
std::fs::create_dir_all(&work).unwrap();
let _ = Volume::delete("test_ov_vol");
let vol = Volume::create("test_ov_vol").expect("failed to create volume");
let mut child = Command::new("/bin/ash")
.args([
"-c",
"echo vol_data > /data/vol_file.txt && echo overlay_data > /overlay_file.txt",
])
.with_chroot(&rootfs)
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_overlay(&upper, &work)
.with_volume(&vol, "/data")
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("failed to spawn overlay+volume container");
let status = child.wait().expect("failed to wait");
assert!(status.success(), "container should exit successfully");
let vol_file = vol.path().join("vol_file.txt");
assert!(
vol_file.exists(),
"volume file should exist on host after container exits"
);
let vol_contents = std::fs::read_to_string(&vol_file).expect("failed to read volume file");
assert_eq!(
vol_contents, "vol_data\n",
"volume file has expected content"
);
assert!(
!rootfs.join("overlay_file.txt").exists(),
"rootfs (lower layer) should not contain overlay_file.txt"
);
assert!(
upper.join("overlay_file.txt").exists(),
"overlay upper dir should contain overlay_file.txt"
);
assert!(
!upper.join("data/vol_file.txt").exists(),
"volume writes should not appear in overlay upper dir"
);
Volume::delete("test_ov_vol").expect("failed to delete volume");
}
#[test]
fn test_overlay_lower_unchanged() {
if !is_root() {
eprintln!("Skipping test_overlay_lower_unchanged: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_overlay_lower_unchanged: alpine-rootfs not found");
return;
}
};
let scratch = tempfile::tempdir().expect("failed to create tempdir");
let upper = scratch.path().join("upper");
let work = scratch.path().join("work");
std::fs::create_dir_all(&upper).unwrap();
std::fs::create_dir_all(&work).unwrap();
let lower_hostname = rootfs.join("etc/hostname");
let original_content =
std::fs::read_to_string(&lower_hostname).unwrap_or_else(|_| String::new());
let mut child = Command::new("/bin/sh")
.args(["-c", "echo modified > /etc/hostname"])
.with_chroot(&rootfs)
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_overlay(&upper, &work)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("failed to spawn overlay container");
child.wait().expect("failed to wait");
let after_content =
std::fs::read_to_string(&lower_hostname).unwrap_or_else(|_| String::new());
assert_eq!(
original_content, after_content,
"lower_dir/etc/hostname should be unchanged; overlay leaked write to lower"
);
let upper_hostname = upper.join("etc/hostname");
assert!(
upper_hostname.exists(),
"upper_dir/etc/hostname should exist (copy-on-write)"
);
let upper_content =
std::fs::read_to_string(&upper_hostname).expect("failed to read upper/etc/hostname");
assert_eq!(
upper_content, "modified\n",
"upper_dir/etc/hostname should contain 'modified\\n'"
);
}
#[test]
fn test_overlay_merged_cleanup() {
if !is_root() {
eprintln!("Skipping test_overlay_merged_cleanup: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_overlay_merged_cleanup: alpine-rootfs not found");
return;
}
};
let scratch = tempfile::tempdir().expect("failed to create tempdir");
let upper = scratch.path().join("upper");
let work = scratch.path().join("work");
std::fs::create_dir_all(&upper).unwrap();
std::fs::create_dir_all(&work).unwrap();
let mut child = Command::new("/bin/sh")
.args(["-c", "true"])
.with_chroot(&rootfs)
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_overlay(&upper, &work)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("failed to spawn overlay container");
let merged_dir = child.overlay_merged_dir().map(|p| p.to_path_buf());
assert!(
merged_dir.is_some(),
"with_overlay should set overlay_merged_dir on Child"
);
let merged = merged_dir.unwrap();
let parent = merged.parent().unwrap().to_path_buf();
assert!(merged.exists(), "merged dir should exist before wait()");
child.wait().expect("failed to wait");
assert!(
!merged.exists(),
"overlay merged dir should be removed after wait(); still present: {}",
merged.display()
);
assert!(
!parent.exists(),
"overlay parent dir should be removed after wait(); still present: {}",
parent.display()
);
}
}
mod cgroups {
use super::*;
#[test]
fn test_cgroup_memory_limit() {
if !is_root() {
eprintln!("Skipping test_cgroup_memory_limit: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_cgroup_memory_limit: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "dd if=/dev/urandom of=/dev/null bs=1M count=64"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_cgroup_memory(32 * 1024 * 1024) .stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn with cgroup memory limit");
let status = child.wait().expect("Failed to wait for child");
let _ = status;
}
#[test]
fn test_cgroup_pids_limit() {
if !is_root() {
eprintln!("Skipping test_cgroup_pids_limit: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_cgroup_pids_limit: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args([
"-c",
"for i in 1 2 3 4 5 6 7 8 9 10; do sleep 0 & done; wait; echo done",
])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_cgroup_pids_limit(4)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn with cgroup pids limit");
let status = child.wait().expect("Failed to wait for child");
let _ = status;
}
#[test]
fn test_cgroup_cpu_shares() {
if !is_root() {
eprintln!("Skipping test_cgroup_cpu_shares: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_cgroup_cpu_shares: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "echo ok"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_cgroup_cpu_shares(512)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn with cgroup cpu shares");
let status = child.wait().expect("Failed to wait for child");
assert!(
status.success(),
"Container with cpu_shares should exit cleanly"
);
}
#[test]
fn test_resource_stats() {
if !is_root() {
eprintln!("Skipping test_resource_stats: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_resource_stats: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "echo hello"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_cgroup_memory(128 * 1024 * 1024)
.with_cgroup_pids_limit(64)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn for resource_stats test");
let stats: ResourceStats = child.resource_stats().expect("resource_stats() failed");
let _ = stats.memory_current_bytes;
let _ = stats.cpu_usage_ns;
let _ = stats.pids_current;
child.wait().expect("Failed to wait for child");
}
#[test]
fn test_cgroup_cleanup() {
if !is_root() {
eprintln!("Skipping test_cgroup_cleanup: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_cgroup_cleanup: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "exit 0"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_cgroup_memory(64 * 1024 * 1024)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn for cgroup cleanup test");
let pid = child.pid();
child.wait().expect("Failed to wait for child");
let cgroup_path = format!("/sys/fs/cgroup/remora-{}", pid);
assert!(
!std::path::Path::new(&cgroup_path).exists(),
"Cgroup {} should be deleted after container exits",
cgroup_path
);
}
#[test]
fn test_cgroup_memory_swap() {
if !is_root() {
eprintln!("Skipping test_cgroup_memory_swap: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_cgroup_memory_swap: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "exit 0"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_cgroup_memory(64 * 1024 * 1024)
.with_cgroup_memory_swap(128 * 1024 * 1024)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn with cgroup memory+swap");
let _status = child.wait().expect("wait failed");
}
#[test]
fn test_cgroup_memory_reservation() {
if !is_root() {
eprintln!("Skipping test_cgroup_memory_reservation: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_cgroup_memory_reservation: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "exit 0"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_cgroup_memory_reservation(32 * 1024 * 1024)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn with cgroup memory reservation");
let _status = child.wait().expect("wait failed");
}
#[test]
fn test_cgroup_cpuset() {
if !is_root() {
eprintln!("Skipping test_cgroup_cpuset: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_cgroup_cpuset: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "exit 0"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_cgroup_cpuset_cpus("0")
.with_cgroup_cpuset_mems("0")
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn with cgroup cpuset");
let _status = child.wait().expect("wait failed");
}
#[test]
fn test_cgroup_blkio_weight() {
if !is_root() {
eprintln!("Skipping test_cgroup_blkio_weight: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_cgroup_blkio_weight: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "exit 0"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_cgroup_blkio_weight(100)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn with cgroup blkio weight");
let _status = child.wait().expect("wait failed");
}
#[test]
fn test_cgroup_device_rule() {
if !is_root() {
eprintln!("Skipping test_cgroup_device_rule: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_cgroup_device_rule: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "exit 0"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_cgroup_device_rule(true, 'a', -1, -1, "rwm")
.with_cgroup_device_rule(false, 'c', 5, 1, "rwm")
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn with cgroup device rules");
let _status = child.wait().expect("wait failed");
}
#[test]
fn test_cgroup_net_classid() {
if !is_root() {
eprintln!("Skipping test_cgroup_net_classid: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_cgroup_net_classid: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "exit 0"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_cgroup_net_classid(0x10001)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn with cgroup net classid");
let _status = child.wait().expect("wait failed");
}
}
mod networking {
use super::*;
#[test]
fn test_loopback_network() {
if !is_root() {
eprintln!("Skipping test_loopback_network: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_loopback_network: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args([
"-c",
"ip addr show lo | grep -q '127.0.0.1' && echo LOOPBACK_OK",
])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Loopback)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_proc_mount()
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn loopback container");
let (status, stdout, _) = child.wait_with_output().expect("Failed to collect output");
let out = String::from_utf8_lossy(&stdout);
assert!(
out.contains("LOOPBACK_OK"),
"lo should have 127.0.0.1 after bring-up, got: {}",
out
);
assert!(status.success(), "Container exited with failure");
}
#[test]
fn test_bridge_network_ip() {
if !is_root() {
eprintln!("Skipping test_bridge_network_ip: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_bridge_network_ip: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args([
"-c",
"ip addr show eth0 | grep -q '172.19.0' && echo BRIDGE_IP_OK",
])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_proc_mount()
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn bridge container");
let (status, stdout, _) = child.wait_with_output().expect("Failed to collect output");
let out = String::from_utf8_lossy(&stdout);
assert!(
out.contains("BRIDGE_IP_OK"),
"eth0 should have a 172.19.0.x address in bridge mode, got: {}",
out
);
assert!(status.success(), "Container exited with failure");
}
#[test]
fn test_bridge_network_veth_exists() {
if !is_root() {
eprintln!("Skipping test_bridge_network_veth_exists: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_bridge_network_veth_exists: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "sleep 2"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn bridge container");
let veth_name = child
.veth_name()
.expect("Bridge mode must have a veth name")
.to_string();
let status = std::process::Command::new("ip")
.args(["link", "show", &veth_name])
.stdout(std::process::Stdio::null())
.status()
.expect("Failed to run ip link show");
assert!(
status.success(),
"Host-side veth {} should exist after spawn",
veth_name
);
child.wait().expect("Failed to wait for container");
}
#[test]
fn test_bridge_network_cleanup() {
if !is_root() {
eprintln!("Skipping test_bridge_network_cleanup: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_bridge_network_cleanup: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "exit 0"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn bridge container");
let veth_name = child
.veth_name()
.expect("Bridge mode must have a veth name")
.to_string();
child.wait().expect("Failed to wait for container");
let status = std::process::Command::new("ip")
.args(["link", "show", &veth_name])
.stderr(std::process::Stdio::null())
.status()
.expect("Failed to run ip link show");
assert!(
!status.success(),
"Host-side veth {} should be gone after container exits",
veth_name
);
}
#[test]
fn test_bridge_netns_cleanup() {
if !is_root() {
eprintln!("Skipping test_bridge_netns_cleanup: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_bridge_netns_cleanup: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "exit 0"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn bridge container");
let ns_name = child
.netns_name()
.expect("Bridge mode must have netns name")
.to_string();
let ns_path = format!("/run/netns/{}", ns_name);
assert!(
std::path::Path::new(&ns_path).exists(),
"Named netns {} should exist before wait()",
ns_path
);
child.wait().expect("Failed to wait for container");
assert!(
!std::path::Path::new(&ns_path).exists(),
"Named netns {} should be deleted after wait()",
ns_path
);
}
#[test]
fn test_bridge_loopback_up() {
if !is_root() {
eprintln!("Skipping test_bridge_loopback_up: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_bridge_loopback_up: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "ip addr show lo | grep -q '127.0.0.1' && echo LO_OK"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_proc_mount()
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn bridge container");
let (status, stdout, _) = child.wait_with_output().expect("Failed to collect output");
let out = String::from_utf8_lossy(&stdout);
assert!(
out.contains("LO_OK"),
"lo should be up with 127.0.0.1 in bridge mode, got: {:?}",
out
);
assert!(status.success(), "Container exited with failure");
}
#[test]
fn test_bridge_gateway_reachable() {
if !is_root() {
eprintln!("Skipping test_bridge_gateway_reachable: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_bridge_gateway_reachable: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args([
"-c",
"ping -c 1 -W 2 172.19.0.1 >/dev/null 2>&1 && echo PING_OK",
])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_proc_mount()
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn bridge container");
let (status, stdout, _) = child.wait_with_output().expect("Failed to collect output");
let out = String::from_utf8_lossy(&stdout);
assert!(
out.contains("PING_OK"),
"Gateway 172.19.0.1 should be reachable from bridge container, got: {:?}",
out
);
assert!(status.success(), "Container exited with failure");
}
#[test]
fn test_bridge_concurrent_spawn() {
if !is_root() {
eprintln!("Skipping test_bridge_concurrent_spawn: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_bridge_concurrent_spawn: alpine-rootfs not found");
return;
};
let r1 = rootfs.clone();
let t1 = std::thread::spawn(move || {
Command::new("/bin/ash")
.args([
"-c",
"ip addr show eth0 | grep -m1 'inet ' | awk '{print $2}'",
])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_chroot(&r1)
.env("PATH", ALPINE_PATH)
.with_proc_mount()
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn container 1")
.wait_with_output()
.expect("Failed to collect output from container 1")
});
let r2 = rootfs.clone();
let t2 = std::thread::spawn(move || {
Command::new("/bin/ash")
.args([
"-c",
"ip addr show eth0 | grep -m1 'inet ' | awk '{print $2}'",
])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_chroot(&r2)
.env("PATH", ALPINE_PATH)
.with_proc_mount()
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn container 2")
.wait_with_output()
.expect("Failed to collect output from container 2")
});
let (_s1, out1, _) = t1.join().expect("Container 1 thread panicked");
let (_s2, out2, _) = t2.join().expect("Container 2 thread panicked");
let ip1 = String::from_utf8_lossy(&out1).trim().to_string();
let ip2 = String::from_utf8_lossy(&out2).trim().to_string();
assert!(!ip1.is_empty(), "Container 1 should output its IP address");
assert!(!ip2.is_empty(), "Container 2 should output its IP address");
assert!(
ip1.starts_with("172.19.0."),
"Container 1 IP should be in bridge subnet: {}",
ip1
);
assert!(
ip2.starts_with("172.19.0."),
"Container 2 IP should be in bridge subnet: {}",
ip2
);
assert_ne!(
ip1, ip2,
"Containers must receive different IPs: got {} and {}",
ip1, ip2
);
}
#[test]
#[serial(nat)]
fn test_nat_rule_added() {
if !is_root() {
eprintln!("Skipping test_nat_rule_added: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_nat_rule_added: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "sleep 2"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn NAT container");
let status = std::process::Command::new("nft")
.args(["list", "table", "ip", "remora-remora0"])
.stdout(std::process::Stdio::null())
.status()
.expect("Failed to run nft list table");
assert!(
status.success(),
"nft table ip remora-remora0 should exist while a NAT container is running"
);
child.wait().expect("Failed to wait for NAT container");
}
#[test]
#[serial(nat)]
fn test_nat_cleanup() {
if !is_root() {
eprintln!("Skipping test_nat_cleanup: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_nat_cleanup: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "exit 0"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn NAT container");
child.wait().expect("Failed to wait for NAT container");
let status = std::process::Command::new("nft")
.args(["list", "table", "ip", "remora-remora0"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("Failed to run nft list table");
assert!(
!status.success(),
"nft table ip remora-remora0 should be removed after all NAT containers exit"
);
}
#[test]
#[serial(nat)]
fn test_nat_refcount() {
if !is_root() {
eprintln!("Skipping test_nat_refcount: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_nat_refcount: alpine-rootfs not found");
return;
};
let mut child_a = Command::new("/bin/ash")
.args(["-c", "sleep 2"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn NAT container A");
let mut child_b = Command::new("/bin/ash")
.args(["-c", "sleep 4"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn NAT container B");
child_a.wait().expect("Failed to wait for container A");
let status = std::process::Command::new("nft")
.args(["list", "table", "ip", "remora-remora0"])
.stdout(std::process::Stdio::null())
.status()
.expect("Failed to run nft list table after A exits");
assert!(
status.success(),
"nft table should still exist after A exits (B is still running)"
);
child_b.wait().expect("Failed to wait for container B");
let status = std::process::Command::new("nft")
.args(["list", "table", "ip", "remora-remora0"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("Failed to run nft list table after B exits");
assert!(
!status.success(),
"nft table should be removed after both NAT containers exit"
);
}
#[test]
#[serial(nat)]
fn test_nat_iptables_forward_rules() {
if !is_root() {
eprintln!("Skipping test_nat_iptables_forward_rules: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_nat_iptables_forward_rules: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "sleep 3"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn NAT container");
let status_src = std::process::Command::new("iptables")
.args(["-C", "FORWARD", "-s", "172.19.0.0/24", "-j", "ACCEPT"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("Failed to run iptables -C (source)");
assert!(
status_src.success(),
"iptables FORWARD rule for source 172.19.0.0/24 should exist while NAT container runs"
);
let status_dst = std::process::Command::new("iptables")
.args(["-C", "FORWARD", "-d", "172.19.0.0/24", "-j", "ACCEPT"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("Failed to run iptables -C (dest)");
assert!(
status_dst.success(),
"iptables FORWARD rule for dest 172.19.0.0/24 should exist while NAT container runs"
);
child.wait().expect("Failed to wait for NAT container");
let status_after = std::process::Command::new("iptables")
.args(["-C", "FORWARD", "-s", "172.19.0.0/24", "-j", "ACCEPT"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("Failed to run iptables -C after cleanup");
assert!(
!status_after.success(),
"iptables FORWARD rule should be removed after NAT container exits"
);
}
#[test]
#[serial(nat)]
fn test_port_forward_rule_added() {
if !is_root() {
eprintln!("Skipping test_port_forward_rule_added: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_port_forward_rule_added: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "sleep 2"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_port_forward(18080, 80)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn port-forward container");
let output = std::process::Command::new("nft")
.args(["list", "chain", "ip", "remora-remora0", "prerouting"])
.output()
.expect("Failed to run nft list chain");
assert!(
output.status.success(),
"nft prerouting chain should exist while port-forward container is running"
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("dport 18080"),
"prerouting chain should contain DNAT rule for dport 18080; got:\n{}",
stdout
);
child
.wait()
.expect("Failed to wait for port-forward container");
}
#[test]
#[serial(nat)]
fn test_port_forward_cleanup() {
if !is_root() {
eprintln!("Skipping test_port_forward_cleanup: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_port_forward_cleanup: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "exit 0"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_port_forward(18081, 80)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn port-forward container");
child
.wait()
.expect("Failed to wait for port-forward container");
let status = std::process::Command::new("nft")
.args(["list", "table", "ip", "remora-remora0"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("Failed to run nft list table");
assert!(
!status.success(),
"nft table ip remora-remora0 should be removed after port-forward container exits"
);
}
#[test]
#[serial(nat)]
fn test_port_forward_independent_teardown() {
if !is_root() {
eprintln!("Skipping test_port_forward_independent_teardown: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_port_forward_independent_teardown: alpine-rootfs not found");
return;
};
let mut child_a = Command::new("/bin/ash")
.args(["-c", "sleep 2"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_port_forward(18082, 80)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn port-forward container A");
let mut child_b = Command::new("/bin/ash")
.args(["-c", "sleep 4"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_port_forward(18083, 80)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn port-forward container B");
child_a.wait().expect("Failed to wait for container A");
let output = std::process::Command::new("nft")
.args(["list", "chain", "ip", "remora-remora0", "prerouting"])
.output()
.expect("Failed to run nft list chain after A exits");
assert!(
output.status.success(),
"prerouting chain should still exist after A exits (B is still running)"
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
!stdout.contains("dport 18082"),
"A's DNAT rule (dport 18082) should be gone after A exits"
);
assert!(
stdout.contains("dport 18083"),
"B's DNAT rule (dport 18083) should still be present; got:\n{}",
stdout
);
child_b.wait().expect("Failed to wait for container B");
let status = std::process::Command::new("nft")
.args(["list", "table", "ip", "remora-remora0"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("Failed to run nft list table after B exits");
assert!(
!status.success(),
"nft table should be removed after both port-forward containers exit"
);
}
#[test]
#[serial(nat)]
fn test_dns_resolv_conf() {
if !is_root() {
eprintln!("Skipping test_dns_resolv_conf: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_dns_resolv_conf: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "cat /etc/resolv.conf"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_dns(&["1.1.1.1", "8.8.8.8"])
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn DNS container");
let (_status, stdout_bytes, _stderr) = child
.wait_with_output()
.expect("Failed to wait for DNS container");
let stdout = String::from_utf8_lossy(&stdout_bytes);
assert!(
stdout.contains("nameserver 1.1.1.1"),
"/etc/resolv.conf should contain 'nameserver 1.1.1.1'; got:\n{}",
stdout
);
assert!(
stdout.contains("nameserver 8.8.8.8"),
"/etc/resolv.conf should contain 'nameserver 8.8.8.8'; got:\n{}",
stdout
);
}
#[test]
#[serial(nat)]
fn test_port_forward_end_to_end() {
if !is_root() {
eprintln!("Skipping test_port_forward_end_to_end: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_port_forward_end_to_end: alpine-rootfs not found");
return;
};
let nc_ok = std::process::Command::new("which")
.arg("nc")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if !nc_ok {
eprintln!("Skipping test_port_forward_end_to_end: nc not found on host");
return;
}
let mut child_a = Command::new("/bin/sh")
.args(["-c", "echo HELLO_FROM_CONTAINER | nc -l -p 80"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_port_forward(19090, 80)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn container A");
std::thread::sleep(std::time::Duration::from_millis(500));
let setup_ok = std::process::Command::new("sh")
.args([
"-c",
"\
ip netns add pf-test-client && \
ip link add pf-test-h type veth peer name pf-test-c && \
ip link set pf-test-c netns pf-test-client && \
ip addr add 10.99.0.1/24 dev pf-test-h && \
ip link set pf-test-h up && \
ip netns exec pf-test-client ip addr add 10.99.0.2/24 dev pf-test-c && \
ip netns exec pf-test-client ip link set pf-test-c up && \
ip netns exec pf-test-client ip route add default via 10.99.0.1\
",
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("setup test netns")
.success();
if !setup_ok {
unsafe {
libc::kill(child_a.pid(), libc::SIGKILL);
}
let _ = child_a.wait();
eprintln!("Skipping test_port_forward_end_to_end: failed to set up test netns");
return;
}
let output = std::process::Command::new("ip")
.args([
"netns",
"exec",
"pf-test-client",
"nc",
"-w",
"2",
"10.99.0.1",
"19090",
])
.output()
.expect("nc from test netns");
let out = String::from_utf8_lossy(&output.stdout);
let err = String::from_utf8_lossy(&output.stderr);
let _ = std::process::Command::new("ip")
.args(["netns", "del", "pf-test-client"])
.status();
let _ = std::process::Command::new("ip")
.args(["link", "del", "pf-test-h"])
.status();
unsafe {
libc::kill(child_a.pid(), libc::SIGKILL);
}
let _ = child_a.wait();
assert!(
out.contains("HELLO_FROM_CONTAINER"),
"External client should receive 'HELLO_FROM_CONTAINER' via port forward 19090→80.\nstdout: {}\nstderr: {}",
out, err
);
}
#[test]
#[serial(nat)]
fn test_udp_port_forward_rule_added() {
if !is_root() {
eprintln!("Skipping test_udp_port_forward_rule_added: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_udp_port_forward_rule_added: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/sh")
.args(["-c", "sleep 5"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_port_forward_udp(19095, 5000)
.with_chroot(&rootfs)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn container");
std::thread::sleep(std::time::Duration::from_millis(200));
let nft_out = std::process::Command::new("nft")
.args(["list", "chain", "ip", "remora-remora0", "prerouting"])
.output();
unsafe {
libc::kill(child.pid(), libc::SIGKILL);
}
let _ = child.wait();
let nft_out = nft_out.expect("nft list chain");
let rules = String::from_utf8_lossy(&nft_out.stdout);
assert!(
rules.contains("udp dport 19095"),
"Expected 'udp dport 19095' in prerouting chain, got:\n{}",
rules
);
assert!(
!rules.contains("tcp dport 19095"),
"UDP-only mapping must not generate a TCP rule, got:\n{}",
rules
);
}
#[test]
#[serial(nat)]
fn test_both_port_forward_rule_added() {
if !is_root() {
eprintln!("Skipping test_both_port_forward_rule_added: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_both_port_forward_rule_added: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/sh")
.args(["-c", "sleep 5"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_port_forward_both(19096, 53)
.with_chroot(&rootfs)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn container");
std::thread::sleep(std::time::Duration::from_millis(200));
let nft_out = std::process::Command::new("nft")
.args(["list", "chain", "ip", "remora-remora0", "prerouting"])
.output();
unsafe {
libc::kill(child.pid(), libc::SIGKILL);
}
let _ = child.wait();
let nft_out = nft_out.expect("nft list chain");
let rules = String::from_utf8_lossy(&nft_out.stdout);
assert!(
rules.contains("tcp dport 19096"),
"Expected 'tcp dport 19096' in prerouting chain, got:\n{}",
rules
);
assert!(
rules.contains("udp dport 19096"),
"Expected 'udp dport 19096' in prerouting chain, got:\n{}",
rules
);
}
#[test]
#[serial(nat)]
fn test_udp_proxy_threads_joined_on_teardown() {
if !is_root() {
eprintln!("Skipping test_udp_proxy_threads_joined_on_teardown: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!(
"Skipping test_udp_proxy_threads_joined_on_teardown: alpine-rootfs not found"
);
return;
};
let test_port: u16 = 19097;
let mut child = Command::new("/bin/sh")
.args(["-c", "sleep 60"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_port_forward_udp(test_port, 5000)
.with_chroot(&rootfs)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("spawn container");
std::thread::sleep(std::time::Duration::from_millis(100));
let bind_while_running =
std::net::UdpSocket::bind(std::net::SocketAddr::from(([127, 0, 0, 1], test_port)));
assert!(
bind_while_running.is_err(),
"UDP proxy should hold port {} while container is running",
test_port
);
unsafe { libc::kill(child.pid(), libc::SIGKILL) };
let _ = child.wait();
let bind_after =
std::net::UdpSocket::bind(std::net::SocketAddr::from(([127, 0, 0, 1], test_port)));
assert!(
bind_after.is_ok(),
"UDP proxy port {} should be released after teardown (thread not joined?)",
test_port
);
}
#[test]
#[serial(nat)]
fn test_bridge_cleanup_after_sigkill() {
if !is_root() {
eprintln!("Skipping test_bridge_cleanup_after_sigkill: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_bridge_cleanup_after_sigkill: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/sleep")
.args(["60"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn container");
let veth = child.veth_name().expect("should have veth").to_string();
let netns = child.netns_name().expect("should have netns").to_string();
let veth_exists = std::process::Command::new("ip")
.args(["link", "show", &veth])
.stderr(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.status()
.expect("ip link show")
.success();
assert!(veth_exists, "veth {} should exist before kill", veth);
let iptables_exists = std::process::Command::new("iptables")
.args(["-C", "FORWARD", "-s", "172.19.0.0/24", "-j", "ACCEPT"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("iptables -C")
.success();
assert!(
iptables_exists,
"iptables FORWARD rule should exist before kill"
);
unsafe {
libc::kill(child.pid(), libc::SIGKILL);
}
let _ = child.wait();
let veth_after = std::process::Command::new("ip")
.args(["link", "show", &veth])
.stderr(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.status()
.expect("ip link show after kill")
.success();
assert!(
!veth_after,
"veth {} should be gone after SIGKILL + wait()",
veth
);
let netns_path = format!("/run/netns/{}", netns);
assert!(
!std::path::Path::new(&netns_path).exists(),
"netns {} should be gone after SIGKILL + wait()",
netns
);
let nft_after = std::process::Command::new("nft")
.args(["list", "table", "ip", "remora-remora0"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("nft list table after kill")
.success();
assert!(
!nft_after,
"nftables table should be gone after SIGKILL + wait()"
);
let iptables_after = std::process::Command::new("iptables")
.args(["-C", "FORWARD", "-s", "172.19.0.0/24", "-j", "ACCEPT"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("iptables -C after kill")
.success();
assert!(
!iptables_after,
"iptables FORWARD rule should be gone after SIGKILL + wait()"
);
}
#[test]
#[serial(nat)]
fn test_nat_end_to_end_tcp() {
if !is_root() {
eprintln!("Skipping test_nat_end_to_end_tcp: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_nat_end_to_end_tcp: alpine-rootfs not found");
return;
};
let internet = std::process::Command::new("ping")
.args(["-c", "1", "-W", "2", "1.1.1.1"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
match internet {
Ok(s) if s.success() => {}
_ => {
eprintln!("Skipping test_nat_end_to_end_tcp: no outbound internet");
return;
}
}
let mut child = Command::new("/bin/sh")
.args(["-c", "wget -q -T 5 --spider http://1.1.1.1/"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_dns(&["1.1.1.1"])
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("Failed to spawn NAT container");
let (status, stdout, stderr) = child.wait_with_output().expect("wait");
let out = String::from_utf8_lossy(&stdout);
let err = String::from_utf8_lossy(&stderr);
assert!(
status.success(),
"wget through NAT should succeed (TCP to 1.1.1.1).\nstdout: {}\nstderr: {}",
out,
err
);
}
}
mod oci_lifecycle {
use super::*;
fn make_oci_bundle(dir: &std::path::Path, rootfs: &std::path::Path, args: &[&str]) -> PathBuf {
let rootfs_link = dir.join("rootfs");
std::os::unix::fs::symlink(rootfs, &rootfs_link).expect("failed to create rootfs symlink");
let args_json: Vec<String> = args.iter().map(|s| format!("\"{}\"", s)).collect();
let config = format!(
r#"{{
"ociVersion": "1.0.2",
"root": {{"path": "rootfs"}},
"process": {{
"args": [{}],
"cwd": "/",
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]
}},
"linux": {{
"namespaces": [
{{"type": "mount"}},
{{"type": "uts"}},
{{"type": "pid"}}
]
}}
}}"#,
args_json.join(", ")
);
std::fs::write(dir.join("config.json"), config).expect("failed to write config.json");
dir.to_path_buf()
}
fn run_remora(args: &[&str]) -> (String, String, bool) {
if args.first() == Some(&"create") {
let tmp = tempfile::NamedTempFile::new().expect("tempfile for stderr");
let stderr_file = tmp.reopen().expect("reopen stderr tempfile");
let status = std::process::Command::new(env!("CARGO_BIN_EXE_remora"))
.args(args)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::from(stderr_file))
.status()
.expect("failed to run remora create");
let stderr = std::fs::read_to_string(tmp.path()).unwrap_or_default();
return (String::new(), stderr, status.success());
}
let output = std::process::Command::new(env!("CARGO_BIN_EXE_remora"))
.args(args)
.output()
.expect("failed to run remora binary");
(
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
output.status.success(),
)
}
fn oci_run_to_completion(id: &str, bundle: &std::path::Path, timeout_secs: u64) {
let (_, stderr, ok) = run_remora(&["create", id, bundle.to_str().unwrap()]);
assert!(ok, "remora create failed: {}", stderr);
let (_, stderr, ok) = run_remora(&["start", id]);
assert!(ok, "remora start failed: {}", stderr);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
loop {
std::thread::sleep(std::time::Duration::from_millis(100));
let (stdout, _, _) = run_remora(&["state", id]);
if stdout.contains("\"stopped\"") {
break;
}
if std::time::Instant::now() > deadline {
run_remora(&["delete", id]);
panic!("container did not stop within {} seconds", timeout_secs);
}
}
let (_, stderr, ok) = run_remora(&["delete", id]);
assert!(ok, "remora delete failed: {}", stderr);
}
#[test]
fn test_oci_create_start_state() {
if !is_root() {
eprintln!("Skipping test_oci_create_start_state: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_create_start_state: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
let bundle = make_oci_bundle(bundle_dir.path(), &rootfs, &["/bin/sleep", "2"]);
let id = format!("test-oci-css-{}", std::process::id());
let (_, stderr, ok) = run_remora(&["create", &id, bundle.to_str().unwrap()]);
assert!(ok, "remora create failed: {}", stderr);
let (stdout, stderr, ok) = run_remora(&["state", &id]);
assert!(ok, "remora state (created) failed: {}", stderr);
assert!(
stdout.contains("\"created\""),
"expected status 'created', got: {}",
stdout
);
let (_, stderr, ok) = run_remora(&["start", &id]);
assert!(ok, "remora start failed: {}", stderr);
let (stdout, _, _) = run_remora(&["state", &id]);
assert!(
stdout.contains("\"running\""),
"expected status 'running' after start, got: {}",
stdout
);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(6);
loop {
std::thread::sleep(std::time::Duration::from_millis(200));
let (stdout, _, _) = run_remora(&["state", &id]);
if stdout.contains("\"stopped\"") {
break;
}
if std::time::Instant::now() > deadline {
panic!(
"container did not stop within 6 seconds; last state: {}",
stdout
);
}
}
let (_, stderr, ok) = run_remora(&["delete", &id]);
assert!(ok, "remora delete failed: {}", stderr);
let state_dir = pelagos::oci::state_dir(&id);
assert!(
!state_dir.exists(),
"state dir still exists after delete: {}",
state_dir.display()
);
}
#[test]
fn test_oci_kill() {
if !is_root() {
eprintln!("Skipping test_oci_kill: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_kill: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
let bundle = make_oci_bundle(bundle_dir.path(), &rootfs, &["/bin/sleep", "60"]);
let id = format!("test-oci-kill-{}", std::process::id());
let (_, stderr, ok) = run_remora(&["create", &id, bundle.to_str().unwrap()]);
assert!(ok, "remora create failed: {}", stderr);
let (_, stderr, ok) = run_remora(&["start", &id]);
assert!(ok, "remora start failed: {}", stderr);
std::thread::sleep(std::time::Duration::from_millis(200));
let (_, stderr, ok) = run_remora(&["kill", &id, "SIGKILL"]);
assert!(ok, "remora kill failed: {}", stderr);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(4);
loop {
std::thread::sleep(std::time::Duration::from_millis(200));
let (stdout, _, _) = run_remora(&["state", &id]);
if stdout.contains("\"stopped\"") {
break;
}
if std::time::Instant::now() > deadline {
panic!("container did not stop after SIGKILL within 4 seconds");
}
}
let (_, stderr, ok) = run_remora(&["delete", &id]);
assert!(ok, "remora delete failed: {}", stderr);
}
#[test]
fn test_oci_delete_cleanup() {
if !is_root() {
eprintln!("Skipping test_oci_delete_cleanup: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_delete_cleanup: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
let bundle = make_oci_bundle(bundle_dir.path(), &rootfs, &["/bin/true"]);
let id = format!("test-oci-del-{}", std::process::id());
let (_, stderr, ok) = run_remora(&["create", &id, bundle.to_str().unwrap()]);
assert!(ok, "remora create failed: {}", stderr);
let (_, stderr, ok) = run_remora(&["start", &id]);
assert!(ok, "remora start failed: {}", stderr);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(4);
loop {
std::thread::sleep(std::time::Duration::from_millis(100));
let (stdout, _, _) = run_remora(&["state", &id]);
if stdout.contains("\"stopped\"") {
break;
}
if std::time::Instant::now() > deadline {
panic!("container did not stop within 4 seconds");
}
}
let state_dir = pelagos::oci::state_dir(&id);
assert!(state_dir.exists(), "state dir should exist before delete");
let (_, stderr, ok) = run_remora(&["delete", &id]);
assert!(ok, "remora delete failed: {}", stderr);
assert!(
!state_dir.exists(),
"state dir {} still present after delete",
state_dir.display()
);
}
#[test]
fn test_oci_state_dir_stable_until_delete() {
if !is_root() {
eprintln!("Skipping test_oci_state_dir_stable_until_delete: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!(
"Skipping test_oci_state_dir_stable_until_delete: alpine-rootfs not found"
);
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
let bundle = make_oci_bundle(bundle_dir.path(), &rootfs, &["/bin/true"]);
let id = format!("test-oci-stable-{}", std::process::id());
let (_, stderr, ok) = run_remora(&["create", &id, bundle.to_str().unwrap()]);
assert!(ok, "remora create failed: {}", stderr);
let (_, stderr, ok) = run_remora(&["start", &id]);
assert!(ok, "remora start failed: {}", stderr);
std::thread::sleep(std::time::Duration::from_millis(200));
let state_dir = pelagos::oci::state_dir(&id);
assert!(
state_dir.exists(),
"state dir must persist until remora delete, not be cleaned up on container exit"
);
let (stdout, stderr, ok) = run_remora(&["state", &id]);
assert!(ok, "remora state on stopped container failed: {}", stderr);
assert!(
stdout.contains("\"stopped\""),
"expected state=stopped, got: {}",
stdout
);
let (_, stderr, ok) = run_remora(&["delete", &id]);
assert!(ok, "remora delete failed: {}", stderr);
assert!(!state_dir.exists(), "state dir should be gone after delete");
}
#[test]
fn test_oci_kill_short_lived() {
if !is_root() {
eprintln!("Skipping test_oci_kill_short_lived: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_kill_short_lived: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
let bundle = make_oci_bundle(bundle_dir.path(), &rootfs, &["/bin/true"]);
let id = format!("test-oci-kill-sl-{}", std::process::id());
let (_, stderr, ok) = run_remora(&["create", &id, bundle.to_str().unwrap()]);
assert!(ok, "remora create failed: {}", stderr);
let (_, stderr, ok) = run_remora(&["start", &id]);
assert!(ok, "remora start failed: {}", stderr);
std::thread::sleep(std::time::Duration::from_millis(200));
let (_, stderr, ok) = run_remora(&["kill", &id, "SIGKILL"]);
assert!(
ok,
"remora kill on short-lived container failed: {}",
stderr
);
let (_, stderr, ok) = run_remora(&["delete", &id]);
assert!(ok, "remora delete failed: {}", stderr);
}
#[test]
fn test_oci_kill_stopped_fails() {
if !is_root() {
eprintln!("Skipping test_oci_kill_stopped_fails: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_kill_stopped_fails: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
let bundle = make_oci_bundle(bundle_dir.path(), &rootfs, &["/bin/true"]);
let id = format!("test-oci-kill-sf-{}", std::process::id());
let (_, stderr, ok) = run_remora(&["create", &id, bundle.to_str().unwrap()]);
assert!(ok, "remora create failed: {}", stderr);
let (_, stderr, ok) = run_remora(&["start", &id]);
assert!(ok, "remora start failed: {}", stderr);
std::thread::sleep(std::time::Duration::from_millis(200));
let (stdout, _, _) = run_remora(&["state", &id]);
assert!(
stdout.contains("\"stopped\""),
"expected state=stopped, got: {}",
stdout
);
let (_, _, ok) = run_remora(&["kill", &id, "SIGKILL"]);
assert!(!ok, "remora kill on stopped container should fail");
let (_, stderr, ok) = run_remora(&["delete", &id]);
assert!(ok, "remora delete failed: {}", stderr);
}
#[test]
fn test_oci_pid_start_time() {
use pelagos::oci::read_pid_start_time;
let our_pid = std::process::id() as libc::pid_t;
let t = read_pid_start_time(our_pid);
assert!(
t.is_some(),
"read_pid_start_time(self) returned None — /proc parsing broken"
);
assert!(t.unwrap() > 0, "starttime should be positive");
assert_eq!(
read_pid_start_time(our_pid),
read_pid_start_time(our_pid),
"read_pid_start_time is not stable"
);
assert!(
read_pid_start_time(i32::MAX).is_none(),
"read_pid_start_time(MAX_PID) should return None"
);
if !is_root() {
eprintln!("Skipping OCI integration part of test_oci_pid_start_time: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!(
"Skipping OCI integration part of test_oci_pid_start_time: alpine-rootfs not found"
);
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
let bundle = make_oci_bundle(bundle_dir.path(), &rootfs, &["/bin/sleep", "30"]);
let id = format!("test-oci-pst-{}", std::process::id());
let (_, stderr, ok) = run_remora(&["create", &id, bundle.to_str().unwrap()]);
assert!(ok, "remora create failed: {}", stderr);
let (_, stderr, ok) = run_remora(&["start", &id]);
assert!(ok, "remora start failed: {}", stderr);
let state_path = format!("/run/remora/{}/state.json", id);
let raw = std::fs::read_to_string(&state_path).expect("state.json not found");
let state_json: serde_json::Value =
serde_json::from_str(&raw).expect("state.json is not valid JSON");
let stored = state_json.get("pidStartTime").and_then(|v| v.as_u64());
assert!(
stored.is_some(),
"pidStartTime missing from state.json: {}",
raw
);
let pid = state_json["pid"].as_i64().expect("pid field") as libc::pid_t;
let live_starttime = read_pid_start_time(pid);
assert_eq!(
stored, live_starttime,
"state.json pidStartTime ({:?}) != /proc/{}/stat starttime ({:?})",
stored, pid, live_starttime
);
let _ = run_remora(&["kill", &id, "SIGKILL"]);
std::thread::sleep(std::time::Duration::from_millis(100));
let _ = run_remora(&["delete", &id]);
}
#[test]
fn test_oci_pidfd_mgmt_socket() {
use pelagos::oci::{is_pidfd_alive, mgmt_sock_path};
if !is_root() {
eprintln!("Skipping test_oci_pidfd_mgmt_socket: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_pidfd_mgmt_socket: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
let bundle = make_oci_bundle(bundle_dir.path(), &rootfs, &["/bin/sleep", "30"]);
let id = format!("test-oci-pidfd-{}", std::process::id());
let (_, stderr, ok) = run_remora(&["create", &id, bundle.to_str().unwrap()]);
assert!(ok, "remora create failed: {}", stderr);
let (_, stderr, ok) = run_remora(&["start", &id]);
assert!(ok, "remora start failed: {}", stderr);
std::thread::sleep(std::time::Duration::from_millis(200));
let mgmt = mgmt_sock_path(&id);
assert!(
mgmt.exists(),
"mgmt.sock not found at {} — shim pidfd setup failed",
mgmt.display()
);
let conn = unsafe {
let fd = libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0);
assert!(fd >= 0, "socket() failed");
let path_bytes = mgmt.to_str().unwrap().as_bytes();
let mut addr: libc::sockaddr_un = std::mem::zeroed();
addr.sun_family = libc::AF_UNIX as libc::sa_family_t;
std::ptr::copy_nonoverlapping(
path_bytes.as_ptr() as *const libc::c_char,
addr.sun_path.as_mut_ptr(),
path_bytes.len(),
);
let r = libc::connect(
fd,
&addr as *const libc::sockaddr_un as *const libc::sockaddr,
std::mem::size_of::<libc::sockaddr_un>() as libc::socklen_t,
);
assert_eq!(r, 0, "connect to mgmt.sock failed");
fd
};
let pidfd = unsafe {
let cmsg_space = libc::CMSG_SPACE(std::mem::size_of::<i32>() as libc::c_uint) as usize;
let mut cmsg_buf = vec![0u8; cmsg_space];
let mut iov_buf = [0u8; 1];
let mut iov = libc::iovec {
iov_base: iov_buf.as_mut_ptr() as *mut libc::c_void,
iov_len: 1,
};
let mut msg: libc::msghdr = std::mem::zeroed();
msg.msg_iov = &mut iov;
msg.msg_iovlen = 1;
msg.msg_control = cmsg_buf.as_mut_ptr() as *mut libc::c_void;
msg.msg_controllen = cmsg_space as _;
let r = libc::recvmsg(conn, &mut msg, 0);
assert!(r >= 0, "recvmsg failed");
let cmsg = libc::CMSG_FIRSTHDR(&msg);
assert!(!cmsg.is_null(), "no SCM_RIGHTS message received");
*(libc::CMSG_DATA(cmsg) as *const i32)
};
unsafe { libc::close(conn) };
assert!(pidfd >= 0, "received invalid pidfd {}", pidfd);
assert!(
is_pidfd_alive(pidfd),
"is_pidfd_alive returned false while container is still running"
);
let _ = run_remora(&["kill", &id, "SIGKILL"]);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
std::thread::sleep(std::time::Duration::from_millis(100));
if !is_pidfd_alive(pidfd) {
break;
}
if std::time::Instant::now() > deadline {
unsafe { libc::close(pidfd) };
let _ = run_remora(&["delete", &id]);
panic!("pidfd still reports alive 5s after SIGKILL");
}
}
assert!(
!is_pidfd_alive(pidfd),
"is_pidfd_alive returned true after container was killed"
);
unsafe { libc::close(pidfd) };
let _ = run_remora(&["delete", &id]);
}
#[test]
fn test_oci_pidfd_state_liveness() {
if !is_root() {
eprintln!("Skipping test_oci_pidfd_state_liveness: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_pidfd_state_liveness: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
let bundle = make_oci_bundle(bundle_dir.path(), &rootfs, &["/bin/true"]);
let id = format!("test-oci-pidfd-sl-{}", std::process::id());
let (_, stderr, ok) = run_remora(&["create", &id, bundle.to_str().unwrap()]);
assert!(ok, "remora create failed: {}", stderr);
let (_, stderr, ok) = run_remora(&["start", &id]);
assert!(ok, "remora start failed: {}", stderr);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
std::thread::sleep(std::time::Duration::from_millis(100));
let (stdout, _, _) = run_remora(&["state", &id]);
if stdout.contains("\"stopped\"") {
break;
}
if std::time::Instant::now() > deadline {
let _ = run_remora(&["delete", &id]);
panic!(
"container did not reach 'stopped' within 5s; last state: {}",
stdout
);
}
}
let (_, stderr, ok) = run_remora(&["delete", &id]);
assert!(ok, "remora delete failed: {}", stderr);
}
#[test]
fn test_oci_bundle_mounts() {
if !is_root() {
eprintln!("Skipping test_oci_bundle_mounts: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_bundle_mounts: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
let rootfs_link = bundle_dir.path().join("rootfs");
std::os::unix::fs::symlink(&rootfs, &rootfs_link).unwrap();
let config = r#"{
"ociVersion": "1.0.2",
"root": {"path": "rootfs"},
"process": {
"args": ["/bin/sh", "-c", "echo hello > /scratch/test.txt && cat /scratch/test.txt"],
"cwd": "/",
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]
},
"mounts": [
{
"destination": "/scratch",
"type": "tmpfs",
"source": "tmpfs",
"options": []
}
],
"linux": {
"namespaces": [
{"type": "mount"},
{"type": "uts"},
{"type": "pid"}
]
}
}"#;
std::fs::write(bundle_dir.path().join("config.json"), config).unwrap();
let bundle = bundle_dir.path();
let id = format!("test-oci-mnt-{}", std::process::id());
let (_, stderr, ok) = run_remora(&["create", &id, bundle.to_str().unwrap()]);
assert!(ok, "remora create failed: {}", stderr);
let (_, stderr, ok) = run_remora(&["start", &id]);
assert!(ok, "remora start failed: {}", stderr);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(4);
loop {
std::thread::sleep(std::time::Duration::from_millis(100));
let (stdout, _, _) = run_remora(&["state", &id]);
if stdout.contains("\"stopped\"") {
break;
}
if std::time::Instant::now() > deadline {
panic!("container did not stop within 4 seconds");
}
}
let (_, stderr, ok) = run_remora(&["delete", &id]);
assert!(ok, "remora delete failed: {}", stderr);
assert!(
stderr.is_empty() || !stderr.contains("error"),
"unexpected error: {}",
stderr
);
}
#[test]
fn test_oci_capabilities() {
if !is_root() {
eprintln!("Skipping test_oci_capabilities: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_capabilities: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
let rootfs_link = bundle_dir.path().join("rootfs");
std::os::unix::fs::symlink(&rootfs, &rootfs_link).unwrap();
let config = r#"{
"ociVersion": "1.0.2",
"root": {"path": "rootfs"},
"process": {
"args": ["/usr/bin/id"],
"cwd": "/",
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],
"capabilities": {
"bounding": ["CAP_CHOWN"],
"effective": ["CAP_CHOWN"],
"permitted": ["CAP_CHOWN"],
"inheritable": []
}
},
"linux": {
"namespaces": [
{"type": "mount"},
{"type": "uts"},
{"type": "pid"}
]
}
}"#;
std::fs::write(bundle_dir.path().join("config.json"), config).unwrap();
let id = format!("test-oci-cap-{}", std::process::id());
let (_, stderr, ok) = run_remora(&["create", &id, bundle_dir.path().to_str().unwrap()]);
assert!(ok, "remora create failed: {}", stderr);
let (_, stderr, ok) = run_remora(&["start", &id]);
assert!(ok, "remora start failed: {}", stderr);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
std::thread::sleep(std::time::Duration::from_millis(100));
let (stdout, _, _) = run_remora(&["state", &id]);
if stdout.contains("\"stopped\"") {
break;
}
if std::time::Instant::now() > deadline {
run_remora(&["delete", &id]);
panic!("container did not stop within 5 seconds");
}
}
let (_, stderr, ok) = run_remora(&["delete", &id]);
assert!(ok, "remora delete failed: {}", stderr);
}
#[test]
fn test_oci_masked_readonly_paths() {
if !is_root() {
eprintln!("Skipping test_oci_masked_readonly_paths: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_masked_readonly_paths: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
let rootfs_link = bundle_dir.path().join("rootfs");
std::os::unix::fs::symlink(&rootfs, &rootfs_link).unwrap();
let config = r#"{
"ociVersion": "1.0.2",
"root": {"path": "rootfs"},
"process": {
"args": ["/bin/sh", "-c",
"[ $(wc -c < /proc/kcore) -eq 0 ] && ! touch /sys/kernel/test 2>/dev/null && echo ok"],
"cwd": "/",
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]
},
"linux": {
"namespaces": [
{"type": "mount"},
{"type": "uts"},
{"type": "pid"}
],
"maskedPaths": ["/proc/kcore"],
"readonlyPaths": ["/sys/kernel"]
}
}"#;
std::fs::write(bundle_dir.path().join("config.json"), config).unwrap();
let id = format!("test-oci-mrp-{}", std::process::id());
let (_, stderr, ok) = run_remora(&["create", &id, bundle_dir.path().to_str().unwrap()]);
assert!(ok, "remora create failed: {}", stderr);
let (_, stderr, ok) = run_remora(&["start", &id]);
assert!(ok, "remora start failed: {}", stderr);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
std::thread::sleep(std::time::Duration::from_millis(100));
let (stdout, _, _) = run_remora(&["state", &id]);
if stdout.contains("\"stopped\"") {
break;
}
if std::time::Instant::now() > deadline {
run_remora(&["delete", &id]);
panic!("container did not stop within 5 seconds");
}
}
let (_, stderr, ok) = run_remora(&["delete", &id]);
assert!(ok, "remora delete failed: {}", stderr);
}
#[test]
fn test_oci_resources() {
if !is_root() {
eprintln!("Skipping test_oci_resources: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_resources: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
std::os::unix::fs::symlink(&rootfs, bundle_dir.path().join("rootfs")).unwrap();
let config = r#"{
"ociVersion": "1.0.2",
"root": {"path": "rootfs"},
"process": {
"args": ["/bin/sh", "-c",
"cat /sys/fs/cgroup/memory.max && cat /sys/fs/cgroup/pids.max"],
"cwd": "/",
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]
},
"linux": {
"namespaces": [{"type": "mount"}, {"type": "uts"}, {"type": "pid"}],
"resources": {
"memory": {"limit": 67108864},
"pids": {"limit": 50}
}
}
}"#;
std::fs::write(bundle_dir.path().join("config.json"), config).unwrap();
let id = format!("test-oci-res-{}", std::process::id());
oci_run_to_completion(&id, bundle_dir.path(), 5);
}
#[test]
fn test_oci_resources_extended() {
if !is_root() {
eprintln!("Skipping test_oci_resources_extended: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_resources_extended: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
std::os::unix::fs::symlink(&rootfs, bundle_dir.path().join("rootfs")).unwrap();
let config = r#"{
"ociVersion": "1.0.2",
"root": {"path": "rootfs"},
"process": {
"args": ["/bin/sh", "-c", "exit 0"],
"cwd": "/",
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]
},
"linux": {
"namespaces": [{"type": "mount"}, {"type": "uts"}, {"type": "pid"}],
"resources": {
"memory": {
"limit": 67108864,
"swap": 134217728,
"reservation": 33554432
},
"cpu": {
"shares": 512,
"cpus": "0",
"mems": "0"
},
"pids": {"limit": 64},
"blockIO": {"weight": 100},
"devices": [
{"allow": true, "type": "a", "access": "rwm"},
{"allow": false, "type": "c", "major": 5, "minor": 1, "access": "rwm"}
],
"network": {"classID": 65537, "priorities": [{"name": "eth0", "priority": 10}]}
}
}
}"#;
std::fs::write(bundle_dir.path().join("config.json"), config).unwrap();
let id = format!("test-oci-res-ext-{}", std::process::id());
oci_run_to_completion(&id, bundle_dir.path(), 5);
}
#[test]
fn test_oci_rlimits() {
if !is_root() {
eprintln!("Skipping test_oci_rlimits: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_rlimits: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
std::os::unix::fs::symlink(&rootfs, bundle_dir.path().join("rootfs")).unwrap();
let config = r#"{
"ociVersion": "1.0.2",
"root": {"path": "rootfs"},
"process": {
"args": ["/bin/sh", "-c", "ulimit -n"],
"cwd": "/",
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],
"rlimits": [{"type": "RLIMIT_NOFILE", "hard": 128, "soft": 128}]
},
"linux": {
"namespaces": [{"type": "mount"}, {"type": "uts"}, {"type": "pid"}]
}
}"#;
std::fs::write(bundle_dir.path().join("config.json"), config).unwrap();
let id = format!("test-oci-rl-{}", std::process::id());
oci_run_to_completion(&id, bundle_dir.path(), 5);
}
#[test]
fn test_oci_sysctl() {
if !is_root() {
eprintln!("Skipping test_oci_sysctl: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_sysctl: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
std::os::unix::fs::symlink(&rootfs, bundle_dir.path().join("rootfs")).unwrap();
let config = r#"{
"ociVersion": "1.0.2",
"root": {"path": "rootfs"},
"process": {
"args": ["/bin/sh", "-c",
"cat /proc/sys/kernel/domainname | grep -q testdomain"],
"cwd": "/",
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]
},
"linux": {
"namespaces": [{"type": "mount"}, {"type": "uts"}, {"type": "pid"}],
"sysctl": {"kernel.domainname": "testdomain.local"}
}
}"#;
std::fs::write(bundle_dir.path().join("config.json"), config).unwrap();
let id = format!("test-oci-sc-{}", std::process::id());
oci_run_to_completion(&id, bundle_dir.path(), 5);
}
#[test]
fn test_oci_hooks() {
if !is_root() {
eprintln!("Skipping test_oci_hooks: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_hooks: alpine-rootfs not found");
return;
}
};
let hooks_dir = tempfile::tempdir().expect("tempdir for hooks");
let prestart_marker = hooks_dir.path().join("prestart_ran");
let poststop_marker = hooks_dir.path().join("poststop_ran");
let bundle_dir = tempfile::tempdir().expect("tempdir");
std::os::unix::fs::symlink(&rootfs, bundle_dir.path().join("rootfs")).unwrap();
let config = format!(
r#"{{
"ociVersion": "1.0.2",
"root": {{"path": "rootfs"}},
"process": {{
"args": ["/bin/true"],
"cwd": "/",
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]
}},
"linux": {{
"namespaces": [{{"type": "mount"}}, {{"type": "uts"}}, {{"type": "pid"}}]
}},
"hooks": {{
"prestart": [{{"path": "/bin/sh", "args": ["/bin/sh", "-c", "touch {prestart}"]}}],
"poststop": [{{"path": "/bin/sh", "args": ["/bin/sh", "-c", "touch {poststop}"]}}]
}}
}}"#,
prestart = prestart_marker.display(),
poststop = poststop_marker.display(),
);
std::fs::write(bundle_dir.path().join("config.json"), &config).unwrap();
let id = format!("test-oci-hk-{}", std::process::id());
let (_, stderr, ok) = run_remora(&["create", &id, bundle_dir.path().to_str().unwrap()]);
assert!(ok, "remora create failed: {}", stderr);
assert!(prestart_marker.exists(), "prestart hook did not run");
let (_, stderr, ok) = run_remora(&["start", &id]);
assert!(ok, "remora start failed: {}", stderr);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
std::thread::sleep(std::time::Duration::from_millis(100));
let (stdout, _, _) = run_remora(&["state", &id]);
if stdout.contains("\"stopped\"") {
break;
}
if std::time::Instant::now() > deadline {
run_remora(&["delete", &id]);
panic!("container did not stop within 5 seconds");
}
}
let (_, stderr, ok) = run_remora(&["delete", &id]);
assert!(ok, "remora delete failed: {}", stderr);
assert!(poststop_marker.exists(), "poststop hook did not run");
}
#[test]
fn test_oci_seccomp() {
if !is_root() {
eprintln!("Skipping test_oci_seccomp: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_seccomp: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
std::os::unix::fs::symlink(&rootfs, bundle_dir.path().join("rootfs")).unwrap();
let config = r#"{
"ociVersion": "1.0.2",
"root": {"path": "rootfs"},
"process": {
"args": ["/bin/echo", "hello"],
"cwd": "/",
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]
},
"linux": {
"namespaces": [{"type": "mount"}, {"type": "uts"}, {"type": "pid"}],
"seccomp": {
"defaultAction": "SCMP_ACT_ALLOW",
"architectures": ["SCMP_ARCH_X86_64"],
"syscalls": [
{"names": ["ptrace", "personality", "bpf"], "action": "SCMP_ACT_ERRNO"}
]
}
}
}"#;
std::fs::write(bundle_dir.path().join("config.json"), config).unwrap();
let id = format!("test-oci-sec-{}", std::process::id());
oci_run_to_completion(&id, bundle_dir.path(), 5);
}
#[test]
fn test_oci_kernel_mounts() {
if !is_root() {
eprintln!("Skipping test_oci_kernel_mounts: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_kernel_mounts: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
std::os::unix::fs::symlink(&rootfs, bundle_dir.path().join("rootfs")).unwrap();
let config = r#"{
"ociVersion": "1.0.2",
"root": {"path": "rootfs"},
"process": {
"args": ["/bin/ls", "/proc/self"],
"cwd": "/",
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]
},
"linux": {
"namespaces": [{"type": "mount"}, {"type": "uts"}, {"type": "pid"}]
},
"mounts": [
{"destination": "/proc", "type": "proc", "source": "proc",
"options": ["nosuid","noexec","nodev"]},
{"destination": "/sys", "type": "sysfs", "source": "sysfs",
"options": ["nosuid","noexec","nodev","ro"]},
{"destination": "/dev", "type": "tmpfs", "source": "tmpfs",
"options": ["nosuid","strictatime","mode=755","size=65536k"]},
{"destination": "/dev/pts", "type": "devpts", "source": "devpts",
"options": ["nosuid","noexec","gid=5","mode=0620"]},
{"destination": "/dev/mqueue", "type": "mqueue", "source": "mqueue",
"options": ["nosuid","noexec","nodev"]}
]
}"#;
std::fs::write(bundle_dir.path().join("config.json"), config).unwrap();
let id = format!("test-oci-kmnt-{}", std::process::id());
let (_, stderr, ok) = run_remora(&[
"create",
"--bundle",
bundle_dir.path().to_str().unwrap(),
&id,
]);
assert!(ok, "remora create (kernel mounts) failed: {}", stderr);
let (_, stderr, ok) = run_remora(&["start", &id]);
assert!(ok, "remora start failed: {}", stderr);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
std::thread::sleep(std::time::Duration::from_millis(100));
let (stdout, _, _) = run_remora(&["state", &id]);
if stdout.contains("\"stopped\"") {
break;
}
if std::time::Instant::now() > deadline {
run_remora(&["delete", &id]);
panic!("container with kernel mounts did not stop within 5s");
}
}
run_remora(&["delete", &id]);
}
#[test]
fn test_oci_create_bundle_flag() {
if !is_root() {
eprintln!("Skipping test_oci_create_bundle_flag: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_create_bundle_flag: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
make_oci_bundle(bundle_dir.path(), &rootfs, &["/bin/sleep", "2"]);
let id = format!("test-oci-bflag-{}", std::process::id());
let (_, stderr, ok) = run_remora(&[
"create",
"--bundle",
bundle_dir.path().to_str().unwrap(),
&id,
]);
assert!(ok, "remora create --bundle failed: {}", stderr);
let (stdout, _, _) = run_remora(&["state", &id]);
assert!(
stdout.contains("\"created\""),
"expected created state, got: {}",
stdout
);
run_remora(&["kill", &id, "SIGKILL"]);
std::thread::sleep(std::time::Duration::from_millis(300));
run_remora(&["delete", &id]);
}
#[test]
fn test_oci_create_pid_file() {
if !is_root() {
eprintln!("Skipping test_oci_create_pid_file: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_create_pid_file: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
make_oci_bundle(bundle_dir.path(), &rootfs, &["/bin/sleep", "5"]);
let id = format!("test-oci-pidf-{}", std::process::id());
let pid_file = bundle_dir.path().join("container.pid");
let (_, stderr, ok) = run_remora(&[
"create",
"--bundle",
bundle_dir.path().to_str().unwrap(),
"--pid-file",
pid_file.to_str().unwrap(),
&id,
]);
assert!(ok, "remora create --pid-file failed: {}", stderr);
let pid_str = std::fs::read_to_string(&pid_file).expect("pid file not written");
let pid: i32 = pid_str
.trim()
.parse()
.expect("pid file contains non-integer");
assert!(pid > 1, "pid file contains invalid PID {}", pid);
let (state_out, _, _) = run_remora(&["state", &id]);
assert!(
state_out.contains(&pid.to_string()),
"pid file PID {} not found in state: {}",
pid,
state_out
);
run_remora(&["kill", &id, "SIGKILL"]);
std::thread::sleep(std::time::Duration::from_millis(300));
run_remora(&["delete", &id]);
}
#[test]
fn test_oci_rootfs_propagation() {
if !is_root() {
eprintln!("Skipping test_oci_rootfs_propagation: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_rootfs_propagation: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
let rootfs_link = bundle_dir.path().join("rootfs");
std::os::unix::fs::symlink(&rootfs, &rootfs_link).unwrap();
let config = r#"{
"ociVersion": "1.0.2",
"root": {"path": "rootfs"},
"process": {
"args": ["/bin/echo", "ok"],
"cwd": "/",
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]
},
"linux": {
"rootfsPropagation": "private",
"namespaces": [
{"type": "mount"},
{"type": "uts"},
{"type": "pid"}
]
}
}"#;
std::fs::write(bundle_dir.path().join("config.json"), config).unwrap();
let id = format!("test-oci-prop-{}", std::process::id());
oci_run_to_completion(&id, bundle_dir.path(), 10);
}
#[test]
fn test_oci_cgroups_path() {
if !is_root() {
eprintln!("Skipping test_oci_cgroups_path: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_cgroups_path: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
let rootfs_link = bundle_dir.path().join("rootfs");
std::os::unix::fs::symlink(&rootfs, &rootfs_link).unwrap();
let unique_cg = format!("remora-oci-test-{}", std::process::id());
let config = format!(
r#"{{
"ociVersion": "1.0.2",
"root": {{"path": "rootfs"}},
"process": {{
"args": ["/bin/echo", "ok"],
"cwd": "/",
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]
}},
"linux": {{
"cgroupsPath": "{}",
"namespaces": [
{{"type": "mount"}},
{{"type": "uts"}},
{{"type": "pid"}}
]
}}
}}"#,
unique_cg
);
std::fs::write(bundle_dir.path().join("config.json"), config).unwrap();
let id = format!("test-oci-cgpath-{}", std::process::id());
oci_run_to_completion(&id, bundle_dir.path(), 10);
}
#[test]
fn test_oci_create_container_hook_in_ns() {
if !is_root() {
eprintln!("Skipping test_oci_create_container_hook_in_ns: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_create_container_hook_in_ns: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
let rootfs_link = bundle_dir.path().join("rootfs");
std::os::unix::fs::symlink(&rootfs, &rootfs_link).unwrap();
let hook_out = bundle_dir.path().join("hook_ns.txt");
let hook_script = bundle_dir.path().join("record_ns.sh");
std::fs::write(
&hook_script,
format!(
"#!/bin/sh\nstat -Lc %i /proc/self/ns/uts > {}\n",
hook_out.display()
),
)
.unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&hook_script, std::fs::Permissions::from_mode(0o755)).unwrap();
let config = format!(
r#"{{
"ociVersion": "1.0.2",
"root": {{"path": "rootfs"}},
"process": {{
"args": ["/bin/sleep", "5"],
"cwd": "/",
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]
}},
"hooks": {{
"createContainer": [
{{"path": "{}"}}
]
}},
"linux": {{
"namespaces": [
{{"type": "mount"}},
{{"type": "uts"}},
{{"type": "pid"}}
]
}}
}}"#,
hook_script.display()
);
std::fs::write(bundle_dir.path().join("config.json"), &config).unwrap();
let id = format!("test-oci-cchook-{}", std::process::id());
let (_, stderr, ok) = run_remora(&[
"create",
"--bundle",
bundle_dir.path().to_str().unwrap(),
&id,
]);
assert!(ok, "remora create failed: {}", stderr);
assert!(hook_out.exists(), "hook did not produce output file");
let hook_inode: u64 = std::fs::read_to_string(&hook_out)
.expect("read hook output")
.trim()
.parse()
.expect("hook output not a number");
let host_uts_meta = std::fs::metadata("/proc/1/ns/uts").expect("stat /proc/1/ns/uts");
use std::os::unix::fs::MetadataExt;
let host_inode = host_uts_meta.ino();
assert_ne!(
hook_inode, host_inode,
"createContainer hook ran in host UTS namespace (inode {}), expected container ns",
hook_inode
);
run_remora(&["kill", &id, "SIGKILL"]);
std::thread::sleep(std::time::Duration::from_millis(300));
run_remora(&["delete", &id]);
}
#[test]
fn test_oci_start_container_hook_in_ns() {
if !is_root() {
eprintln!("Skipping test_oci_start_container_hook_in_ns: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_start_container_hook_in_ns: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
let rootfs_link = bundle_dir.path().join("rootfs");
std::os::unix::fs::symlink(&rootfs, &rootfs_link).unwrap();
let hook_out = bundle_dir.path().join("start_hook_ns.txt");
let hook_script = bundle_dir.path().join("record_start_ns.sh");
std::fs::write(
&hook_script,
format!(
"#!/bin/sh\nstat -Lc %i /proc/self/ns/uts > {}\n",
hook_out.display()
),
)
.unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&hook_script, std::fs::Permissions::from_mode(0o755)).unwrap();
let config = format!(
r#"{{
"ociVersion": "1.0.2",
"root": {{"path": "rootfs"}},
"process": {{
"args": ["/bin/echo", "ok"],
"cwd": "/",
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]
}},
"hooks": {{
"startContainer": [
{{"path": "{}"}}
]
}},
"linux": {{
"namespaces": [
{{"type": "mount"}},
{{"type": "uts"}},
{{"type": "pid"}}
]
}}
}}"#,
hook_script.display()
);
std::fs::write(bundle_dir.path().join("config.json"), &config).unwrap();
let id = format!("test-oci-schook-{}", std::process::id());
let (_, stderr, ok) = run_remora(&[
"create",
"--bundle",
bundle_dir.path().to_str().unwrap(),
&id,
]);
assert!(ok, "remora create failed: {}", stderr);
let (_, stderr, ok) = run_remora(&["start", &id]);
assert!(ok, "remora start failed: {}", stderr);
assert!(
hook_out.exists(),
"startContainer hook did not produce output file"
);
let hook_inode: u64 = std::fs::read_to_string(&hook_out)
.expect("read hook output")
.trim()
.parse()
.expect("hook output not a number");
let host_uts_meta = std::fs::metadata("/proc/1/ns/uts").expect("stat /proc/1/ns/uts");
use std::os::unix::fs::MetadataExt;
let host_inode = host_uts_meta.ino();
assert_ne!(
hook_inode, host_inode,
"startContainer hook ran in host UTS namespace (inode {}), expected container ns",
hook_inode
);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
std::thread::sleep(std::time::Duration::from_millis(100));
let (stdout, _, _) = run_remora(&["state", &id]);
if stdout.contains("\"stopped\"") {
break;
}
if std::time::Instant::now() > deadline {
break;
}
}
run_remora(&["delete", &id]);
}
}
mod rootless {
use super::*;
fn is_pasta_available() -> bool {
pelagos::network::is_pasta_available()
}
#[test]
fn test_rootless_basic() {
if is_root() {
eprintln!("Skipping test_rootless_basic: must run as non-root (no sudo)");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_rootless_basic: alpine-rootfs not found");
return;
}
};
let mut child = Command::new("/bin/ash")
.args(["-c", "id"])
.env("PATH", ALPINE_PATH)
.with_chroot(&rootfs)
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("rootless spawn failed");
let (status, stdout, _stderr) = child.wait_with_output().expect("wait failed");
assert!(status.success(), "rootless container exited non-zero");
let out = String::from_utf8_lossy(&stdout);
assert!(
out.contains("uid=0"),
"expected uid=0 inside rootless container, got: {}",
out
);
}
#[test]
fn test_rootless_loopback() {
if is_root() {
eprintln!("Skipping test_rootless_loopback: must run as non-root (no sudo)");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_rootless_loopback: alpine-rootfs not found");
return;
}
};
let mut child = Command::new("/bin/ash")
.args(["-c", "ip addr show lo | grep -q 'LOOPBACK,UP' && echo ok"])
.env("PATH", ALPINE_PATH)
.with_chroot(&rootfs)
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Loopback)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("rootless loopback spawn failed");
let (status, stdout, _) = child.wait_with_output().expect("wait failed");
assert!(status.success(), "rootless loopback container failed");
assert!(String::from_utf8_lossy(&stdout).contains("ok"));
}
#[test]
fn test_rootless_bridge_rejected() {
if is_root() {
eprintln!("Skipping test_rootless_bridge_rejected: must run as non-root (no sudo)");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_rootless_bridge_rejected: alpine-rootfs not found");
return;
}
};
let result = Command::new("/bin/echo")
.with_chroot(&rootfs)
.with_namespaces(Namespace::MOUNT)
.with_network(NetworkMode::Bridge)
.spawn();
match result {
Ok(_) => panic!("expected bridge networking to fail in rootless mode"),
Err(e) => {
let err_msg = format!("{}", e);
assert!(
err_msg.contains("rootless") || err_msg.contains("root"),
"error message should mention rootless/root: {}",
err_msg
);
}
}
}
#[test]
fn test_user_namespace_explicit() {
if !is_root() {
eprintln!("Skipping test_user_namespace_explicit: requires root");
return;
}
let mut child = Command::new("/usr/bin/id")
.with_namespaces(Namespace::USER)
.with_uid_maps(&[UidMap {
inside: 0,
outside: 0,
count: 1,
}])
.with_gid_maps(&[GidMap {
inside: 0,
outside: 0,
count: 1,
}])
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("user namespace spawn failed");
let (status, stdout, _) = child.wait_with_output().expect("wait failed");
assert!(status.success(), "user namespace container exited non-zero");
let out = String::from_utf8_lossy(&stdout);
assert!(
out.contains("uid=0"),
"expected uid=0 inside user namespace, got: {}",
out
);
}
#[test]
fn test_pasta_interface_exists() {
if is_root() {
eprintln!("Skipping test_pasta_interface_exists: pasta is designed for rootless mode");
return;
}
if !is_pasta_available() {
eprintln!("Skipping test_pasta_interface_exists: pasta not installed");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_pasta_interface_exists: alpine-rootfs not found");
return;
}
};
let mut child = Command::new("/bin/ash")
.args(["-c", "sleep 1 && ip addr show"])
.with_chroot(&rootfs)
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_proc_mount()
.with_network(NetworkMode::Pasta)
.env("PATH", ALPINE_PATH)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn failed");
let (status, stdout, _) = child.wait_with_output().expect("wait failed");
assert!(status.success(), "container exited non-zero");
let out = String::from_utf8_lossy(&stdout);
let has_non_loopback = out
.lines()
.any(|l| l.contains(": ") && !l.contains("lo:") && !l.contains(" lo@"));
assert!(
has_non_loopback,
"expected a non-loopback TAP interface from pasta, got:\n{}",
out
);
let has_tap_ip = out.lines().any(|l| {
let l = l.trim();
l.starts_with("inet ") && !l.starts_with("inet 127.")
});
assert!(
has_tap_ip,
"expected inet address on pasta TAP (--config-net), got:\n{}",
out
);
}
#[test]
fn test_pasta_rootless() {
if is_root() {
eprintln!("Skipping test_pasta_rootless: must run as non-root (no sudo)");
return;
}
if !is_pasta_available() {
eprintln!("Skipping test_pasta_rootless: pasta not installed");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_pasta_rootless: alpine-rootfs not found");
return;
}
};
let mut child = Command::new("/bin/ash")
.args(["-c", "sleep 1 && ip addr show"])
.with_chroot(&rootfs)
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_proc_mount()
.with_network(NetworkMode::Pasta)
.env("PATH", ALPINE_PATH)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn failed");
let (status, stdout, _) = child.wait_with_output().expect("wait failed");
assert!(status.success(), "container exited non-zero");
let out = String::from_utf8_lossy(&stdout);
let has_non_loopback = out
.lines()
.any(|l| l.contains(": ") && !l.contains("lo:") && !l.contains(" lo@"));
assert!(
has_non_loopback,
"expected a non-loopback TAP interface from pasta in rootless mode, got:\n{}",
out
);
let has_tap_ip = out.lines().any(|l| {
let l = l.trim();
l.starts_with("inet ") && !l.starts_with("inet 127.")
});
assert!(
has_tap_ip,
"expected inet address on pasta TAP in rootless mode, got:\n{}",
out
);
}
#[test]
fn test_pasta_connectivity() {
if is_root() {
eprintln!("Skipping test_pasta_connectivity: pasta is designed for rootless mode");
return;
}
if !is_pasta_available() {
eprintln!("Skipping test_pasta_connectivity: pasta not installed");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_pasta_connectivity: alpine-rootfs not found");
return;
}
};
let mut child = Command::new("/bin/ash")
.args([
"-c",
"sleep 2 && wget -q -T 5 --spider http://1.1.1.1/ && echo CONNECTED",
])
.with_chroot(&rootfs)
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_proc_mount()
.with_network(NetworkMode::Pasta)
.env("PATH", ALPINE_PATH)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn failed");
let (status, stdout, stderr) = child.wait_with_output().expect("wait failed");
let out = String::from_utf8_lossy(&stdout);
let err = String::from_utf8_lossy(&stderr);
assert!(status.success(),
"pasta connectivity test failed (is outbound internet available?)\nstdout: {}\nstderr: {}", out, err);
assert!(
out.contains("CONNECTED"),
"wget succeeded but CONNECTED marker missing:\n{}",
out
);
}
}
mod linking {
use super::*;
#[test]
#[serial]
fn test_container_link_hosts() {
if !is_root() {
eprintln!("Skipping test_container_link_hosts: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_container_link_hosts: alpine-rootfs not found");
return;
};
let mut child_a = Command::new("/bin/sleep")
.args(["60"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_chroot(&rootfs)
.with_proc_mount()
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn container A");
let ip_a = child_a
.container_ip()
.expect("container A should have bridge IP");
let state_dir = std::path::Path::new("/run/remora/containers/link-test-a");
std::fs::create_dir_all(state_dir).unwrap();
let state_json = format!(
r#"{{"name":"link-test-a","rootfs":"test","status":"running","pid":{},"watcher_pid":0,"started_at":"2026-01-01T00:00:00Z","exit_code":null,"command":["sleep","60"],"stdout_log":null,"stderr_log":null,"bridge_ip":"{}"}}"#,
child_a.pid(),
ip_a
);
std::fs::write(state_dir.join("state.json"), &state_json).unwrap();
let mut child_b = Command::new("/bin/cat")
.args(["/etc/hosts"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_chroot(&rootfs)
.with_proc_mount()
.with_link("link-test-a")
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn container B");
let (status, stdout, _) = child_b.wait_with_output().expect("wait B");
let out = String::from_utf8_lossy(&stdout);
unsafe {
libc::kill(child_a.pid(), libc::SIGKILL);
}
let _ = child_a.wait();
let _ = std::fs::remove_dir_all(state_dir);
assert!(status.success(), "Container B should exit successfully");
assert!(
out.contains(&ip_a) && out.contains("link-test-a"),
"B's /etc/hosts should contain A's IP ({}) and name, got:\n{}",
ip_a,
out
);
}
#[test]
#[serial]
fn test_container_link_alias() {
if !is_root() {
eprintln!("Skipping test_container_link_alias: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_container_link_alias: alpine-rootfs not found");
return;
};
let mut child_a = Command::new("/bin/sleep")
.args(["60"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_chroot(&rootfs)
.with_proc_mount()
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn container A");
let ip_a = child_a
.container_ip()
.expect("container A should have bridge IP");
let state_dir = std::path::Path::new("/run/remora/containers/link-alias-a");
std::fs::create_dir_all(state_dir).unwrap();
let state_json = format!(
r#"{{"name":"link-alias-a","rootfs":"test","status":"running","pid":{},"watcher_pid":0,"started_at":"2026-01-01T00:00:00Z","exit_code":null,"command":["sleep","60"],"stdout_log":null,"stderr_log":null,"bridge_ip":"{}"}}"#,
child_a.pid(),
ip_a
);
std::fs::write(state_dir.join("state.json"), &state_json).unwrap();
let mut child_b = Command::new("/bin/cat")
.args(["/etc/hosts"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_chroot(&rootfs)
.with_proc_mount()
.with_link_alias("link-alias-a", "db")
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn container B");
let (status, stdout, _) = child_b.wait_with_output().expect("wait B");
let out = String::from_utf8_lossy(&stdout);
unsafe {
libc::kill(child_a.pid(), libc::SIGKILL);
}
let _ = child_a.wait();
let _ = std::fs::remove_dir_all(state_dir);
assert!(status.success(), "Container B should exit successfully");
assert!(
out.contains(&ip_a) && out.contains("db"),
"B's /etc/hosts should contain A's IP ({}) and alias 'db', got:\n{}",
ip_a,
out
);
assert!(
out.contains("link-alias-a"),
"B's /etc/hosts should also contain A's original name, got:\n{}",
out
);
}
#[test]
#[serial]
fn test_container_link_ping() {
if !is_root() {
eprintln!("Skipping test_container_link_ping: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_container_link_ping: alpine-rootfs not found");
return;
};
let mut child_a = Command::new("/bin/sleep")
.args(["60"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_chroot(&rootfs)
.with_proc_mount()
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn container A");
let ip_a = child_a
.container_ip()
.expect("container A should have bridge IP");
let state_dir = std::path::Path::new("/run/remora/containers/link-ping-a");
std::fs::create_dir_all(state_dir).unwrap();
let state_json = format!(
r#"{{"name":"link-ping-a","rootfs":"test","status":"running","pid":{},"watcher_pid":0,"started_at":"2026-01-01T00:00:00Z","exit_code":null,"command":["sleep","60"],"stdout_log":null,"stderr_log":null,"bridge_ip":"{}"}}"#,
child_a.pid(),
ip_a
);
std::fs::write(state_dir.join("state.json"), &state_json).unwrap();
let mut child_b = Command::new("/bin/ping")
.args(["-c1", "-W2", "link-ping-a"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_chroot(&rootfs)
.with_proc_mount()
.with_link("link-ping-a")
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("Failed to spawn container B");
let (status, stdout, stderr) = child_b.wait_with_output().expect("wait B");
let out = String::from_utf8_lossy(&stdout);
let err = String::from_utf8_lossy(&stderr);
unsafe {
libc::kill(child_a.pid(), libc::SIGKILL);
}
let _ = child_a.wait();
let _ = std::fs::remove_dir_all(state_dir);
assert!(
status.success(),
"ping from B to A by name should succeed.\nstdout: {}\nstderr: {}",
out,
err
);
}
#[test]
#[serial]
fn test_container_link_tcp() {
if !is_root() {
eprintln!("Skipping test_container_link_tcp: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_container_link_tcp: alpine-rootfs not found");
return;
};
let mut child_a = Command::new("/bin/sh")
.args(["-c", "echo HELLO_FROM_A | nc -l -p 8080"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn container A");
let ip_a = child_a
.container_ip()
.expect("container A should have bridge IP");
let state_dir = std::path::Path::new("/run/remora/containers/link-tcp-a");
std::fs::create_dir_all(state_dir).unwrap();
let state_json = format!(
r#"{{"name":"link-tcp-a","rootfs":"test","status":"running","pid":{},"watcher_pid":0,"started_at":"2026-01-01T00:00:00Z","exit_code":null,"command":["/bin/sh"],"stdout_log":null,"stderr_log":null,"bridge_ip":"{}"}}"#,
child_a.pid(),
ip_a
);
std::fs::write(state_dir.join("state.json"), &state_json).unwrap();
std::thread::sleep(std::time::Duration::from_millis(500));
let mut child_b = Command::new("/bin/sh")
.args(["-c", "nc -w 2 link-tcp-a 8080"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_chroot(&rootfs)
.with_proc_mount()
.with_link("link-tcp-a")
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("Failed to spawn container B");
let (status_b, stdout_b, stderr_b) = child_b.wait_with_output().expect("wait B");
let out = String::from_utf8_lossy(&stdout_b);
let err = String::from_utf8_lossy(&stderr_b);
unsafe {
libc::kill(child_a.pid(), libc::SIGKILL);
}
let _ = child_a.wait();
let _ = std::fs::remove_dir_all(state_dir);
assert!(
status_b.success(),
"Container B should connect to A via TCP successfully.\nstdout: {}\nstderr: {}",
out,
err
);
assert!(
out.contains("HELLO_FROM_A"),
"B should receive 'HELLO_FROM_A' from A via TCP, got:\nstdout: {}\nstderr: {}",
out,
err
);
}
#[test]
fn test_container_link_missing() {
if !is_root() {
eprintln!("Skipping test_container_link_missing: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_container_link_missing: alpine-rootfs not found");
return;
};
let result = Command::new("/bin/true")
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_chroot(&rootfs)
.with_proc_mount()
.with_link("nonexistent-container-xyz")
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn();
match result {
Ok(_) => panic!("spawn should fail when linked container doesn't exist"),
Err(e) => {
let err_msg = format!("{}", e);
assert!(
err_msg.contains("nonexistent-container-xyz"),
"error should mention the missing container name, got: {}",
err_msg
);
}
}
}
}
mod images {
use super::*;
fn copy_rootfs(rootfs: &std::path::Path, dest: &std::path::Path) {
let status = std::process::Command::new("rsync")
.args(["-a", "--exclude=/sys", "--exclude=/proc", "--exclude=/dev"])
.arg(rootfs.to_str().unwrap().to_string() + "/")
.arg(dest.to_str().unwrap().to_string() + "/")
.status()
.expect("rsync rootfs to layer (is rsync installed?)");
assert!(status.success(), "rsync should succeed");
std::fs::create_dir_all(dest.join("proc")).unwrap();
std::fs::create_dir_all(dest.join("sys")).unwrap();
std::fs::create_dir_all(dest.join("dev")).unwrap();
}
#[test]
#[serial]
fn test_layer_extraction() {
if !is_root() {
eprintln!("Skipping test_layer_extraction: requires root");
return;
}
use pelagos::image;
let tmp_dir = tempfile::tempdir().expect("create tempdir");
let tar_gz_path = tmp_dir.path().join("layer.tar.gz");
{
let file = std::fs::File::create(&tar_gz_path).expect("create tar.gz");
let gz = flate2::write::GzEncoder::new(file, flate2::Compression::default());
let mut builder = tar::Builder::new(gz);
let data = b"hello from layer";
let mut header = tar::Header::new_gnu();
header.set_size(data.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder
.append_data(&mut header, "test-file.txt", &data[..])
.unwrap();
let data2 = b"nested content";
let mut header2 = tar::Header::new_gnu();
header2.set_size(data2.len() as u64);
header2.set_mode(0o644);
header2.set_cksum();
builder
.append_data(&mut header2, "subdir/nested.txt", &data2[..])
.unwrap();
builder.finish().unwrap();
}
let digest = "sha256:test_layer_extraction_deadbeef";
let layer_path = image::layer_dir(digest);
let _ = std::fs::remove_dir_all(&layer_path);
let result = image::extract_layer(digest, &tar_gz_path);
assert!(
result.is_ok(),
"extract_layer should succeed: {:?}",
result.err()
);
let extracted = result.unwrap();
assert!(
extracted.join("test-file.txt").exists(),
"test-file.txt should exist"
);
assert_eq!(
std::fs::read_to_string(extracted.join("test-file.txt")).unwrap(),
"hello from layer"
);
assert!(
extracted.join("subdir/nested.txt").exists(),
"subdir/nested.txt should exist"
);
assert_eq!(
std::fs::read_to_string(extracted.join("subdir/nested.txt")).unwrap(),
"nested content"
);
let _ = std::fs::remove_dir_all(&layer_path);
}
#[test]
#[serial]
fn test_multi_layer_overlay_merge() {
if !is_root() {
eprintln!("Skipping test_multi_layer_overlay_merge: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_multi_layer_overlay_merge: alpine-rootfs not found");
return;
};
let bottom = tempfile::tempdir().expect("bottom layer dir");
let top = tempfile::tempdir().expect("top layer dir");
copy_rootfs(&rootfs, bottom.path());
std::fs::write(bottom.path().join("layer-bottom"), "bottom").unwrap();
std::fs::write(top.path().join("layer-top"), "top").unwrap();
let layers = vec![top.path().to_path_buf(), bottom.path().to_path_buf()];
let mut child = Command::new("/bin/sh")
.args(["-c", "cat /layer-bottom && echo --- && cat /layer-top"])
.with_image_layers(layers)
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn with image layers");
let (status, stdout, stderr) = child.wait_with_output().expect("wait");
let out = String::from_utf8_lossy(&stdout);
let err = String::from_utf8_lossy(&stderr);
assert!(status.success(), "container should exit 0, stderr: {}", err);
assert!(
out.contains("bottom"),
"should see bottom layer file, got: {}",
out
);
assert!(
out.contains("top"),
"should see top layer file, got: {}",
out
);
}
#[test]
#[serial]
fn test_multi_layer_overlay_shadow() {
if !is_root() {
eprintln!("Skipping test_multi_layer_overlay_shadow: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_multi_layer_overlay_shadow: alpine-rootfs not found");
return;
};
let bottom = tempfile::tempdir().expect("bottom layer dir");
let top = tempfile::tempdir().expect("top layer dir");
copy_rootfs(&rootfs, bottom.path());
std::fs::write(bottom.path().join("shadow-file"), "bottom-value").unwrap();
std::fs::write(top.path().join("shadow-file"), "top-value").unwrap();
let layers = vec![top.path().to_path_buf(), bottom.path().to_path_buf()];
let mut child = Command::new("/bin/cat")
.args(["/shadow-file"])
.with_image_layers(layers)
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn with image layers");
let (status, stdout, stderr) = child.wait_with_output().expect("wait");
let out = String::from_utf8_lossy(&stdout);
let err = String::from_utf8_lossy(&stderr);
assert!(status.success(), "container should exit 0, stderr: {}", err);
assert_eq!(
out.trim(),
"top-value",
"top layer should shadow bottom, got: {}",
out
);
}
#[test]
#[serial]
fn test_image_layers_cleanup() {
if !is_root() {
eprintln!("Skipping test_image_layers_cleanup: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_image_layers_cleanup: alpine-rootfs not found");
return;
};
let layer = tempfile::tempdir().expect("layer dir");
copy_rootfs(&rootfs, layer.path());
let layers = vec![layer.path().to_path_buf()];
let mut child = Command::new("/bin/true")
.with_image_layers(layers)
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("spawn");
let merged = child
.overlay_merged_dir()
.expect("should have merged dir")
.to_path_buf();
let overlay_base = merged
.parent()
.expect("merged should have parent")
.to_path_buf();
assert!(
overlay_base.exists(),
"overlay base dir should exist before wait"
);
let status = child.wait().expect("wait");
assert!(status.success(), "container should exit 0");
assert!(
!overlay_base.exists(),
"overlay base dir should be cleaned up after wait: {:?}",
overlay_base
);
}
#[test]
#[ignore]
#[serial]
fn test_pull_and_run_real_image() {
if !is_root() {
eprintln!("Skipping test_pull_and_run_real_image: requires root");
return;
}
use pelagos::image;
let reference = "docker.io/library/alpine:latest";
let pull_status = std::process::Command::new(env!("CARGO_BIN_EXE_remora"))
.args(["image", "pull", "alpine"])
.status()
.expect("failed to run remora image pull");
assert!(pull_status.success(), "remora image pull should succeed");
let manifest =
image::load_image(reference).expect("image manifest should be loadable after pull");
let layers = image::layer_dirs(&manifest);
assert!(!layers.is_empty(), "alpine should have at least one layer");
let mut child = Command::new("/bin/sh")
.args(["-c", "cat /etc/alpine-release"])
.with_image_layers(layers)
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn from real image");
let (status, stdout, stderr) = child.wait_with_output().expect("wait");
let out = String::from_utf8_lossy(&stdout).trim().to_string();
let err = String::from_utf8_lossy(&stderr);
assert!(status.success(), "container should exit 0, stderr: {}", err);
assert!(
out.chars().next().is_some_and(|c| c.is_ascii_digit()) && out.contains('.'),
"expected Alpine version string, got: '{}'",
out
);
println!("Alpine version from real image: {}", out);
let _ = image::remove_image(reference);
}
}
mod exec {
use super::*;
use std::os::unix::io::AsRawFd;
fn build_exec_command(pid: i32, exe: &str, args: &[&str]) -> Command {
let ns_types: &[(&str, Namespace)] = &[
("mnt", Namespace::MOUNT),
("uts", Namespace::UTS),
("ipc", Namespace::IPC),
("net", Namespace::NET),
("pid", Namespace::PID),
("user", Namespace::USER),
("cgroup", Namespace::CGROUP),
];
let mut cmd = Command::new(exe).args(args);
let mut has_mount_ns = false;
let mut pid_ns_found = false;
for &(ns_name, ns_flag) in ns_types {
let container_ns = format!("/proc/{}/ns/{}", pid, ns_name);
let init_ns = format!("/proc/1/ns/{}", ns_name);
let c_ino = std::fs::metadata(&container_ns).map(|m| {
use std::os::unix::fs::MetadataExt;
m.ino()
});
let i_ino = std::fs::metadata(&init_ns).map(|m| {
use std::os::unix::fs::MetadataExt;
m.ino()
});
if let (Ok(c), Ok(i)) = (c_ino, i_ino) {
if c != i {
if ns_flag == Namespace::MOUNT {
has_mount_ns = true;
} else {
if ns_flag == Namespace::PID {
pid_ns_found = true;
}
cmd = cmd.with_namespace_join(&container_ns, ns_flag);
}
}
}
}
if !pid_ns_found {
let pfc_path = format!("/proc/{}/ns/pid_for_children", pid);
let pfc_ino = std::fs::metadata(&pfc_path).map(|m| {
use std::os::unix::fs::MetadataExt;
m.ino()
});
let init_pid_ino = std::fs::metadata("/proc/1/ns/pid").map(|m| {
use std::os::unix::fs::MetadataExt;
m.ino()
});
if let (Ok(pfc), Ok(init)) = (pfc_ino, init_pid_ino) {
if pfc != init {
cmd = cmd.with_namespace_join(&pfc_path, Namespace::PID);
}
}
}
if has_mount_ns {
let mnt_ns_path = format!("/proc/{}/ns/mnt", pid);
let mnt_ns_file = std::fs::File::open(&mnt_ns_path).expect("open mount ns");
let mnt_ns_fd = mnt_ns_file.as_raw_fd();
let root_path = format!("/proc/{}/root", pid);
let root_file = std::fs::File::open(&root_path).expect("open container root");
let root_fd = root_file.as_raw_fd();
cmd = cmd.with_pre_exec(move || {
let _keep_mnt = &mnt_ns_file;
let _keep_root = &root_file;
unsafe {
if libc::setns(mnt_ns_fd, libc::CLONE_NEWNS) != 0 {
return Err(std::io::Error::last_os_error());
}
if libc::fchdir(root_fd) != 0 {
return Err(std::io::Error::last_os_error());
}
let dot = std::ffi::CString::new(".").unwrap();
if libc::chroot(dot.as_ptr()) != 0 {
return Err(std::io::Error::last_os_error());
}
let slash = std::ffi::CString::new("/").unwrap();
if libc::chdir(slash.as_ptr()) != 0 {
return Err(std::io::Error::last_os_error());
}
}
Ok(())
});
} else {
cmd = cmd.with_chroot(format!("/proc/{}/root", pid));
}
cmd
}
#[test]
#[serial]
fn test_exec_basic() {
if !is_root() {
eprintln!("Skipping test_exec_basic (requires root)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping (no rootfs)");
return;
}
};
let mut container = Command::new("/bin/sleep")
.args(["30"])
.with_chroot(&rootfs)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("spawn container");
let pid = container.pid();
let cmd = build_exec_command(pid, "/bin/cat", &["/etc/os-release"])
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped);
let mut exec_child = cmd.spawn().expect("exec spawn");
let (status, stdout, stderr) = exec_child.wait_with_output().expect("exec wait");
unsafe {
libc::kill(pid, libc::SIGKILL);
}
let _ = container.wait();
let out = String::from_utf8_lossy(&stdout);
let err = String::from_utf8_lossy(&stderr);
assert!(status.success(), "exec should exit 0, stderr: {}", err);
assert!(
out.contains("Alpine"),
"exec should see Alpine os-release, got: {}",
out
);
}
#[test]
#[serial]
fn test_exec_sees_container_filesystem() {
if !is_root() {
eprintln!("Skipping test_exec_sees_container_filesystem (requires root)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping (no rootfs)");
return;
}
};
let mut container = Command::new("/bin/sh")
.args([
"-c",
"echo EXEC_MARKER_12345 > /tmp/exec-marker && sleep 30",
])
.with_chroot(&rootfs)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_tmpfs("/tmp", "")
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("spawn container");
let pid = container.pid();
std::thread::sleep(std::time::Duration::from_millis(500));
let cmd = build_exec_command(pid, "/bin/cat", &["/tmp/exec-marker"])
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped);
let mut exec_child = cmd.spawn().expect("exec spawn");
let (status, stdout, stderr) = exec_child.wait_with_output().expect("exec wait");
unsafe {
libc::kill(pid, libc::SIGKILL);
}
let _ = container.wait();
let out = String::from_utf8_lossy(&stdout).trim().to_string();
let err = String::from_utf8_lossy(&stderr);
assert!(status.success(), "exec should exit 0, stderr: {}", err);
assert_eq!(
out, "EXEC_MARKER_12345",
"exec should see the container's tmpfs marker"
);
}
#[test]
#[serial]
fn test_exec_environment() {
if !is_root() {
eprintln!("Skipping test_exec_environment (requires root)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping (no rootfs)");
return;
}
};
let mut container = Command::new("/bin/sleep")
.args(["30"])
.with_chroot(&rootfs)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.env("PATH", ALPINE_PATH)
.env("FOO", "bar_from_container")
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("spawn container");
let pid = container.pid();
std::thread::sleep(std::time::Duration::from_millis(100));
let environ_path = format!("/proc/{}/environ", pid);
let environ_data = std::fs::read(&environ_path).expect("read /proc/pid/environ");
let env_pairs: Vec<(String, String)> = environ_data
.split(|&b| b == 0)
.filter(|s| !s.is_empty())
.filter_map(|entry| {
let s = String::from_utf8_lossy(entry);
let (k, v) = s.split_once('=')?;
Some((k.to_string(), v.to_string()))
})
.collect();
let mut cmd = build_exec_command(pid, "/bin/sh", &["-c", "echo $FOO"]);
for (k, v) in &env_pairs {
cmd = cmd.env(k, v);
}
cmd = cmd
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped);
let mut exec_child = cmd.spawn().expect("exec spawn");
let (status, stdout, stderr) = exec_child.wait_with_output().expect("exec wait");
unsafe {
libc::kill(pid, libc::SIGKILL);
}
let _ = container.wait();
let out = String::from_utf8_lossy(&stdout).trim().to_string();
let err = String::from_utf8_lossy(&stderr);
assert!(status.success(), "exec should exit 0, stderr: {}", err);
assert_eq!(
out, "bar_from_container",
"exec should see container's FOO env var"
);
}
#[test]
#[serial]
fn test_exec_nonrunning_container_fails() {
if !is_root() {
eprintln!("Skipping test_exec_nonrunning_container_fails (requires root)");
return;
}
let alive = unsafe { libc::kill(999999, 0) == 0 };
assert!(!alive, "PID 999999 should not be alive");
let root_path = std::path::Path::new("/proc/999999/root");
assert!(!root_path.exists(), "/proc/999999/root should not exist");
}
#[test]
#[serial]
fn test_exec_joins_pid_namespace() {
if !is_root() {
eprintln!("Skipping test_exec_joins_pid_namespace (requires root)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping test_exec_joins_pid_namespace (no rootfs)");
return;
}
};
let bin = env!("CARGO_BIN_EXE_remora");
let name = "remora-exec-pid-ns-test";
let _ = std::process::Command::new(bin)
.args(["rm", "-f", name])
.output();
let run_status = std::process::Command::new(bin)
.args([
"run",
"-d",
"--name",
name,
"--rootfs",
rootfs.to_str().unwrap(),
"/bin/sleep",
"30",
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("remora run -d");
assert!(run_status.success(), "remora run -d failed");
let state_path = format!("/run/remora/containers/{}/state.json", name);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let mut started = false;
while std::time::Instant::now() < deadline {
if let Ok(data) = std::fs::read_to_string(&state_path) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&data) {
if v["pid"].as_i64().unwrap_or(0) > 0 {
started = true;
break;
}
}
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
assert!(started, "container did not start within 10s");
let state_data = std::fs::read_to_string(&state_path).expect("read state.json");
let state: serde_json::Value = serde_json::from_str(&state_data).expect("parse state.json");
let intermediate_pid = state["pid"].as_i64().expect("state.pid") as i32;
let pfc_link = format!("/proc/{}/ns/pid_for_children", intermediate_pid);
let container_pid_ns = std::fs::read_link(&pfc_link)
.expect("read pid_for_children link")
.to_string_lossy()
.into_owned();
let exec_out = std::process::Command::new(bin)
.args(["exec", name, "readlink", "/proc/self/ns/pid"])
.output()
.expect("remora exec readlink /proc/self/ns/pid");
let _ = std::process::Command::new(bin)
.args(["stop", name])
.output();
let _ = std::process::Command::new(bin)
.args(["rm", "-f", name])
.output();
assert!(
exec_out.status.success(),
"remora exec readlink failed: {}",
String::from_utf8_lossy(&exec_out.stderr)
);
let exec_pid_ns = String::from_utf8_lossy(&exec_out.stdout).trim().to_string();
assert_eq!(
exec_pid_ns, container_pid_ns,
"exec'd process should be in the container's PID namespace ({}), \
but is in namespace ({}). \
This means discover_namespaces did not join pid_for_children.",
container_pid_ns, exec_pid_ns
);
}
}
mod watcher {
use super::*;
#[test]
#[serial]
fn test_watcher_kill_propagates_to_container() {
if !is_root() {
eprintln!("Skipping test_watcher_kill_propagates_to_container (requires root)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping test_watcher_kill_propagates_to_container (no rootfs)");
return;
}
};
let bin = env!("CARGO_BIN_EXE_remora");
let name = "remora-watcher-subreaper-test";
let _ = std::process::Command::new(bin)
.args(["rm", "-f", name])
.output();
let run_status = std::process::Command::new(bin)
.args([
"run",
"-d",
"--name",
name,
"--rootfs",
rootfs.to_str().unwrap(),
"/bin/sleep",
"300",
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("remora run -d");
assert!(run_status.success(), "remora run -d failed");
let state_path = format!("/run/remora/containers/{}/state.json", name);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let mut container_pid: i32 = 0;
while std::time::Instant::now() < deadline {
if let Ok(data) = std::fs::read_to_string(&state_path) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&data) {
let pid = v["pid"].as_i64().unwrap_or(0) as i32;
if pid > 0 {
container_pid = pid;
break;
}
}
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
assert!(container_pid > 0, "container did not start within 10s");
let watcher_pid = {
let status_path = format!("/proc/{}/status", container_pid);
let status_data = std::fs::read_to_string(&status_path).expect("read /proc/<P>/status");
let ppid_line = status_data
.lines()
.find(|l| l.starts_with("PPid:"))
.expect("PPid line in /proc status");
ppid_line
.split_whitespace()
.nth(1)
.unwrap()
.parse::<i32>()
.unwrap()
};
assert!(watcher_pid > 1, "watcher PID should be > 1");
let alive_before = unsafe { libc::kill(container_pid, 0) == 0 };
assert!(
alive_before,
"container process should be alive before test"
);
let kill_ret = unsafe { libc::kill(watcher_pid, libc::SIGKILL) };
assert_eq!(kill_ret, 0, "failed to kill watcher");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
let mut container_died = false;
while std::time::Instant::now() < deadline {
let ret = unsafe { libc::kill(container_pid, 0) };
if ret != 0 {
container_died = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
let _ = std::process::Command::new(bin)
.args(["rm", "-f", name])
.output();
assert!(
container_died,
"container process (pid {}) did not die within 3s after watcher (pid {}) was killed; \
PR_SET_CHILD_SUBREAPER may not be in effect",
container_pid, watcher_pid
);
}
}
mod dev {
use super::*;
#[test]
#[serial]
fn test_dev_minimal_devices() {
if !is_root() {
eprintln!("Skipping test_dev_minimal_devices: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_dev_minimal_devices: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ls")
.args(["/dev/"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.with_chroot(&rootfs)
.with_proc_mount()
.with_dev_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("Failed to spawn container");
let (status, stdout_bytes, _) = child.wait_with_output().expect("Failed to wait for child");
let stdout = String::from_utf8_lossy(&stdout_bytes);
assert!(status.success(), "ls /dev/ failed: {}", stdout);
for dev in &["null", "zero", "random", "urandom", "full", "tty"] {
assert!(
stdout.contains(dev),
"/dev/ should contain '{}', got: {}",
dev,
stdout
);
}
for bad in &["sda", "nvme", "video"] {
assert!(
!stdout.contains(bad),
"/dev/ should NOT contain host device '{}', got: {}",
bad,
stdout
);
}
}
#[test]
#[serial]
fn test_dev_null_works() {
if !is_root() {
eprintln!("Skipping test_dev_null_works: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_dev_null_works: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "echo ok > /dev/null && echo pass"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.with_chroot(&rootfs)
.with_proc_mount()
.with_dev_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("Failed to spawn container");
let (status, stdout_bytes, _) = child.wait_with_output().expect("Failed to wait for child");
let stdout = String::from_utf8_lossy(&stdout_bytes);
assert!(status.success(), "command failed: {}", stdout);
assert!(
stdout.contains("pass"),
"expected 'pass' in output, got: {}",
stdout
);
}
#[test]
#[serial]
fn test_dev_zero_works() {
if !is_root() {
eprintln!("Skipping test_dev_zero_works: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_dev_zero_works: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "head -c 4 /dev/zero | wc -c"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.with_chroot(&rootfs)
.with_proc_mount()
.with_dev_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("Failed to spawn container");
let (status, stdout_bytes, _) = child.wait_with_output().expect("Failed to wait for child");
let stdout = String::from_utf8_lossy(&stdout_bytes);
assert!(status.success(), "command failed: {}", stdout);
assert!(
stdout.trim().contains('4'),
"expected '4' in output, got: {}",
stdout
);
}
#[test]
#[serial]
fn test_dev_symlinks() {
if !is_root() {
eprintln!("Skipping test_dev_symlinks: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_dev_symlinks: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args([
"-c",
"test -L /dev/fd && test -L /dev/stdin && test -L /dev/stdout && test -L /dev/stderr && echo ok",
])
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.with_chroot(&rootfs)
.with_proc_mount()
.with_dev_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("Failed to spawn container");
let (status, stdout_bytes, _) = child.wait_with_output().expect("Failed to wait for child");
let stdout = String::from_utf8_lossy(&stdout_bytes);
assert!(status.success(), "symlink check failed: {}", stdout);
assert!(
stdout.contains("ok"),
"expected 'ok' in output, got: {}",
stdout
);
}
#[test]
#[serial]
fn test_dev_pts_exists() {
if !is_root() {
eprintln!("Skipping test_dev_pts_exists: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_dev_pts_exists: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "test -d /dev/pts && test -d /dev/shm && echo ok"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.with_chroot(&rootfs)
.with_proc_mount()
.with_dev_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("Failed to spawn container");
let (status, stdout_bytes, _) = child.wait_with_output().expect("Failed to wait for child");
let stdout = String::from_utf8_lossy(&stdout_bytes);
assert!(status.success(), "/dev/pts or /dev/shm missing: {}", stdout);
assert!(
stdout.contains("ok"),
"expected 'ok' in output, got: {}",
stdout
);
}
}
mod rootless_cgroups {
use super::*;
fn skip_unless_delegation() -> bool {
if !pelagos::cgroup_rootless::is_delegation_available() {
eprintln!("Skipping: cgroup v2 delegation not available");
return false;
}
true
}
fn read_cgroup_knob(pid: i32, knob: &str) -> Option<String> {
let parent =
pelagos::cgroup_rootless::self_cgroup_path().expect("self_cgroup_path should work");
let path = parent.join(format!("remora-{}", pid)).join(knob);
match std::fs::read_to_string(&path) {
Ok(s) => Some(s),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => panic!("failed to read {}: {}", path.display(), e),
}
}
#[test]
fn test_rootless_cgroup_memory() {
if is_root() {
eprintln!("Skipping test_rootless_cgroup_memory: must run as non-root");
return;
}
if !skip_unless_delegation() {
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_rootless_cgroup_memory: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/sleep")
.args(["10"])
.with_namespaces(Namespace::USER | Namespace::MOUNT | Namespace::UTS)
.with_chroot(&rootfs)
.with_uid_maps(&[UidMap {
inside: 0,
outside: unsafe { libc::getuid() },
count: 1,
}])
.with_gid_maps(&[GidMap {
inside: 0,
outside: unsafe { libc::getgid() },
count: 1,
}])
.env("PATH", ALPINE_PATH)
.with_cgroup_memory(64 * 1024 * 1024) .stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn with rootless cgroup memory");
let val = read_cgroup_knob(child.pid(), "memory.max");
unsafe {
libc::kill(child.pid(), libc::SIGKILL);
}
child.wait().expect("Failed to wait");
match val {
Some(v) => assert_eq!(
v.trim(),
"67108864",
"expected 64MB in memory.max, got: {}",
v.trim()
),
None => eprintln!(
"Skipping memory assertion: memory controller not delegated to sub-cgroup"
),
}
}
#[test]
fn test_rootless_cgroup_pids() {
if is_root() {
eprintln!("Skipping test_rootless_cgroup_pids: must run as non-root");
return;
}
if !skip_unless_delegation() {
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_rootless_cgroup_pids: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/sleep")
.args(["10"])
.with_namespaces(Namespace::USER | Namespace::MOUNT | Namespace::UTS)
.with_chroot(&rootfs)
.with_uid_maps(&[UidMap {
inside: 0,
outside: unsafe { libc::getuid() },
count: 1,
}])
.with_gid_maps(&[GidMap {
inside: 0,
outside: unsafe { libc::getgid() },
count: 1,
}])
.env("PATH", ALPINE_PATH)
.with_cgroup_pids_limit(16)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn with rootless cgroup pids");
let val = read_cgroup_knob(child.pid(), "pids.max");
unsafe {
libc::kill(child.pid(), libc::SIGKILL);
}
child.wait().expect("Failed to wait");
match val {
Some(v) => assert_eq!(v.trim(), "16", "expected 16 in pids.max, got: {}", v.trim()),
None => {
eprintln!("Skipping pids assertion: pids controller not delegated to sub-cgroup")
}
}
}
#[test]
fn test_rootless_cgroup_cleanup() {
if is_root() {
eprintln!("Skipping test_rootless_cgroup_cleanup: must run as non-root");
return;
}
if !skip_unless_delegation() {
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_rootless_cgroup_cleanup: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/true")
.with_namespaces(Namespace::USER | Namespace::MOUNT | Namespace::UTS)
.with_chroot(&rootfs)
.with_uid_maps(&[UidMap {
inside: 0,
outside: unsafe { libc::getuid() },
count: 1,
}])
.with_gid_maps(&[GidMap {
inside: 0,
outside: unsafe { libc::getgid() },
count: 1,
}])
.env("PATH", ALPINE_PATH)
.with_cgroup_memory(32 * 1024 * 1024)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn");
let pid = child.pid();
child.wait().expect("Failed to wait");
let cg_parent =
pelagos::cgroup_rootless::self_cgroup_path().expect("self_cgroup_path should work");
let cg_dir = cg_parent.join(format!("remora-{}", pid));
for _ in 0..10 {
if !cg_dir.exists() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(50));
let _ = std::fs::remove_dir(&cg_dir);
}
assert!(
!cg_dir.exists(),
"cgroup dir should have been removed: {}",
cg_dir.display()
);
}
}
mod json_output {
use super::*;
fn remora(args: &[&str]) -> (String, String, bool) {
let output = std::process::Command::new(env!("CARGO_BIN_EXE_remora"))
.args(args)
.output()
.expect("failed to run remora binary");
(
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
output.status.success(),
)
}
#[test]
#[serial]
fn test_volume_ls_json() {
if !is_root() {
eprintln!("Skipping test_volume_ls_json: requires root");
return;
}
let vol_name = "test-json-vol";
let _ = remora(&["volume", "rm", vol_name]);
let (_, stderr, ok) = remora(&["volume", "create", vol_name]);
assert!(ok, "volume create failed: {}", stderr);
let (stdout, stderr, ok) = remora(&["volume", "ls", "--format", "json"]);
assert!(ok, "volume ls --format json failed: {}", stderr);
let parsed: serde_json::Value =
serde_json::from_str(&stdout).expect("volume ls JSON should parse");
let arr = parsed.as_array().expect("expected JSON array");
let found = arr
.iter()
.any(|v| v.get("name").and_then(|n| n.as_str()) == Some(vol_name));
assert!(
found,
"volume '{}' not in JSON output: {}",
vol_name, stdout
);
let entry = arr
.iter()
.find(|v| v.get("name").and_then(|n| n.as_str()) == Some(vol_name))
.unwrap();
assert!(
entry.get("path").and_then(|p| p.as_str()).is_some(),
"volume JSON entry should have a 'path' field"
);
let (_, stderr, ok) = remora(&["volume", "rm", vol_name]);
assert!(ok, "volume rm failed: {}", stderr);
let (stdout, _, ok) = remora(&["volume", "ls", "--format", "json"]);
assert!(ok, "volume ls --format json failed after rm");
let parsed: serde_json::Value =
serde_json::from_str(&stdout).expect("volume ls JSON should parse after rm");
let arr = parsed.as_array().expect("expected JSON array");
let found = arr
.iter()
.any(|v| v.get("name").and_then(|n| n.as_str()) == Some(vol_name));
assert!(
!found,
"volume '{}' should not appear after rm: {}",
vol_name, stdout
);
}
#[test]
#[serial]
fn test_rootfs_ls_json() {
if !is_root() {
eprintln!("Skipping test_rootfs_ls_json: requires root");
return;
}
let name = "test-json-rootfs";
let _ = remora(&["rootfs", "rm", name]);
let (_, stderr, ok) = remora(&["rootfs", "import", name, "/tmp"]);
assert!(ok, "rootfs import failed: {}", stderr);
let (stdout, stderr, ok) = remora(&["rootfs", "ls", "--format", "json"]);
assert!(ok, "rootfs ls --format json failed: {}", stderr);
let parsed: serde_json::Value =
serde_json::from_str(&stdout).expect("rootfs ls JSON should parse");
let arr = parsed.as_array().expect("expected JSON array");
let entry = arr
.iter()
.find(|v| v.get("name").and_then(|n| n.as_str()) == Some(name));
assert!(
entry.is_some(),
"rootfs '{}' not in JSON output: {}",
name,
stdout
);
assert!(
entry
.unwrap()
.get("path")
.and_then(|p| p.as_str())
.is_some(),
"rootfs JSON entry should have a 'path' field"
);
let (_, stderr, ok) = remora(&["rootfs", "rm", name]);
assert!(ok, "rootfs rm failed: {}", stderr);
let (stdout, _, ok) = remora(&["rootfs", "ls", "--format", "json"]);
assert!(ok, "rootfs ls --format json failed after rm");
let parsed: serde_json::Value =
serde_json::from_str(&stdout).expect("rootfs ls JSON should parse after rm");
let arr = parsed.as_array().expect("expected JSON array");
let found = arr
.iter()
.any(|v| v.get("name").and_then(|n| n.as_str()) == Some(name));
assert!(
!found,
"rootfs '{}' should not appear after rm: {}",
name, stdout
);
}
#[test]
#[serial]
fn test_ps_json_and_inspect() {
if !is_root() {
eprintln!("Skipping test_ps_json_and_inspect: requires root");
return;
}
let name = "test-json-ctr";
let _ = remora(&["rm", name]);
let ctr_dir = pelagos::paths::containers_dir().join(name);
std::fs::create_dir_all(&ctr_dir).expect("create container dir");
let state = serde_json::json!({
"name": name,
"rootfs": "alpine",
"status": "exited",
"pid": 0,
"watcher_pid": 0,
"started_at": "2026-01-01T00:00:00Z",
"exit_code": 0,
"command": ["/bin/sh"],
"stdout_log": null,
"stderr_log": null
});
std::fs::write(
ctr_dir.join("state.json"),
serde_json::to_string_pretty(&state).unwrap(),
)
.expect("write state.json");
let (stdout, stderr, ok) = remora(&["ps", "-a", "--format", "json"]);
assert!(ok, "ps --format json failed: {}", stderr);
let parsed: serde_json::Value =
serde_json::from_str(&stdout).expect("ps JSON should parse");
let arr = parsed.as_array().expect("expected JSON array");
let found = arr
.iter()
.any(|v| v.get("name").and_then(|n| n.as_str()) == Some(name));
assert!(
found,
"container '{}' not in ps JSON output: {}",
name, stdout
);
let (stdout, stderr, ok) = remora(&["container", "inspect", name]);
assert!(ok, "container inspect failed: {}", stderr);
let obj: serde_json::Value =
serde_json::from_str(&stdout).expect("inspect JSON should parse");
assert!(obj.is_object(), "inspect should return a JSON object");
assert_eq!(
obj.get("name").and_then(|n| n.as_str()),
Some(name),
"inspect name mismatch"
);
assert!(
obj.get("pid").is_some(),
"inspect should include 'pid' field"
);
assert!(
obj.get("status").is_some(),
"inspect should include 'status' field"
);
let (_, stderr, ok) = remora(&["rm", name]);
assert!(ok, "rm failed: {}", stderr);
let (stdout, _, ok) = remora(&["ps", "-a", "--format", "json"]);
assert!(ok, "ps --format json failed after rm");
let parsed: serde_json::Value =
serde_json::from_str(&stdout).expect("ps JSON should parse after rm");
let arr = parsed.as_array().expect("expected JSON array");
let found = arr
.iter()
.any(|v| v.get("name").and_then(|n| n.as_str()) == Some(name));
assert!(
!found,
"container '{}' should not appear after rm: {}",
name, stdout
);
}
#[test]
#[serial]
fn test_image_ls_json() {
if !is_root() {
eprintln!("Skipping test_image_ls_json: requires root");
return;
}
let (stdout, stderr, ok) = remora(&["image", "ls", "--format", "json"]);
assert!(ok, "image ls --format json failed: {}", stderr);
let parsed: serde_json::Value =
serde_json::from_str(&stdout).expect("image ls JSON should parse");
let arr = parsed.as_array().expect("expected JSON array");
for entry in arr {
assert!(
entry.get("reference").and_then(|v| v.as_str()).is_some(),
"image entry should have 'reference': {:?}",
entry
);
assert!(
entry.get("digest").and_then(|v| v.as_str()).is_some(),
"image entry should have 'digest': {:?}",
entry
);
assert!(
entry.get("layers").and_then(|v| v.as_array()).is_some(),
"image entry should have 'layers' array: {:?}",
entry
);
}
}
}
mod rootless_idmap {
use super::*;
fn skip_unless_idmap_helpers() -> bool {
if !pelagos::idmap::has_newuidmap() || !pelagos::idmap::has_newgidmap() {
eprintln!("Skipping: newuidmap/newgidmap not available");
return false;
}
let username = match pelagos::idmap::current_username() {
Ok(u) => u,
Err(_) => {
eprintln!("Skipping: could not determine username");
return false;
}
};
let uid = unsafe { libc::getuid() };
let uid_ranges =
pelagos::idmap::parse_subid_file(std::path::Path::new("/etc/subuid"), &username, uid)
.unwrap_or_default();
let gid_ranges = pelagos::idmap::parse_subid_file(
std::path::Path::new("/etc/subgid"),
&username,
unsafe { libc::getgid() },
)
.unwrap_or_default();
if uid_ranges.is_empty() || gid_ranges.is_empty() {
eprintln!("Skipping: no subordinate UID/GID ranges in /etc/subuid or /etc/subgid");
return false;
}
true
}
#[test]
fn test_rootless_multi_uid_maps_written() {
if is_root() {
eprintln!("Skipping: must run as non-root");
return;
}
if !skip_unless_idmap_helpers() {
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/sleep")
.args(["10"])
.env("PATH", ALPINE_PATH)
.with_chroot(&rootfs)
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("rootless multi-uid spawn failed");
let uid_map_path = format!("/proc/{}/uid_map", child.pid());
let uid_map = std::fs::read_to_string(&uid_map_path)
.unwrap_or_else(|e| panic!("failed to read {}: {}", uid_map_path, e));
unsafe {
libc::kill(child.pid(), libc::SIGKILL);
}
child.wait().expect("Failed to wait");
let lines: Vec<&str> = uid_map.lines().collect();
assert!(
lines.len() >= 2,
"expected at least 2 uid_map lines, got {} lines: {:?}",
lines.len(),
lines
);
}
#[test]
fn test_rootless_multi_uid_file_ownership() {
if is_root() {
eprintln!("Skipping: must run as non-root");
return;
}
if !skip_unless_idmap_helpers() {
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "stat -c '%u' /etc/passwd"])
.env("PATH", ALPINE_PATH)
.with_chroot(&rootfs)
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("rootless file-ownership spawn failed");
let (status, stdout, stderr) = child.wait_with_output().expect("wait failed");
let out = String::from_utf8_lossy(&stdout);
let err = String::from_utf8_lossy(&stderr);
assert!(
status.success(),
"container exited non-zero; stdout={}, stderr={}",
out,
err
);
assert_eq!(
out.trim(),
"0",
"expected /etc/passwd owned by UID 0, got: {}",
out.trim()
);
}
#[test]
fn test_rootless_single_uid_fallback() {
if is_root() {
eprintln!("Skipping: must run as non-root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "id -u"])
.env("PATH", ALPINE_PATH)
.with_chroot(&rootfs)
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_uid_maps(&[UidMap {
inside: 0,
outside: unsafe { libc::getuid() },
count: 1,
}])
.with_gid_maps(&[GidMap {
inside: 0,
outside: unsafe { libc::getgid() },
count: 1,
}])
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("rootless single-uid spawn failed");
let (status, stdout, _stderr) = child.wait_with_output().expect("wait failed");
assert!(status.success(), "container exited non-zero");
let out = String::from_utf8_lossy(&stdout);
assert_eq!(
out.trim(),
"0",
"expected uid 0 inside container, got: {}",
out.trim()
);
}
}
mod build_instructions {
use pelagos::build;
use std::collections::HashMap;
#[test]
fn test_parse_entrypoint_json() {
let content = r#"FROM alpine
ENTRYPOINT ["python3", "-m", "http.server"]
CMD ["8080"]"#;
let instructions = build::parse_remfile(content).unwrap();
assert_eq!(instructions.len(), 3);
assert_eq!(
instructions[1],
build::Instruction::Entrypoint(vec![
"python3".into(),
"-m".into(),
"http.server".into()
])
);
assert_eq!(
instructions[2],
build::Instruction::Cmd(vec!["8080".into()])
);
}
#[test]
fn test_parse_entrypoint_shell_form() {
let content = "FROM alpine\nENTRYPOINT /usr/bin/myapp --flag";
let instructions = build::parse_remfile(content).unwrap();
assert_eq!(
instructions[1],
build::Instruction::Entrypoint(vec![
"/bin/sh".into(),
"-c".into(),
"/usr/bin/myapp --flag".into()
])
);
}
#[test]
fn test_parse_label_quoted_and_unquoted() {
let content = "FROM alpine\nLABEL maintainer=\"Jane Doe\"\nLABEL version=2.0";
let instructions = build::parse_remfile(content).unwrap();
assert_eq!(
instructions[1],
build::Instruction::Label {
key: "maintainer".into(),
value: "Jane Doe".into()
}
);
assert_eq!(
instructions[2],
build::Instruction::Label {
key: "version".into(),
value: "2.0".into()
}
);
}
#[test]
fn test_parse_user_with_gid() {
let content = "FROM alpine\nUSER 1000:1000";
let instructions = build::parse_remfile(content).unwrap();
assert_eq!(
instructions[1],
build::Instruction::User("1000:1000".into())
);
}
#[test]
fn test_image_config_labels_serde_roundtrip() {
use pelagos::image::ImageConfig;
let mut labels = HashMap::new();
labels.insert("maintainer".to_string(), "test@example.com".to_string());
labels.insert("version".to_string(), "1.0".to_string());
let config = ImageConfig {
env: vec![],
cmd: vec![],
entrypoint: vec![],
working_dir: String::new(),
user: String::new(),
labels: labels.clone(),
healthcheck: None,
};
let json = serde_json::to_string(&config).unwrap();
let loaded: ImageConfig = serde_json::from_str(&json).unwrap();
assert_eq!(loaded.labels, labels);
let minimal = r#"{"env":[],"cmd":[]}"#;
let loaded: ImageConfig = serde_json::from_str(minimal).unwrap();
assert!(loaded.labels.is_empty());
}
#[test]
fn test_image_config_user_field() {
use pelagos::image::ImageConfig;
let config = ImageConfig {
env: vec![],
cmd: vec![],
entrypoint: vec!["/app".to_string()],
working_dir: String::new(),
user: "1000:1000".to_string(),
labels: HashMap::new(),
healthcheck: None,
};
let json = serde_json::to_string(&config).unwrap();
let loaded: ImageConfig = serde_json::from_str(&json).unwrap();
assert_eq!(loaded.user, "1000:1000");
assert_eq!(loaded.entrypoint, vec!["/app"]);
}
#[test]
fn test_full_remfile_with_all_instructions() {
let content = r#"
FROM alpine:3.19
LABEL maintainer="test"
ENV APP_PORT=8080
USER nobody
WORKDIR /app
COPY app.py /app/app.py
RUN apk add python3
ENTRYPOINT ["python3"]
CMD ["app.py"]
EXPOSE 8080
"#;
let instructions = build::parse_remfile(content).unwrap();
assert_eq!(instructions.len(), 10);
assert!(matches!(instructions[0], build::Instruction::From { .. }));
assert!(matches!(instructions[1], build::Instruction::Label { .. }));
assert!(matches!(instructions[2], build::Instruction::Env { .. }));
assert!(matches!(instructions[3], build::Instruction::User(_)));
assert!(matches!(instructions[4], build::Instruction::Workdir(_)));
assert!(matches!(instructions[5], build::Instruction::Copy { .. }));
assert!(matches!(instructions[6], build::Instruction::Run(_)));
assert!(matches!(instructions[7], build::Instruction::Entrypoint(_)));
assert!(matches!(instructions[8], build::Instruction::Cmd(_)));
assert!(matches!(instructions[9], build::Instruction::Expose(_)));
}
#[test]
fn test_parse_arg_instruction() {
let content = "ARG BASE=alpine\nFROM $BASE\nARG VERSION\nRUN echo $VERSION";
let instructions = build::parse_remfile(content).unwrap();
assert_eq!(instructions.len(), 4);
assert_eq!(
instructions[0],
build::Instruction::Arg {
name: "BASE".into(),
default: Some("alpine".into())
}
);
assert_eq!(
instructions[2],
build::Instruction::Arg {
name: "VERSION".into(),
default: None,
}
);
let mut vars = HashMap::new();
vars.insert("BASE".to_string(), "alpine:3.19".to_string());
assert_eq!(
build::substitute_vars("img=${BASE}", &vars),
"img=alpine:3.19"
);
assert_eq!(
build::substitute_vars("$BASE/path", &vars),
"alpine:3.19/path"
);
assert_eq!(build::substitute_vars("$$literal", &vars), "$literal");
}
#[test]
fn test_remignore_filtering() {
use std::io::Write;
let ctx = tempfile::tempdir().unwrap();
let mut f = std::fs::File::create(ctx.path().join(".remignore")).unwrap();
writeln!(f, "*.log").unwrap();
writeln!(f, "build/").unwrap();
std::fs::write(ctx.path().join("app.rs"), "fn main() {}").unwrap();
std::fs::write(ctx.path().join("debug.log"), "log data").unwrap();
std::fs::create_dir(ctx.path().join("build")).unwrap();
std::fs::write(ctx.path().join("build/output"), "binary").unwrap();
std::fs::create_dir(ctx.path().join("src")).unwrap();
std::fs::write(ctx.path().join("src/lib.rs"), "pub fn f() {}").unwrap();
let mut builder = ignore::gitignore::GitignoreBuilder::new(ctx.path());
builder.add(ctx.path().join(".remignore"));
let gi = builder.build().unwrap();
let dst = tempfile::tempdir().unwrap();
fn copy_filtered(
src: &std::path::Path,
dst: &std::path::Path,
gi: &ignore::gitignore::Gitignore,
root: &std::path::Path,
) {
std::fs::create_dir_all(dst).unwrap();
for entry in std::fs::read_dir(src).unwrap() {
let entry = entry.unwrap();
let ft = entry.file_type().unwrap();
let path = entry.path();
let dest = dst.join(entry.file_name());
let rel = path.strip_prefix(root).unwrap();
if gi.matched(rel, ft.is_dir()).is_ignore() {
continue;
}
if ft.is_dir() {
copy_filtered(&path, &dest, gi, root);
} else {
std::fs::copy(&path, &dest).unwrap();
}
}
}
copy_filtered(ctx.path(), dst.path(), &gi, ctx.path());
assert!(dst.path().join("app.rs").exists());
assert!(dst.path().join("src/lib.rs").exists());
assert!(!dst.path().join("debug.log").exists());
assert!(!dst.path().join("build").exists());
}
#[test]
fn test_parse_add_instruction() {
let content =
"FROM alpine\nADD app.tar.gz /opt/app\nADD https://example.com/file /tmp/file";
let instructions = build::parse_remfile(content).unwrap();
assert_eq!(instructions.len(), 3);
assert_eq!(
instructions[1],
build::Instruction::Add {
src: "app.tar.gz".into(),
dest: "/opt/app".into()
}
);
assert_eq!(
instructions[2],
build::Instruction::Add {
src: "https://example.com/file".into(),
dest: "/tmp/file".into()
}
);
}
#[test]
fn test_add_local_tar_extraction() {
let tmp = tempfile::tempdir().unwrap();
let archive_path = tmp.path().join("test.tar.gz");
{
let file = std::fs::File::create(&archive_path).unwrap();
let gz = flate2::write::GzEncoder::new(file, flate2::Compression::fast());
let mut tar_builder = tar::Builder::new(gz);
let data1 = b"hello world";
let mut header1 = tar::Header::new_gnu();
header1.set_size(data1.len() as u64);
header1.set_mode(0o644);
header1.set_cksum();
tar_builder
.append_data(&mut header1, "hello.txt", &data1[..])
.unwrap();
let data2 = b"sub content";
let mut header2 = tar::Header::new_gnu();
header2.set_size(data2.len() as u64);
header2.set_mode(0o644);
header2.set_cksum();
tar_builder
.append_data(&mut header2, "subdir/file.txt", &data2[..])
.unwrap();
let gz = tar_builder.into_inner().unwrap();
gz.finish().unwrap();
}
let extract_dir = tmp.path().join("extracted");
std::fs::create_dir_all(&extract_dir).unwrap();
let file = std::fs::File::open(&archive_path).unwrap();
let decoder = flate2::read::GzDecoder::new(file);
tar::Archive::new(decoder).unpack(&extract_dir).unwrap();
assert!(extract_dir.join("hello.txt").exists());
assert_eq!(
std::fs::read_to_string(extract_dir.join("hello.txt")).unwrap(),
"hello world"
);
assert!(extract_dir.join("subdir/file.txt").exists());
assert_eq!(
std::fs::read_to_string(extract_dir.join("subdir/file.txt")).unwrap(),
"sub content"
);
}
#[test]
fn test_parse_multi_stage_remfile() {
let content = r#"
FROM alpine:3.19 AS builder
RUN echo "building..."
COPY src/ /build/src/
FROM alpine:3.19
COPY --from=builder /build/output /app/bin
CMD ["/app/bin"]
"#;
let instructions = build::parse_remfile(content).unwrap();
assert_eq!(instructions.len(), 6);
assert_eq!(
instructions[0],
build::Instruction::From {
image: "alpine:3.19".into(),
alias: Some("builder".into()),
}
);
assert!(matches!(
instructions[2],
build::Instruction::Copy {
ref from_stage, ..
} if from_stage.is_none()
));
assert_eq!(
instructions[3],
build::Instruction::From {
image: "alpine:3.19".into(),
alias: None,
}
);
assert_eq!(
instructions[4],
build::Instruction::Copy {
src: "/build/output".into(),
dest: "/app/bin".into(),
from_stage: Some("builder".into()),
}
);
}
}
mod port_proxy {
use super::*;
#[test]
#[serial(nat)]
fn test_port_proxy_localhost_connectivity() {
if !is_root() {
eprintln!("Skipping test_port_proxy_localhost_connectivity: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_port_proxy_localhost_connectivity: alpine-rootfs not found");
return;
};
let nc_ok = std::process::Command::new("which")
.arg("nc")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if !nc_ok {
eprintln!("Skipping test_port_proxy_localhost_connectivity: nc not found on host");
return;
}
let mut child = Command::new("/bin/sh")
.args(["-c", "echo PROXY_WORKS | nc -l -p 80"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_port_forward(19190, 80)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn container");
std::thread::sleep(std::time::Duration::from_millis(500));
let output = std::process::Command::new("nc")
.args(["-w", "2", "127.0.0.1", "19190"])
.output()
.expect("nc to localhost");
let out = String::from_utf8_lossy(&output.stdout);
unsafe {
libc::kill(child.pid(), libc::SIGKILL);
}
let _ = child.wait();
assert!(
out.contains("PROXY_WORKS"),
"Localhost connection via port proxy should receive 'PROXY_WORKS'.\nstdout: {}\nstderr: {}",
out,
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
#[serial(nat)]
fn test_port_proxy_cleanup_on_teardown() {
if !is_root() {
eprintln!("Skipping test_port_proxy_cleanup_on_teardown: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_port_proxy_cleanup_on_teardown: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/true")
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_port_forward(19191, 80)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn container");
let status = child.wait().expect("wait failed");
assert!(status.success(), "container should exit cleanly");
std::thread::sleep(std::time::Duration::from_millis(300));
let bind_result = std::net::TcpListener::bind("0.0.0.0:19191");
assert!(
bind_result.is_ok(),
"Port 19191 should be free after teardown, but bind failed: {:?}",
bind_result.err()
);
}
#[test]
#[serial(nat)]
fn test_port_proxy_multiple_connections() {
if !is_root() {
eprintln!("Skipping test_port_proxy_multiple_connections: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_port_proxy_multiple_connections: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/sh")
.args([
"-c",
"while true; do echo PONG | nc -l -p 8080 2>/dev/null; done",
])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_port_forward(19192, 8080)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn container");
std::thread::sleep(std::time::Duration::from_millis(600));
const N: usize = 5;
let mut failures = Vec::new();
for i in 0..N {
use std::io::Read;
match std::net::TcpStream::connect("127.0.0.1:19192") {
Ok(mut stream) => {
stream
.set_read_timeout(Some(std::time::Duration::from_secs(3)))
.ok();
let mut buf = String::new();
let _ = stream.read_to_string(&mut buf);
if !buf.contains("PONG") {
failures.push(format!("conn {}: got {:?}", i, buf));
}
drop(stream);
std::thread::sleep(std::time::Duration::from_millis(300));
}
Err(e) => {
failures.push(format!("conn {}: connect failed: {}", i, e));
}
}
}
unsafe { libc::kill(child.pid(), libc::SIGKILL) };
let _ = child.wait();
assert!(
failures.is_empty(),
"Port proxy multiple-connection test failed:\n{}",
failures.join("\n")
);
}
}
mod multi_network {
use super::*;
use pelagos::network::{Ipv4Net, NetworkDef};
fn cleanup_test_network(name: &str) {
let config_dir = pelagos::paths::network_config_dir(name);
let _ = std::fs::remove_dir_all(&config_dir);
let runtime_dir = pelagos::paths::network_runtime_dir(name);
let _ = std::fs::remove_dir_all(&runtime_dir);
let bridge = if name == "remora0" {
"remora0".to_string()
} else {
format!("rm-{}", name)
};
let _ = std::process::Command::new("ip")
.args(["link", "del", &bridge])
.stderr(std::process::Stdio::null())
.status();
}
#[test]
#[serial]
fn test_network_create_ls_rm() {
if !is_root() {
eprintln!("Skipping test_network_create_ls_rm (requires root)");
return;
}
let name = "testnet1";
cleanup_test_network(name);
let subnet = Ipv4Net::from_cidr("10.99.1.0/24").unwrap();
let net = NetworkDef {
name: name.to_string(),
subnet: subnet.clone(),
gateway: subnet.gateway(),
bridge_name: format!("rm-{}", name),
};
net.save().expect("save network");
let config = pelagos::paths::network_config_dir(name).join("config.json");
assert!(config.exists(), "config.json should exist after save");
let loaded = NetworkDef::load(name).expect("load network");
assert_eq!(loaded.name, name);
assert_eq!(loaded.subnet.cidr_string(), "10.99.1.0/24");
assert_eq!(loaded.gateway, std::net::Ipv4Addr::new(10, 99, 1, 1));
assert_eq!(loaded.bridge_name, "rm-testnet1");
cleanup_test_network(name);
assert!(!config.exists(), "config.json should be gone after cleanup");
}
#[test]
#[serial]
fn test_network_create_overlap_rejected() {
if !is_root() {
eprintln!("Skipping test_network_create_overlap_rejected (requires root)");
return;
}
let name_a = "overlap-a";
let name_b = "overlap-b";
cleanup_test_network(name_a);
cleanup_test_network(name_b);
let subnet_a = Ipv4Net::from_cidr("10.77.0.0/16").unwrap();
let net_a = NetworkDef {
name: name_a.to_string(),
subnet: subnet_a.clone(),
gateway: subnet_a.gateway(),
bridge_name: format!("rm-{}", name_a),
};
net_a.save().expect("save network A");
let subnet_b = Ipv4Net::from_cidr("10.77.1.0/24").unwrap();
assert!(
subnet_a.overlaps(&subnet_b),
"10.77.0.0/16 and 10.77.1.0/24 should overlap"
);
let networks_dir = pelagos::paths::networks_config_dir();
let mut found_overlap = false;
if let Ok(entries) = std::fs::read_dir(&networks_dir) {
for entry in entries.flatten() {
let cfg_path = entry.path().join("config.json");
if let Ok(data) = std::fs::read_to_string(&cfg_path) {
if let Ok(existing) = serde_json::from_str::<NetworkDef>(&data) {
if existing.subnet.overlaps(&subnet_b) {
found_overlap = true;
}
}
}
}
}
assert!(found_overlap, "overlap should be detected");
cleanup_test_network(name_a);
cleanup_test_network(name_b);
}
#[test]
fn test_network_name_validation() {
let long_name = "abcdefghijklm"; assert!(long_name.len() > 12);
let bad_chars = "net_work";
assert!(bad_chars.contains('_'));
let leading = "-net";
assert!(leading.starts_with('-'));
assert!(Ipv4Net::from_cidr("not-a-cidr").is_err());
assert!(Ipv4Net::from_cidr("10.0.0.0/33").is_err());
assert!(Ipv4Net::from_cidr("10.0.0.0/24").is_ok());
}
#[test]
#[serial(nat)]
fn test_named_network_container() {
if !is_root() {
eprintln!("Skipping test_named_network_container (requires root)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping test_named_network_container (no rootfs)");
return;
}
};
let name = "testnet2";
cleanup_test_network(name);
let subnet = Ipv4Net::from_cidr("10.98.1.0/24").unwrap();
let net = NetworkDef {
name: name.to_string(),
subnet: subnet.clone(),
gateway: subnet.gateway(),
bridge_name: format!("rm-{}", name),
};
net.save().expect("save network");
let mut child = Command::new("/bin/sh")
.args(["-c", "ip addr show eth0 | grep 'inet '"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(name.to_string()))
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn on named network");
let (_status, stdout_raw, _stderr) = child.wait_with_output().expect("wait_with_output");
let stdout = String::from_utf8_lossy(&stdout_raw);
assert!(
stdout.contains("10.98.1."),
"container IP should be in 10.98.1.0/24, got: {}",
stdout.trim()
);
cleanup_test_network(name);
}
#[test]
#[serial(nat)]
fn test_default_network_backwards_compat() {
if !is_root() {
eprintln!("Skipping test_default_network_backwards_compat (requires root)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping test_default_network_backwards_compat (no rootfs)");
return;
}
};
let mut child = Command::new("/bin/sh")
.args(["-c", "ip addr show eth0 | grep 'inet '"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn on default bridge");
let (_status, stdout_raw, _stderr) = child.wait_with_output().expect("wait_with_output");
let stdout = String::from_utf8_lossy(&stdout_raw);
assert!(
stdout.contains("172.19.0."),
"default bridge should assign 172.19.0.x IP, got: {}",
stdout.trim()
);
}
#[test]
#[serial]
fn test_network_rm_refuses_default() {
if !is_root() {
eprintln!("Skipping test_network_rm_refuses_default (requires root)");
return;
}
let _ = pelagos::network::bootstrap_default_network().expect("bootstrap default");
let config = pelagos::paths::network_config_dir("remora0").join("config.json");
assert!(config.exists(), "default network config should exist");
}
fn create_test_network(name: &str, cidr: &str) {
cleanup_test_network(name);
let subnet = Ipv4Net::from_cidr(cidr).unwrap();
let net = NetworkDef {
name: name.to_string(),
subnet: subnet.clone(),
gateway: subnet.gateway(),
bridge_name: format!("rm-{}", name),
};
net.save().expect("save network");
}
#[test]
#[serial(nat)]
fn test_multi_network_dual_interface() {
if !is_root() {
eprintln!("Skipping test_multi_network_dual_interface (requires root)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping test_multi_network_dual_interface (no rootfs)");
return;
}
};
let net1 = "mntest1";
let net2 = "mntest2";
create_test_network(net1, "10.99.1.0/24");
create_test_network(net2, "10.99.2.0/24");
let mut child = Command::new("/bin/sh")
.args([
"-c",
"ip addr show eth0 | grep 'inet '; ip addr show eth1 | grep 'inet '",
])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net1.to_string()))
.with_additional_network(net2)
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn multi-network");
let ip1 = child.container_ip().expect("primary IP");
assert!(
ip1.starts_with("10.99.1."),
"primary IP should be in 10.99.1.0/24, got: {}",
ip1
);
let ip2 = child
.container_ip_on(net2)
.expect("secondary IP on mntest2");
assert!(
ip2.starts_with("10.99.2."),
"secondary IP should be in 10.99.2.0/24, got: {}",
ip2
);
let (_status, stdout_raw, _stderr) = child.wait_with_output().expect("wait_with_output");
let stdout = String::from_utf8_lossy(&stdout_raw);
assert!(
stdout.contains("10.99.1."),
"eth0 should have 10.99.1.x IP in output: {}",
stdout.trim()
);
assert!(
stdout.contains("10.99.2."),
"eth1 should have 10.99.2.x IP in output: {}",
stdout.trim()
);
cleanup_test_network(net1);
cleanup_test_network(net2);
}
#[test]
#[serial(nat)]
fn test_multi_network_isolation() {
if !is_root() {
eprintln!("Skipping test_multi_network_isolation (requires root)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping test_multi_network_isolation (no rootfs)");
return;
}
};
let net1 = "mniso1";
let net2 = "mniso2";
let bridge1 = "rm-mniso1";
let bridge2 = "rm-mniso2";
create_test_network(net1, "10.98.1.0/24");
create_test_network(net2, "10.98.2.0/24");
let _ = std::process::Command::new("iptables")
.args(["-I", "FORWARD", "-i", bridge1, "-o", bridge2, "-j", "DROP"])
.status();
let _ = std::process::Command::new("iptables")
.args(["-I", "FORWARD", "-i", bridge2, "-o", bridge1, "-j", "DROP"])
.status();
let mut child_a = Command::new("/bin/sleep")
.args(["30"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net1.to_string()))
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("spawn container A");
let ip_a = child_a.container_ip().expect("A's IP");
let mut child_b = Command::new("/bin/sleep")
.args(["30"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net2.to_string()))
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("spawn container B");
let ip_b = child_b.container_ip().expect("B's IP");
let test_cmd = format!("ping -c1 -W1 {} && ping -c1 -W1 {}", ip_a, ip_b);
let mut child_c = Command::new("/bin/sh")
.args(["-c", &test_cmd])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net1.to_string()))
.with_additional_network(net2)
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn container C");
let (status_c, _stdout, _stderr) = child_c.wait_with_output().expect("wait C");
assert!(
status_c.success(),
"container C (both networks) should reach both A and B"
);
let test_cmd_fail = format!("ping -c1 -W1 {}", ip_b);
let mut child_d = Command::new("/bin/sh")
.args(["-c", &test_cmd_fail])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net1.to_string()))
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn isolation test");
let (status_d, _stdout, _stderr) = child_d.wait_with_output().expect("wait D");
assert!(
!status_d.success(),
"container on net1 should NOT reach container on net2"
);
unsafe {
libc::kill(child_a.pid(), libc::SIGTERM);
libc::kill(child_b.pid(), libc::SIGTERM);
}
let _ = child_a.wait();
let _ = child_b.wait();
let _ = std::process::Command::new("iptables")
.args(["-D", "FORWARD", "-i", bridge1, "-o", bridge2, "-j", "DROP"])
.status();
let _ = std::process::Command::new("iptables")
.args(["-D", "FORWARD", "-i", bridge2, "-o", bridge1, "-j", "DROP"])
.status();
cleanup_test_network(net1);
cleanup_test_network(net2);
}
#[test]
#[serial(nat)]
fn test_multi_network_teardown() {
if !is_root() {
eprintln!("Skipping test_multi_network_teardown (requires root)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping test_multi_network_teardown (no rootfs)");
return;
}
};
let net1 = "mntd1";
let net2 = "mntd2";
create_test_network(net1, "10.97.1.0/24");
create_test_network(net2, "10.97.2.0/24");
let mut child = Command::new("/bin/true")
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net1.to_string()))
.with_additional_network(net2)
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("spawn teardown test");
let ns_name = child.netns_name().unwrap().to_string();
let primary_veth = child.veth_name().unwrap().to_string();
let secondary_veths: Vec<String> = child
.secondary_networks()
.iter()
.map(|n| n.veth_host.clone())
.collect();
assert_eq!(
secondary_veths.len(),
1,
"should have one secondary network"
);
child.wait().expect("wait for container");
let ns_path = format!("/run/netns/{}", ns_name);
assert!(
!std::path::Path::new(&ns_path).exists(),
"netns {} should be removed after wait()",
ns_name
);
let veth_check = std::process::Command::new("ip")
.args(["link", "show", &primary_veth])
.stderr(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.status()
.expect("ip link show");
assert!(
!veth_check.success(),
"primary veth {} should be removed",
primary_veth
);
for veth in &secondary_veths {
let check = std::process::Command::new("ip")
.args(["link", "show", veth])
.stderr(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.status()
.expect("ip link show");
assert!(
!check.success(),
"secondary veth {} should be removed",
veth
);
}
cleanup_test_network(net1);
cleanup_test_network(net2);
}
#[test]
#[serial(nat)]
fn test_multi_network_link_resolution() {
if !is_root() {
eprintln!("Skipping test_multi_network_link_resolution (requires root)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping test_multi_network_link_resolution (no rootfs)");
return;
}
};
let net1 = "mnlink1";
let net2 = "mnlink2";
create_test_network(net1, "10.96.1.0/24");
create_test_network(net2, "10.96.2.0/24");
let mut server = Command::new("/bin/sleep")
.args(["30"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net1.to_string()))
.with_additional_network(net2)
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("spawn server");
let server_ip_net1 = server.container_ip_on(net1).expect("server IP on net1");
let server_ip_net2 = server.container_ip_on(net2).expect("server IP on net2");
let server_name = "mnlink-server";
let server_dir = pelagos::paths::containers_dir().join(server_name);
std::fs::create_dir_all(&server_dir).expect("create server dir");
let mut network_ips = std::collections::HashMap::new();
network_ips.insert(net1.to_string(), server_ip_net1.clone());
network_ips.insert(net2.to_string(), server_ip_net2.clone());
let state_json = serde_json::json!({
"name": server_name,
"rootfs": rootfs.to_string_lossy(),
"status": "running",
"pid": server.pid(),
"watcher_pid": 0,
"started_at": "2026-01-01T00:00:00Z",
"command": ["/bin/sleep", "30"],
"bridge_ip": server_ip_net1,
"network_ips": network_ips,
});
std::fs::write(
server_dir.join("state.json"),
serde_json::to_string_pretty(&state_json).unwrap(),
)
.expect("write server state");
let mut client = Command::new("/bin/sh")
.args(["-c", "cat /etc/hosts"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net2.to_string()))
.with_nat()
.with_link(server_name)
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn client");
let (_status, stdout_raw, _stderr) = client.wait_with_output().expect("wait client");
let hosts = String::from_utf8_lossy(&stdout_raw);
assert!(
hosts.contains(&server_ip_net2),
"/etc/hosts should contain server's net2 IP {}, got: {}",
server_ip_net2,
hosts.trim()
);
unsafe { libc::kill(server.pid(), libc::SIGTERM) };
let _ = server.wait();
let _ = std::fs::remove_dir_all(&server_dir);
cleanup_test_network(net1);
cleanup_test_network(net2);
}
}
mod dns {
use super::*;
use pelagos::network::{Ipv4Net, NetworkDef};
fn cleanup_test_network(name: &str) {
let config_dir = pelagos::paths::network_config_dir(name);
let _ = std::fs::remove_dir_all(&config_dir);
let runtime_dir = pelagos::paths::network_runtime_dir(name);
let _ = std::fs::remove_dir_all(&runtime_dir);
let bridge = format!("rm-{}", name);
let _ = std::process::Command::new("ip")
.args(["link", "del", &bridge])
.stderr(std::process::Stdio::null())
.status();
}
fn create_test_network(name: &str, cidr: &str) {
cleanup_test_network(name);
let subnet = Ipv4Net::from_cidr(cidr).unwrap();
let net = NetworkDef {
name: name.to_string(),
subnet: subnet.clone(),
gateway: subnet.gateway(),
bridge_name: format!("rm-{}", name),
};
net.save().expect("save network");
}
fn cleanup_dns() {
let dns_dir = pelagos::paths::dns_config_dir();
let pid_file = dns_dir.join("pid");
if let Ok(content) = std::fs::read_to_string(&pid_file) {
if let Ok(pid) = content.trim().parse::<i32>() {
unsafe { libc::kill(pid, libc::SIGTERM) };
std::thread::sleep(std::time::Duration::from_millis(200));
}
}
let _ = std::fs::remove_dir_all(&dns_dir);
}
#[test]
#[serial(nat)]
fn test_dns_resolves_container_name() {
if !is_root() {
eprintln!("Skipping test_dns_resolves_container_name (requires root)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping test_dns_resolves_container_name (no rootfs)");
return;
}
};
let net_name = "dnstest1";
cleanup_dns();
create_test_network(net_name, "10.90.1.0/24");
let mut server = Command::new("/bin/sleep")
.args(["30"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net_name.to_string()))
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn server");
let server_ip = server.container_ip().expect("server should have IP");
let net_def = pelagos::network::load_network_def(net_name).expect("load net def");
pelagos::dns::dns_add_entry(
net_name,
"server-a",
server_ip.parse().unwrap(),
net_def.gateway,
&["8.8.8.8".to_string()],
)
.expect("dns_add_entry");
std::thread::sleep(std::time::Duration::from_millis(200));
let resolve_cmd = format!(
"nslookup server-a {} 2>&1 || echo 'NSLOOKUP_FAILED'",
net_def.gateway
);
let mut client = Command::new("/bin/sh")
.args(["-c", &resolve_cmd])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net_name.to_string()))
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn client");
let (_status, stdout_raw, stderr_raw) = client.wait_with_output().expect("client wait");
let stdout = String::from_utf8_lossy(&stdout_raw);
let stderr = String::from_utf8_lossy(&stderr_raw);
assert!(
stdout.contains(&server_ip),
"nslookup should resolve server-a to {}, stdout: {}, stderr: {}",
server_ip,
stdout.trim(),
stderr.trim()
);
pelagos::dns::dns_remove_entry(net_name, "server-a").ok();
unsafe { libc::kill(server.pid(), libc::SIGTERM) };
let _ = server.wait();
cleanup_dns();
cleanup_test_network(net_name);
}
#[test]
#[serial(nat)]
fn test_dns_upstream_forward() {
if !is_root() {
eprintln!("Skipping test_dns_upstream_forward (requires root)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping test_dns_upstream_forward (no rootfs)");
return;
}
};
let net_name = "dnstest2";
cleanup_dns();
create_test_network(net_name, "10.90.2.0/24");
let mut holder = Command::new("/bin/sleep")
.args(["30"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net_name.to_string()))
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("spawn holder");
let net_def = pelagos::network::load_network_def(net_name).expect("load net def");
pelagos::dns::dns_add_entry(
net_name,
"dummy",
"10.90.2.99".parse().unwrap(),
net_def.gateway,
&["8.8.8.8".to_string(), "1.1.1.1".to_string()],
)
.expect("dns_add_entry");
std::thread::sleep(std::time::Duration::from_millis(200));
let resolve_cmd = format!(
"nslookup example.com {} 2>&1 || echo 'NSLOOKUP_FAILED'",
net_def.gateway
);
let mut client = Command::new("/bin/sh")
.args(["-c", &resolve_cmd])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net_name.to_string()))
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn client");
let (_status, stdout_raw, stderr_raw) = client.wait_with_output().expect("client wait");
let stdout = String::from_utf8_lossy(&stdout_raw);
let stderr = String::from_utf8_lossy(&stderr_raw);
assert!(
!stdout.contains("NSLOOKUP_FAILED")
&& (stdout.contains("Address") || stdout.contains("Name")),
"nslookup example.com should succeed via upstream, stdout: {}, stderr: {}",
stdout.trim(),
stderr.trim()
);
pelagos::dns::dns_remove_entry(net_name, "dummy").ok();
unsafe { libc::kill(holder.pid(), libc::SIGTERM) };
let _ = holder.wait();
cleanup_dns();
cleanup_test_network(net_name);
}
#[test]
#[serial(nat)]
fn test_dns_network_isolation() {
if !is_root() {
eprintln!("Skipping test_dns_network_isolation (requires root)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping test_dns_network_isolation (no rootfs)");
return;
}
};
let net1 = "dnsiso1";
let net2 = "dnsiso2";
cleanup_dns();
create_test_network(net1, "10.90.3.0/24");
create_test_network(net2, "10.90.4.0/24");
let mut holder1 = Command::new("/bin/sleep")
.args(["30"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net1.to_string()))
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("spawn holder1");
let mut holder2 = Command::new("/bin/sleep")
.args(["30"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net2.to_string()))
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("spawn holder2");
let net1_def = pelagos::network::load_network_def(net1).expect("load net1");
pelagos::dns::dns_add_entry(
net1,
"alpha",
"10.90.3.5".parse().unwrap(),
net1_def.gateway,
&["8.8.8.8".to_string()],
)
.expect("add alpha");
let net2_def = pelagos::network::load_network_def(net2).expect("load net2");
pelagos::dns::dns_add_entry(
net2,
"beta",
"10.90.4.5".parse().unwrap(),
net2_def.gateway,
&["8.8.8.8".to_string()],
)
.expect("add beta");
std::thread::sleep(std::time::Duration::from_millis(200));
let resolve_cmd = format!("nslookup alpha {} 2>&1; echo EXIT=$?", net2_def.gateway);
let mut client = Command::new("/bin/sh")
.args(["-c", &resolve_cmd])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net2.to_string()))
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn client");
let (_status, stdout_raw, _stderr) = client.wait_with_output().expect("client wait");
let stdout = String::from_utf8_lossy(&stdout_raw);
assert!(
!stdout.contains("10.90.3.5"),
"alpha's IP should NOT be resolvable from net2, got: {}",
stdout.trim()
);
let resolve_beta = format!("nslookup beta {} 2>&1", net2_def.gateway);
let mut client2 = Command::new("/bin/sh")
.args(["-c", &resolve_beta])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net2.to_string()))
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn client2");
let (_status, stdout_raw, _stderr) = client2.wait_with_output().expect("client2 wait");
let stdout = String::from_utf8_lossy(&stdout_raw);
assert!(
stdout.contains("10.90.4.5"),
"beta should resolve on net2 to 10.90.4.5, got: {}",
stdout.trim()
);
pelagos::dns::dns_remove_entry(net1, "alpha").ok();
pelagos::dns::dns_remove_entry(net2, "beta").ok();
unsafe { libc::kill(holder1.pid(), libc::SIGTERM) };
unsafe { libc::kill(holder2.pid(), libc::SIGTERM) };
let _ = holder1.wait();
let _ = holder2.wait();
cleanup_dns();
cleanup_test_network(net1);
cleanup_test_network(net2);
}
#[test]
#[serial(nat)]
fn test_dns_multi_network() {
if !is_root() {
eprintln!("Skipping test_dns_multi_network (requires root)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping test_dns_multi_network (no rootfs)");
return;
}
};
let net1 = "dnsmn1";
let net2 = "dnsmn2";
cleanup_dns();
create_test_network(net1, "10.90.5.0/24");
create_test_network(net2, "10.90.6.0/24");
let mut server = Command::new("/bin/sleep")
.args(["30"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net1.to_string()))
.with_additional_network(net2)
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn server");
let ip_net1 = server.container_ip().expect("server IP on net1");
let ip_net2 = server.container_ip_on(net2).expect("server IP on net2");
assert!(ip_net1.starts_with("10.90.5."), "net1 IP: {}", ip_net1);
assert!(ip_net2.starts_with("10.90.6."), "net2 IP: {}", ip_net2);
let net1_def = pelagos::network::load_network_def(net1).expect("load net1");
let net2_def = pelagos::network::load_network_def(net2).expect("load net2");
pelagos::dns::dns_add_entry(
net1,
"multi-a",
ip_net1.parse().unwrap(),
net1_def.gateway,
&["8.8.8.8".to_string()],
)
.expect("add to net1");
pelagos::dns::dns_add_entry(
net2,
"multi-a",
ip_net2.parse().unwrap(),
net2_def.gateway,
&["8.8.8.8".to_string()],
)
.expect("add to net2");
std::thread::sleep(std::time::Duration::from_millis(200));
let resolve_cmd = format!("nslookup multi-a {} 2>&1", net2_def.gateway);
let mut client = Command::new("/bin/sh")
.args(["-c", &resolve_cmd])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net2.to_string()))
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn client");
let (_status, stdout_raw, _stderr) = client.wait_with_output().expect("client wait");
let stdout = String::from_utf8_lossy(&stdout_raw);
assert!(
stdout.contains(&ip_net2),
"should resolve multi-a to net2 IP {}, got: {}",
ip_net2,
stdout.trim()
);
pelagos::dns::dns_remove_entry(net1, "multi-a").ok();
pelagos::dns::dns_remove_entry(net2, "multi-a").ok();
unsafe { libc::kill(server.pid(), libc::SIGTERM) };
let _ = server.wait();
cleanup_dns();
cleanup_test_network(net1);
cleanup_test_network(net2);
}
#[test]
#[serial(nat)]
fn test_dns_daemon_lifecycle() {
if !is_root() {
eprintln!("Skipping test_dns_daemon_lifecycle (requires root)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping test_dns_daemon_lifecycle (no rootfs)");
return;
}
};
let net_name = "dnslc";
cleanup_dns();
create_test_network(net_name, "10.90.7.0/24");
let mut holder = Command::new("/bin/sleep")
.args(["30"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net_name.to_string()))
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("spawn holder");
let net_def = pelagos::network::load_network_def(net_name).expect("load net def");
let pid_file = pelagos::paths::dns_pid_file();
assert!(
!pid_file.exists(),
"PID file should not exist before any DNS entries"
);
pelagos::dns::dns_add_entry(
net_name,
"lifecycle-test",
"10.90.7.5".parse().unwrap(),
net_def.gateway,
&["8.8.8.8".to_string()],
)
.expect("dns_add_entry");
std::thread::sleep(std::time::Duration::from_millis(300));
assert!(
pid_file.exists(),
"PID file should exist after DNS entry added"
);
let pid_str = std::fs::read_to_string(&pid_file).expect("read PID file");
let pid: i32 = pid_str.trim().parse().expect("parse PID");
assert!(
unsafe { libc::kill(pid, 0) } == 0,
"DNS daemon (PID {}) should be alive",
pid
);
pelagos::dns::dns_remove_entry(net_name, "lifecycle-test").expect("dns_remove_entry");
std::thread::sleep(std::time::Duration::from_millis(500));
assert!(
unsafe { libc::kill(pid, 0) } != 0,
"DNS daemon (PID {}) should have exited after last entry removed",
pid
);
unsafe { libc::kill(holder.pid(), libc::SIGTERM) };
let _ = holder.wait();
cleanup_dns();
cleanup_test_network(net_name);
}
fn has_dnsmasq() -> bool {
std::process::Command::new("which")
.arg("dnsmasq")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
#[test]
#[serial(nat)]
fn test_dns_dnsmasq_resolves_container_name() {
if !is_root() {
eprintln!("Skipping test_dns_dnsmasq_resolves_container_name (requires root)");
return;
}
if !has_dnsmasq() {
eprintln!("Skipping test_dns_dnsmasq_resolves_container_name (dnsmasq not found)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping test_dns_dnsmasq_resolves_container_name (no rootfs)");
return;
}
};
let net_name = "dnsmq1";
cleanup_dns();
create_test_network(net_name, "10.90.11.0/24");
unsafe { std::env::set_var("REMORA_DNS_BACKEND", "dnsmasq") };
let mut server = Command::new("/bin/sleep")
.args(["30"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net_name.to_string()))
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn server");
let server_ip = server.container_ip().expect("server should have IP");
let net_def = pelagos::network::load_network_def(net_name).expect("load net def");
pelagos::dns::dns_add_entry(
net_name,
"dnsmasq-server",
server_ip.parse().unwrap(),
net_def.gateway,
&["8.8.8.8".to_string()],
)
.expect("dns_add_entry");
std::thread::sleep(std::time::Duration::from_millis(500));
let backend_file = pelagos::paths::dns_backend_file();
if backend_file.exists() {
let backend = std::fs::read_to_string(&backend_file).unwrap_or_default();
assert_eq!(
backend.trim(),
"dnsmasq",
"backend marker should say dnsmasq"
);
}
let resolve_cmd = format!(
"nslookup dnsmasq-server {} 2>&1 || echo 'NSLOOKUP_FAILED'",
net_def.gateway
);
let mut client = Command::new("/bin/sh")
.args(["-c", &resolve_cmd])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net_name.to_string()))
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn client");
let (_status, stdout_raw, stderr_raw) = client.wait_with_output().expect("client wait");
let stdout = String::from_utf8_lossy(&stdout_raw);
let stderr = String::from_utf8_lossy(&stderr_raw);
assert!(
stdout.contains(&server_ip),
"nslookup should resolve dnsmasq-server to {}, stdout: {}, stderr: {}",
server_ip,
stdout.trim(),
stderr.trim()
);
pelagos::dns::dns_remove_entry(net_name, "dnsmasq-server").ok();
unsafe { libc::kill(server.pid(), libc::SIGTERM) };
let _ = server.wait();
cleanup_dns();
cleanup_test_network(net_name);
unsafe { std::env::remove_var("REMORA_DNS_BACKEND") };
}
#[test]
#[serial(nat)]
fn test_dns_dnsmasq_upstream_forward() {
if !is_root() {
eprintln!("Skipping test_dns_dnsmasq_upstream_forward (requires root)");
return;
}
if !has_dnsmasq() {
eprintln!("Skipping test_dns_dnsmasq_upstream_forward (dnsmasq not found)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping test_dns_dnsmasq_upstream_forward (no rootfs)");
return;
}
};
let net_name = "dnsmq2";
cleanup_dns();
create_test_network(net_name, "10.90.12.0/24");
unsafe { std::env::set_var("REMORA_DNS_BACKEND", "dnsmasq") };
let mut holder = Command::new("/bin/sleep")
.args(["30"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net_name.to_string()))
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("spawn holder");
let net_def = pelagos::network::load_network_def(net_name).expect("load net def");
pelagos::dns::dns_add_entry(
net_name,
"dummy-fwd",
"10.90.12.5".parse().unwrap(),
net_def.gateway,
&["8.8.8.8".to_string(), "1.1.1.1".to_string()],
)
.expect("dns_add_entry");
std::thread::sleep(std::time::Duration::from_millis(500));
let resolve_cmd = format!(
"nslookup example.com {} 2>&1 || echo 'NSLOOKUP_FAILED'",
net_def.gateway
);
let mut client = Command::new("/bin/sh")
.args(["-c", &resolve_cmd])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net_name.to_string()))
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn client");
let (_status, stdout_raw, stderr_raw) = client.wait_with_output().expect("client wait");
let stdout = String::from_utf8_lossy(&stdout_raw);
let stderr = String::from_utf8_lossy(&stderr_raw);
assert!(
!stdout.contains("NSLOOKUP_FAILED")
&& (stdout.contains("Address") || stdout.contains("Name")),
"dnsmasq should forward upstream queries, stdout: {}, stderr: {}",
stdout.trim(),
stderr.trim()
);
pelagos::dns::dns_remove_entry(net_name, "dummy-fwd").ok();
unsafe { libc::kill(holder.pid(), libc::SIGTERM) };
let _ = holder.wait();
cleanup_dns();
cleanup_test_network(net_name);
unsafe { std::env::remove_var("REMORA_DNS_BACKEND") };
}
#[test]
#[serial(nat)]
fn test_dns_dnsmasq_lifecycle() {
if !is_root() {
eprintln!("Skipping test_dns_dnsmasq_lifecycle (requires root)");
return;
}
if !has_dnsmasq() {
eprintln!("Skipping test_dns_dnsmasq_lifecycle (dnsmasq not found)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping test_dns_dnsmasq_lifecycle (no rootfs)");
return;
}
};
let net_name = "dnsmqlc";
cleanup_dns();
create_test_network(net_name, "10.90.13.0/24");
unsafe { std::env::set_var("REMORA_DNS_BACKEND", "dnsmasq") };
let mut holder = Command::new("/bin/sleep")
.args(["30"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net_name.to_string()))
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("spawn holder");
let net_def = pelagos::network::load_network_def(net_name).expect("load net def");
let pid_file = pelagos::paths::dns_pid_file();
assert!(
!pid_file.exists(),
"PID file should not exist before DNS entries"
);
pelagos::dns::dns_add_entry(
net_name,
"lifecycle-dnsmasq",
"10.90.13.5".parse().unwrap(),
net_def.gateway,
&["8.8.8.8".to_string()],
)
.expect("dns_add_entry");
std::thread::sleep(std::time::Duration::from_millis(500));
assert!(
pid_file.exists(),
"PID file should exist after DNS entry added"
);
let pid_str = std::fs::read_to_string(&pid_file).expect("read PID file");
let pid: i32 = pid_str.trim().parse().expect("parse PID");
assert!(
unsafe { libc::kill(pid, 0) } == 0,
"dnsmasq daemon (PID {}) should be alive",
pid
);
let backend_file = pelagos::paths::dns_backend_file();
assert!(backend_file.exists(), "backend marker should exist");
let marker = std::fs::read_to_string(&backend_file).unwrap();
assert_eq!(marker.trim(), "dnsmasq", "backend should be dnsmasq");
pelagos::dns::dns_remove_entry(net_name, "lifecycle-dnsmasq").expect("dns_remove_entry");
unsafe { libc::kill(pid, libc::SIGTERM) };
std::thread::sleep(std::time::Duration::from_millis(300));
assert!(
unsafe { libc::kill(pid, 0) } != 0,
"dnsmasq daemon (PID {}) should have exited after SIGTERM",
pid
);
unsafe { libc::kill(holder.pid(), libc::SIGTERM) };
let _ = holder.wait();
cleanup_dns();
cleanup_test_network(net_name);
unsafe { std::env::remove_var("REMORA_DNS_BACKEND") };
}
}
#[test]
#[serial]
fn test_child_drop_cleans_up_netns() {
if !is_root() {
eprintln!("Skipping test_child_drop_cleans_up_netns (requires root)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping test_child_drop_cleans_up_netns (no rootfs)");
return;
}
};
let child = Command::new("/bin/sleep")
.args(["60"])
.with_chroot(&rootfs)
.with_namespaces(Namespace::UTS | Namespace::MOUNT | Namespace::NET | Namespace::PID)
.with_proc_mount()
.with_network(NetworkMode::Bridge)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn container for drop test");
let ns_name = child
.netns_name()
.expect("bridge container should have netns name")
.to_string();
let netns_path = std::path::Path::new("/run/netns").join(&ns_name);
assert!(
netns_path.exists(),
"netns {} should exist before drop",
ns_name
);
drop(child);
std::thread::sleep(std::time::Duration::from_millis(200));
assert!(
!netns_path.exists(),
"netns {} should be removed after drop",
ns_name
);
}
#[test]
fn test_sexpr_parse_compose_file() {
let input = r#"
; A typical web application stack
(compose
(network backend (subnet "10.88.1.0/24"))
(network frontend (subnet "10.88.2.0/24"))
(volume pgdata)
(service db
(image "postgres:16")
(network backend)
(volume pgdata "/var/lib/postgresql/data")
(env POSTGRES_PASSWORD "secret")
(port 5432 5432)
(memory "512m"))
(service api
(image "my-api:latest")
(network backend frontend)
(depends-on (db :ready-port 5432))
(env DATABASE_URL "postgres://db:5432/app")
(port 8080 8080)
(cpus "1.0"))
(service web
(image "my-web:latest")
(network frontend)
(depends-on (api :ready-port 8080))
(port 80 3000)
(command "/bin/sh" "-c" "nginx -g 'daemon off;'")))
"#;
let expr = pelagos::sexpr::parse(input).expect("should parse compose file");
let items = expr.as_list().expect("top-level should be a list");
assert_eq!(items[0].as_atom().unwrap(), "compose");
assert_eq!(items.len(), 7);
}
#[test]
fn test_compose_parse_and_validate() {
let input = r#"
(compose
(network backend (subnet "10.88.1.0/24"))
(volume data)
(service db
(image "postgres:16")
(network backend)
(volume data "/var/lib/postgresql/data")
(env POSTGRES_PASSWORD "secret")
(port 5432 5432)
(memory "512m"))
(service api
(image "my-api:latest")
(network backend)
(depends-on (db :ready-port 5432))
(port 8080 8080)))
"#;
let compose = pelagos::compose::parse_compose(input).expect("should parse and validate");
assert_eq!(compose.networks.len(), 1);
assert_eq!(compose.networks[0].name, "backend");
assert_eq!(compose.networks[0].subnet.as_deref(), Some("10.88.1.0/24"));
assert_eq!(compose.volumes, vec!["data"]);
assert_eq!(compose.services.len(), 2);
let db = &compose.services[0];
assert_eq!(db.name, "db");
assert_eq!(db.image, "postgres:16");
assert_eq!(db.networks, vec!["backend"]);
assert_eq!(db.volumes[0].name, "data");
assert_eq!(db.volumes[0].mount_path, "/var/lib/postgresql/data");
assert_eq!(db.env.get("POSTGRES_PASSWORD").unwrap(), "secret");
assert_eq!(db.ports[0].host, 5432);
assert_eq!(db.ports[0].container, 5432);
assert_eq!(db.memory.as_deref(), Some("512m"));
let api = &compose.services[1];
assert_eq!(api.depends_on.len(), 1);
assert_eq!(api.depends_on[0].service, "db");
assert_eq!(
api.depends_on[0].health_check,
Some(pelagos::compose::HealthCheck::Port(5432))
);
}
#[test]
fn test_compose_topo_sort() {
let input = r#"
(compose
(service web
(image "web")
(depends-on api))
(service api
(image "api")
(depends-on db))
(service db
(image "db")))
"#;
let compose = pelagos::compose::parse_compose(input).expect("should parse");
let order = pelagos::compose::topo_sort(&compose.services).expect("should topo-sort");
let db_pos = order.iter().position(|n| n == "db").unwrap();
let api_pos = order.iter().position(|n| n == "api").unwrap();
let web_pos = order.iter().position(|n| n == "web").unwrap();
assert!(
db_pos < api_pos,
"db ({}) must come before api ({})",
db_pos,
api_pos
);
assert!(
api_pos < web_pos,
"api ({}) must come before web ({})",
api_pos,
web_pos
);
}
#[test]
fn test_compose_cycle_detection() {
let input = r#"
(compose
(service a
(image "a")
(depends-on b))
(service b
(image "b")
(depends-on a)))
"#;
let err = pelagos::compose::parse_compose(input).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("cycle"), "expected cycle error, got: {}", msg);
}
#[test]
fn test_compose_unknown_dependency() {
let input = r#"
(compose
(service a
(image "a")
(depends-on nonexistent)))
"#;
let err = pelagos::compose::parse_compose(input).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("unknown service"),
"expected unknown dependency error, got: {}",
msg
);
}
#[test]
#[serial]
fn test_compose_up_down_single_service() {
if !is_root() {
eprintln!("skipping test_compose_up_down_single_service: requires root");
return;
}
let _rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("skipping test_compose_up_down_single_service: no alpine-rootfs");
return;
}
};
let project_name = "test-compose";
let project_dir = pelagos::paths::compose_project_dir(project_name);
let _ = std::fs::remove_dir_all(&project_dir);
std::fs::create_dir_all(pelagos::paths::compose_project_dir(project_name))
.expect("should create compose project dir");
assert!(
pelagos::paths::compose_project_dir(project_name).exists(),
"compose project dir should exist"
);
let _ = std::fs::remove_dir_all(&project_dir);
}
#[test]
fn test_compose_bind_mount_parse_and_validate() {
let input = r#"
(compose
(network monitoring (subnet "172.20.0.0/24"))
(volume grafana-data)
; Prometheus: two read-only bind mounts (config + rules dir)
(service prometheus
(image "prom/prometheus:latest")
(network monitoring)
(port 9090 9090)
(bind-mount "./config/prometheus.yml" "/etc/prometheus/prometheus.yml" :ro)
(bind-mount "./config/rules" "/etc/prometheus/rules" :ro))
; Grafana: named volume + read-only provisioning bind mount + rw data
(service grafana
(image "grafana/grafana:latest")
(network monitoring)
(port 3000 3000)
(volume grafana-data "/var/lib/grafana")
(bind-mount "./config/grafana/provisioning" "/etc/grafana/provisioning" :ro)
(env GF_SECURITY_ADMIN_PASSWORD "secret")
(depends-on (prometheus :ready-port 9090)))
; SNMP exporter: single read-only config
(service snmp-exporter
(image "prom/snmp-exporter:v0.21.0")
(network monitoring)
(port 9116 9116)
(bind-mount "./config/snmp.yml" "/etc/snmp_exporter/snmp.yml" :ro)))
"#;
let compose = pelagos::compose::parse_compose(input).expect("should parse and validate");
assert_eq!(compose.networks.len(), 1);
assert_eq!(compose.volumes, vec!["grafana-data"]);
assert_eq!(compose.services.len(), 3);
let prom = compose
.services
.iter()
.find(|s| s.name == "prometheus")
.unwrap();
assert_eq!(prom.bind_mounts.len(), 2);
assert_eq!(prom.bind_mounts[0].host_path, "./config/prometheus.yml");
assert_eq!(
prom.bind_mounts[0].container_path,
"/etc/prometheus/prometheus.yml"
);
assert!(prom.bind_mounts[0].read_only, "prometheus.yml must be :ro");
assert_eq!(prom.bind_mounts[1].host_path, "./config/rules");
assert!(prom.bind_mounts[1].read_only);
assert!(prom.volumes.is_empty());
let grafana = compose
.services
.iter()
.find(|s| s.name == "grafana")
.unwrap();
assert_eq!(grafana.volumes.len(), 1);
assert_eq!(grafana.volumes[0].name, "grafana-data");
assert_eq!(grafana.bind_mounts.len(), 1);
assert!(grafana.bind_mounts[0].read_only);
assert_eq!(grafana.depends_on[0].service, "prometheus");
assert_eq!(
grafana.depends_on[0].health_check,
Some(pelagos::compose::HealthCheck::Port(9090))
);
let snmp = compose
.services
.iter()
.find(|s| s.name == "snmp-exporter")
.unwrap();
assert_eq!(snmp.bind_mounts.len(), 1);
assert_eq!(
snmp.bind_mounts[0].container_path,
"/etc/snmp_exporter/snmp.yml"
);
assert!(snmp.bind_mounts[0].read_only);
let order = pelagos::compose::topo_sort(&compose.services).unwrap();
let prom_pos = order.iter().position(|n| n == "prometheus").unwrap();
let grafana_pos = order.iter().position(|n| n == "grafana").unwrap();
assert!(
prom_pos < grafana_pos,
"prometheus must start before grafana"
);
}
#[test]
fn test_compose_tmpfs_parse_and_validate() {
let input = r#"
(compose
(network cache (subnet "10.77.0.0/24"))
(service redis
(image "redis:7")
(network cache)
(tmpfs "/data"))
(service app
(image "my-app:latest")
(network cache)
(tmpfs "/tmp")
(tmpfs "/run")
(depends-on redis)))
"#;
let compose = pelagos::compose::parse_compose(input).expect("should parse and validate");
assert_eq!(compose.services.len(), 2);
let redis = compose.services.iter().find(|s| s.name == "redis").unwrap();
assert_eq!(redis.tmpfs_mounts.len(), 1);
assert_eq!(redis.tmpfs_mounts[0], "/data");
assert!(redis.bind_mounts.is_empty());
assert!(redis.volumes.is_empty());
let app = compose.services.iter().find(|s| s.name == "app").unwrap();
assert_eq!(app.tmpfs_mounts.len(), 2);
assert_eq!(app.tmpfs_mounts[0], "/tmp");
assert_eq!(app.tmpfs_mounts[1], "/run");
assert_eq!(app.depends_on[0].service, "redis");
let order = pelagos::compose::topo_sort(&compose.services).unwrap();
let redis_pos = order.iter().position(|n| n == "redis").unwrap();
let app_pos = order.iter().position(|n| n == "app").unwrap();
assert!(redis_pos < app_pos, "redis must start before app");
}
#[test]
fn test_compose_health_check_parse() {
use pelagos::compose::HealthCheck;
let input = r#"
(compose
(network net (subnet "10.77.1.0/24"))
; port check (new :ready syntax)
(service svc-port
(image "img")
(network net)
(depends-on (base :ready (port 5432))))
; http check
(service svc-http
(image "img")
(network net)
(depends-on (base :ready (http "http://localhost:8080/healthz"))))
; cmd check (single-string form, split on whitespace)
(service svc-cmd
(image "img")
(network net)
(depends-on (base :ready (cmd "pg_isready -U postgres"))))
; and check
(service svc-and
(image "img")
(network net)
(depends-on (base :ready (and (port 5432) (cmd "pg_isready")))))
; or check
(service svc-or
(image "img")
(network net)
(depends-on (base :ready (or (port 8080) (http "http://localhost:8080/health")))))
; backward compat: :ready-port N stays as Port(N)
(service svc-compat
(image "img")
(network net)
(depends-on (base :ready-port 6379)))
(service base
(image "img")
(network net)))
"#;
let compose = pelagos::compose::parse_compose(input).expect("should parse");
assert_eq!(compose.services.len(), 7);
let find = |name: &str| {
compose
.services
.iter()
.find(|s| s.name == name)
.unwrap()
.depends_on[0]
.health_check
.clone()
};
assert_eq!(find("svc-port"), Some(HealthCheck::Port(5432)));
assert_eq!(
find("svc-http"),
Some(HealthCheck::Http(
"http://localhost:8080/healthz".to_string()
))
);
assert_eq!(
find("svc-cmd"),
Some(HealthCheck::Cmd(vec![
"pg_isready".into(),
"-U".into(),
"postgres".into()
]))
);
assert_eq!(
find("svc-and"),
Some(HealthCheck::And(vec![
HealthCheck::Port(5432),
HealthCheck::Cmd(vec!["pg_isready".into()])
]))
);
assert_eq!(
find("svc-or"),
Some(HealthCheck::Or(vec![
HealthCheck::Port(8080),
HealthCheck::Http("http://localhost:8080/health".into())
]))
);
assert_eq!(find("svc-compat"), Some(HealthCheck::Port(6379)));
let base = compose.services.iter().find(|s| s.name == "base").unwrap();
assert!(base.depends_on.is_empty());
}
#[test]
fn test_lisp_compose_basic() {
use pelagos::lisp::Interpreter;
let mut interp = Interpreter::new();
interp
.eval_str(
r#"
; Parameterised service factory
(define (mk-service name img net)
(service name
(list 'image img)
(list 'network net)))
; Build three services with map
(define services
(map (lambda (pair)
(mk-service (car pair) (cadr pair) "backend"))
'(("db" "postgres:16")
("api" "myapi:latest")
("web" "nginx:stable"))))
; Register an on-ready hook
(on-ready "db" (lambda () (log "db ready")))
; Store the spec via compose-up
(compose-up
(compose
(network "backend" (list 'subnet "10.90.0.0/24"))
services))
"#,
)
.expect("eval_str failed");
let pending = interp.take_pending().expect("no pending compose");
let spec = pending.spec.expect("no spec in pending");
assert_eq!(spec.networks.len(), 1);
assert_eq!(spec.networks[0].name, "backend");
assert_eq!(spec.services.len(), 3);
let names: Vec<&str> = spec.services.iter().map(|s| s.name.as_str()).collect();
assert!(names.contains(&"db"), "db missing from {:?}", names);
assert!(names.contains(&"api"), "api missing from {:?}", names);
assert!(names.contains(&"web"), "web missing from {:?}", names);
let db = spec.services.iter().find(|s| s.name == "db").unwrap();
assert_eq!(db.image, "postgres:16");
assert_eq!(db.networks, vec!["backend"]);
let hooks = interp.take_hooks();
assert!(hooks.contains_key("db"), "no hook for 'db'");
}
#[test]
fn test_lisp_evaluator_tco_and_higher_order() {
use pelagos::lisp::Interpreter;
let mut interp = Interpreter::new();
let sum = interp
.eval_str(
"(define (sum-to n)
(let loop ((i n) (acc 0))
(if (= i 0) acc (loop (- i 1) (+ acc i)))))
(sum-to 10000)",
)
.expect("eval failed");
assert_eq!(sum, pelagos::lisp::Value::Int(50005000));
let squares = interp
.eval_str("(map (lambda (x) (* x x)) '(1 2 3 4 5))")
.expect("map failed");
let items = squares.to_vec().expect("not a list");
assert_eq!(items.len(), 5);
assert_eq!(items[4], pelagos::lisp::Value::Int(25));
}
#[test]
fn test_lisp_eval_file_web_stack_fixture() {
use pelagos::lisp::Interpreter;
let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("examples/compose/web-stack/compose.reml");
assert!(fixture.exists(), "fixture not found: {}", fixture.display());
let mut interp = Interpreter::new();
interp
.eval_file(&fixture)
.unwrap_or_else(|e| panic!("eval_file failed: {}", e.message));
let pending = interp.take_pending().expect("compose-up was not called");
let spec = pending.spec.expect("no spec in pending");
assert_eq!(spec.networks.len(), 2);
let net_names: Vec<&str> = spec.networks.iter().map(|n| n.name.as_str()).collect();
assert!(net_names.contains(&"frontend"), "missing frontend network");
assert!(net_names.contains(&"backend"), "missing backend network");
let frontend = spec.networks.iter().find(|n| n.name == "frontend").unwrap();
assert_eq!(
frontend.subnet.as_deref(),
Some("10.88.1.0/24"),
"frontend subnet wrong"
);
assert_eq!(spec.volumes, vec!["notes-data"]);
assert_eq!(spec.services.len(), 3);
let names: Vec<&str> = spec.services.iter().map(|s| s.name.as_str()).collect();
assert!(names.contains(&"redis"), "redis missing");
assert!(names.contains(&"app"), "app missing");
assert!(names.contains(&"proxy"), "proxy missing");
let redis = spec.services.iter().find(|s| s.name == "redis").unwrap();
assert_eq!(redis.image, "web-stack-redis:latest");
assert_eq!(redis.networks, vec!["backend"]);
assert_eq!(redis.memory.as_deref(), Some("64m"));
assert!(redis.depends_on.is_empty());
let app = spec.services.iter().find(|s| s.name == "app").unwrap();
assert_eq!(app.networks, vec!["frontend", "backend"]);
assert_eq!(app.depends_on.len(), 1);
assert_eq!(app.depends_on[0].service, "redis");
assert!(
matches!(
app.depends_on[0].health_check,
Some(pelagos::compose::HealthCheck::Port(6379))
),
"app should depend on redis:6379 TCP check"
);
assert_eq!(app.env.get("REDIS_HOST").map(String::as_str), Some("redis"));
let proxy = spec.services.iter().find(|s| s.name == "proxy").unwrap();
assert_eq!(proxy.networks, vec!["frontend"]);
assert_eq!(proxy.depends_on.len(), 1);
assert_eq!(proxy.depends_on[0].service, "app");
assert!(
matches!(
proxy.depends_on[0].health_check,
Some(pelagos::compose::HealthCheck::Port(5000))
),
"proxy should depend on app:5000 TCP check"
);
assert_eq!(proxy.ports.len(), 1);
assert_eq!(
proxy.ports[0].host, 8080,
"default host port should be 8080"
);
assert_eq!(proxy.ports[0].container, 80);
let hooks = interp.take_hooks();
assert!(
hooks.contains_key("redis"),
"on-ready hook for 'redis' missing"
);
assert!(hooks.contains_key("app"), "on-ready hook for 'app' missing");
}
#[test]
fn test_lisp_depends_on_with_port() {
use pelagos::lisp::Interpreter;
let mut interp = Interpreter::new();
interp
.eval_str(
r#"
(compose-up
(compose
(service "worker"
'(image "myapp:latest")
(list 'depends-on "db" 5432)
(list 'depends-on "cache"))))
"#,
)
.expect("eval failed");
let spec = interp
.take_pending()
.expect("no pending")
.spec
.expect("no spec");
let worker = spec.services.iter().find(|s| s.name == "worker").unwrap();
assert_eq!(worker.depends_on.len(), 2);
let dep_db = worker
.depends_on
.iter()
.find(|d| d.service == "db")
.unwrap();
assert!(
matches!(
dep_db.health_check,
Some(pelagos::compose::HealthCheck::Port(5432))
),
"db dependency should have Port(5432)"
);
let dep_cache = worker
.depends_on
.iter()
.find(|d| d.service == "cache")
.unwrap();
assert!(
dep_cache.health_check.is_none(),
"cache dependency should have no health check"
);
}
#[test]
fn test_lisp_env_fallback_and_override() {
use pelagos::lisp::Interpreter;
std::env::remove_var("_REMORA_TEST_PORT");
let mut interp = Interpreter::new();
let v = interp
.eval_str(
r#"(let ((p (env "_REMORA_TEST_PORT")))
(if (null? p) 9999 (string->number p)))"#,
)
.expect("eval failed");
assert_eq!(v, pelagos::lisp::Value::Int(9999));
std::env::set_var("_REMORA_TEST_PORT", "1234");
let v2 = interp
.eval_str(
r#"(let ((p (env "_REMORA_TEST_PORT")))
(if (null? p) 9999 (string->number p)))"#,
)
.expect("eval failed");
assert_eq!(v2, pelagos::lisp::Value::Int(1234));
std::env::remove_var("_REMORA_TEST_PORT");
}
#[test]
fn test_lisp_eval_file_jupyter_fixture() {
use pelagos::compose::{HealthCheck, ServiceSpec};
use pelagos::lisp::Interpreter;
let fixture = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("examples/compose/jupyter/compose.reml");
unsafe { std::env::remove_var("JUPYTER_PORT") };
let mut interp = Interpreter::new();
interp.eval_file(&fixture).expect("eval_file failed");
let pending = interp.take_pending().expect("compose-up was not called");
let spec = pending.spec.expect("compose-up produced no spec");
assert_eq!(spec.networks.len(), 1, "expected 1 network");
assert_eq!(spec.networks[0].name, "jupyter-net");
assert_eq!(
spec.networks[0].subnet.as_deref(),
Some("10.89.0.0/24"),
"wrong subnet"
);
assert!(
spec.volumes.contains(&"jupyter-notebooks".to_string()),
"missing jupyter-notebooks volume"
);
assert_eq!(spec.services.len(), 2, "expected 2 services");
let redis = spec
.services
.iter()
.find(|s: &&ServiceSpec| s.name == "redis")
.expect("redis service missing");
assert_eq!(redis.image, "jupyter-redis:latest");
assert!(redis.depends_on.is_empty(), "redis should have no deps");
assert_eq!(redis.memory.as_deref(), Some("64m"));
let jlab = spec
.services
.iter()
.find(|s: &&ServiceSpec| s.name == "jupyterlab")
.expect("jupyterlab service missing");
assert_eq!(jlab.image, "jupyter-jupyterlab:latest");
assert_eq!(jlab.depends_on.len(), 1);
assert_eq!(jlab.depends_on[0].service, "redis");
assert_eq!(
jlab.depends_on[0].health_check,
Some(HealthCheck::Port(6379))
);
assert_eq!(jlab.ports.len(), 1);
assert_eq!(jlab.ports[0].host, 8888);
assert_eq!(jlab.ports[0].container, 8888);
assert_eq!(
jlab.env.get("REDIS_HOST").map(String::as_str),
Some("redis")
);
assert_eq!(jlab.env.get("REDIS_PORT").map(String::as_str), Some("6379"));
let hooks = interp.take_hooks();
assert!(
hooks.contains_key("redis"),
"on-ready hook for redis not registered"
);
assert_eq!(
hooks["redis"].len(),
1,
"expected exactly one hook for redis"
);
}
fn read_proc_status(pid: i32) -> Option<String> {
std::fs::read_to_string(format!("/proc/{}/status", pid)).ok()
}
fn proc_status_field<'a>(status: &'a str, field: &str) -> Option<&'a str> {
for line in status.lines() {
if let Some(rest) = line.strip_prefix(field) {
return Some(rest.trim());
}
}
None
}
fn first_child_pid(parent_pid: i32) -> Option<i32> {
let path = format!("/proc/{}/task/{}/children", parent_pid, parent_pid);
let contents = std::fs::read_to_string(path).ok()?;
contents
.split_whitespace()
.next()
.and_then(|s| s.parse().ok())
}
#[test]
fn test_hardening_combination() {
if !is_root() {
eprintln!("SKIP: test_hardening_combination requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("SKIP: test_hardening_combination requires alpine-rootfs");
return;
}
};
let mut child = Command::new("/bin/sh")
.args([
"-c",
"grep -E '^(Seccomp|CapEff|NoNewPrivs|NSpid):' /proc/self/status; \
echo HOSTNAME=$(hostname)",
])
.with_chroot(&rootfs)
.with_proc_mount()
.with_namespaces(Namespace::MOUNT | Namespace::PID | Namespace::UTS | Namespace::IPC)
.with_hostname("hardening-test")
.with_seccomp_default()
.drop_all_capabilities()
.with_no_new_privileges(true)
.with_masked_paths_default()
.env("PATH", ALPINE_PATH)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn failed");
let (status, stdout_bytes, _stderr) =
child.wait_with_output().expect("wait_with_output failed");
let stdout = String::from_utf8_lossy(&stdout_bytes);
assert!(
stdout.contains("Seccomp:\t2") || stdout.contains("Seccomp: 2"),
"expected Seccomp:2, got: {stdout}"
);
let capeff = stdout
.lines()
.find(|l| l.starts_with("CapEff:"))
.unwrap_or("CapEff: not found");
let capeff_val = capeff.split_whitespace().nth(1).unwrap_or("?");
assert!(
capeff_val.chars().all(|c| c == '0'),
"expected all-zero CapEff, got: {capeff_val}"
);
assert!(
stdout.contains("NoNewPrivs:\t1") || stdout.contains("NoNewPrivs: 1"),
"expected NoNewPrivs:1, got: {stdout}"
);
let nspid_line = stdout
.lines()
.find(|l| l.starts_with("NSpid:"))
.unwrap_or("NSpid: not found");
let inner_nspid: u32 = nspid_line
.split_whitespace()
.last()
.and_then(|s| s.parse().ok())
.unwrap_or(99999);
assert!(
inner_nspid <= 5,
"expected small innermost NSpid (≤5, proves PID namespace active), NSpid line: {nspid_line}"
);
assert!(
stdout.contains("HOSTNAME=hardening-test"),
"expected HOSTNAME=hardening-test, got: {stdout}"
);
assert!(status.success(), "container exited non-zero: {:?}", status);
}
#[test]
#[serial]
fn test_lisp_container_spawn_hardening() {
if !is_root() {
eprintln!("SKIP: test_lisp_container_spawn_hardening requires root");
return;
}
if pelagos::image::load_image("alpine:latest").is_err() {
eprintln!(
"SKIP: test_lisp_container_spawn_hardening requires alpine:latest in image store"
);
return;
}
use pelagos::lisp::Interpreter;
use pelagos::lisp::Value;
let tmp = tempfile::TempDir::new().expect("tempdir");
let mut interp = Interpreter::new_with_runtime("test-iso".into(), tmp.path().to_path_buf());
let val = interp
.eval_str(
r#"(container-start
(service "probe"
(list 'image "alpine:latest")
(list 'command "sleep" "30")))"#,
)
.expect("container-start failed");
let intermediate_pid = match val {
Value::ContainerHandle { pid, .. } => pid,
other => panic!("expected ContainerHandle, got {:?}", other),
};
std::thread::sleep(std::time::Duration::from_millis(300));
let inner_pid = first_child_pid(intermediate_pid)
.expect("could not find inner child of container intermediate process");
let status = read_proc_status(inner_pid).expect("could not read inner child's /proc/status");
let seccomp = proc_status_field(&status, "Seccomp:").unwrap_or("missing");
assert_eq!(
seccomp, "2",
"expected Seccomp:2 in lisp container, got: {seccomp}"
);
let capeff = proc_status_field(&status, "CapEff:").unwrap_or("missing");
assert!(
capeff.chars().all(|c| c == '0'),
"expected all-zero CapEff in lisp container, got: {capeff}"
);
let nnp = proc_status_field(&status, "NoNewPrivs:").unwrap_or("missing");
assert_eq!(
nnp, "1",
"expected NoNewPrivs:1 in lisp container, got: {nnp}"
);
let container_uts = std::fs::read_link(format!("/proc/{}/ns/uts", inner_pid))
.expect("readlink container ns/uts");
let host_uts = std::fs::read_link("/proc/self/ns/uts").expect("readlink host ns/uts");
assert_ne!(
container_uts, host_uts,
"container UTS namespace should differ from host"
);
drop(interp);
}
mod registry_auth {
use super::*;
fn find_free_port() -> u16 {
use std::net::TcpListener;
let l = TcpListener::bind("127.0.0.1:0").expect("bind to port 0");
l.local_addr().unwrap().port()
}
fn wait_for_tcp(addr: &str, deadline: std::time::Instant) -> bool {
use std::net::TcpStream;
while std::time::Instant::now() < deadline {
if TcpStream::connect(addr).is_ok() {
return true;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
false
}
fn cleanup_container(name: &str) {
let bin = env!("CARGO_BIN_EXE_remora");
let _ = std::process::Command::new(bin)
.args(["stop", name])
.output();
std::thread::sleep(std::time::Duration::from_millis(200));
let _ = std::process::Command::new(bin)
.args(["rm", "-f", name])
.output();
}
#[test]
#[ignore]
#[serial]
fn test_local_registry_push_pull_roundtrip() {
if !is_root() {
eprintln!("Skipping: requires root");
return;
}
let bin = env!("CARGO_BIN_EXE_remora");
let port = find_free_port();
let registry_addr = format!("127.0.0.1:{}", port);
let registry_name = format!("test-registry-{}", port);
let pull = std::process::Command::new(bin)
.args(["image", "pull", "registry:2"])
.status()
.expect("remora image pull registry:2");
assert!(pull.success(), "failed to pull registry:2");
let port_map = format!("{}:5000", port);
let run_status = std::process::Command::new(bin)
.args([
"run",
"--detach",
"--name",
®istry_name,
"--network",
"bridge",
"-p",
&port_map,
"registry:2",
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("remora run registry:2");
assert!(run_status.success(), "failed to start registry");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
assert!(
wait_for_tcp(®istry_addr, deadline),
"registry did not become reachable on {}",
registry_addr
);
let _ = std::process::Command::new(bin)
.args(["image", "pull", "alpine"])
.status();
let dest_ref = format!("{}/library/alpine:latest", registry_addr);
let push_out = std::process::Command::new(bin)
.args(["image", "push", "alpine", "--dest", &dest_ref, "--insecure"])
.output()
.expect("remora image push");
assert!(
push_out.status.success(),
"push failed: {}",
String::from_utf8_lossy(&push_out.stderr)
);
assert!(
String::from_utf8_lossy(&push_out.stdout).contains("Pushed"),
"expected 'Pushed' in push output, got: {}",
String::from_utf8_lossy(&push_out.stdout)
);
let _ = std::process::Command::new(bin)
.args(["image", "rm", &dest_ref])
.output();
let pull2_out = std::process::Command::new(bin)
.args(["image", "pull", "--insecure", &dest_ref])
.output()
.expect("remora image pull from local registry");
assert!(
pull2_out.status.success(),
"pull from local registry failed: {}",
String::from_utf8_lossy(&pull2_out.stderr)
);
let ls_out = std::process::Command::new(bin)
.args(["image", "ls", "--format", "json"])
.output()
.expect("image ls");
let ls_json = String::from_utf8_lossy(&ls_out.stdout);
assert!(
ls_json.contains(®istry_addr),
"local registry image should appear in image ls, got: {}",
ls_json
);
let _ = std::process::Command::new(bin)
.args(["image", "rm", &dest_ref])
.output();
cleanup_container(®istry_name);
}
#[test]
#[ignore]
#[serial]
fn test_local_registry_auth_roundtrip() {
if !is_root() {
eprintln!("Skipping: requires root");
return;
}
let bin = env!("CARGO_BIN_EXE_remora");
let port = find_free_port();
let registry_addr = format!("127.0.0.1:{}", port);
let registry_name = format!("test-auth-registry-{}", port);
let test_user = "testuser";
let test_pass = "testpassword";
let htpasswd_entry =
"testuser:$2y$05$8/q2bfRcX74EuxGf0qOcSuhWDQJXrgWiy6Fi73/JM2tKC66qSrLve";
let htpasswd_dir = tempfile::tempdir().expect("tempdir for htpasswd");
let htpasswd_path = htpasswd_dir.path().join("htpasswd");
std::fs::write(&htpasswd_path, format!("{}\n", htpasswd_entry)).expect("write htpasswd");
let _ = std::process::Command::new(bin)
.args(["image", "pull", "registry:2"])
.status();
let port_map = format!("{}:5000", port);
let htpasswd_mount = format!("{}:/auth/htpasswd:ro", htpasswd_path.display());
let run_status = std::process::Command::new(bin)
.args([
"run",
"--detach",
"--name",
®istry_name,
"--network",
"bridge",
"-p",
&port_map,
"-v",
&htpasswd_mount,
"-e",
"REGISTRY_AUTH=htpasswd",
"-e",
"REGISTRY_AUTH_HTPASSWD_REALM=Registry Realm",
"-e",
"REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd",
"registry:2",
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("remora run registry:2 with auth");
assert!(run_status.success(), "failed to start auth registry");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
assert!(
wait_for_tcp(®istry_addr, deadline),
"auth registry did not become reachable on {}",
registry_addr
);
std::thread::sleep(std::time::Duration::from_millis(500));
let _ = std::process::Command::new(bin)
.args(["image", "pull", "alpine"])
.status();
let dest_ref = format!("{}/library/alpine:latest", registry_addr);
let push_anon = std::process::Command::new(bin)
.args(["image", "push", "alpine", "--dest", &dest_ref, "--insecure"])
.output()
.expect("push without creds");
assert!(
!push_anon.status.success(),
"anonymous push to authenticated registry should fail, stdout: {}",
String::from_utf8_lossy(&push_anon.stdout)
);
let push_explicit = std::process::Command::new(bin)
.args([
"image",
"push",
"alpine",
"--dest",
&dest_ref,
"--insecure",
"--username",
test_user,
"--password",
test_pass,
])
.output()
.expect("push with explicit creds");
assert!(
push_explicit.status.success(),
"push with explicit credentials failed: {} / {}",
String::from_utf8_lossy(&push_explicit.stdout),
String::from_utf8_lossy(&push_explicit.stderr)
);
assert!(
String::from_utf8_lossy(&push_explicit.stdout).contains("Pushed"),
"expected 'Pushed' (explicit creds), got: {}",
String::from_utf8_lossy(&push_explicit.stdout)
);
let home_dir = tempfile::tempdir().expect("tempdir for HOME");
let home_path = home_dir.path().to_str().unwrap().to_string();
let mut login_child = std::process::Command::new(bin)
.args([
"image",
"login",
"--username",
test_user,
"--password-stdin",
®istry_addr,
])
.env("HOME", &home_path)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("remora image login");
{
use std::io::Write as _;
login_child
.stdin
.as_mut()
.expect("stdin")
.write_all(test_pass.as_bytes())
.expect("write password");
}
let login_out = login_child.wait_with_output().expect("login wait");
assert!(
login_out.status.success(),
"login failed: {}",
String::from_utf8_lossy(&login_out.stderr)
);
assert!(
String::from_utf8_lossy(&login_out.stdout).contains("Login Succeeded"),
"expected 'Login Succeeded', got: {}",
String::from_utf8_lossy(&login_out.stdout)
);
let config_path = std::path::PathBuf::from(&home_path)
.join(".docker")
.join("config.json");
let config_content = std::fs::read_to_string(&config_path)
.expect("~/.docker/config.json should exist after login");
assert!(
config_content.contains(®istry_addr),
"docker config should contain registry key '{}', got: {}",
registry_addr,
config_content
);
let push_auth = std::process::Command::new(bin)
.args(["image", "push", "alpine", "--dest", &dest_ref, "--insecure"])
.env("HOME", &home_path)
.output()
.expect("push via docker config");
assert!(
push_auth.status.success(),
"push via docker config failed: {} / {}",
String::from_utf8_lossy(&push_auth.stdout),
String::from_utf8_lossy(&push_auth.stderr)
);
assert!(
String::from_utf8_lossy(&push_auth.stdout).contains("Pushed"),
"expected 'Pushed' (docker config), got: {}",
String::from_utf8_lossy(&push_auth.stdout)
);
let _ = std::process::Command::new(bin)
.args(["image", "rm", &dest_ref])
.env("HOME", &home_path)
.output();
let pull_auth = std::process::Command::new(bin)
.args(["image", "pull", "--insecure", &dest_ref])
.env("HOME", &home_path)
.output()
.expect("pull with creds");
assert!(
pull_auth.status.success(),
"authenticated pull failed: {}",
String::from_utf8_lossy(&pull_auth.stderr)
);
let logout_out = std::process::Command::new(bin)
.args(["image", "logout", ®istry_addr])
.env("HOME", &home_path)
.output()
.expect("remora image logout");
assert!(
logout_out.status.success(),
"logout failed: {}",
String::from_utf8_lossy(&logout_out.stderr)
);
let _ = std::process::Command::new(bin)
.args(["image", "rm", &dest_ref])
.env("HOME", &home_path)
.output();
let pull_anon = std::process::Command::new(bin)
.args(["image", "pull", "--insecure", &dest_ref])
.env("HOME", &home_path)
.output()
.expect("pull after logout");
assert!(
!pull_anon.status.success(),
"pull after logout should fail (401), stdout: {}",
String::from_utf8_lossy(&pull_anon.stdout)
);
let _ = std::process::Command::new(bin)
.args(["image", "rm", &dest_ref])
.output();
cleanup_container(®istry_name);
}
}
mod image_save_load {
#[test]
#[ignore]
fn test_image_save_load_roundtrip() {
let bin = env!("CARGO_BIN_EXE_remora");
let reference = "docker.io/library/alpine:latest";
let tar_path = "/tmp/remora-test-alpine-save.tar";
let pull = std::process::Command::new(bin)
.args(["image", "pull", reference])
.output()
.expect("remora image pull");
assert!(
pull.status.success(),
"pull failed:\n{}",
String::from_utf8_lossy(&pull.stderr)
);
let _ = std::fs::remove_file(tar_path);
let save = std::process::Command::new(bin)
.args(["image", "save", reference, "-o", tar_path])
.output()
.expect("remora image save");
assert!(
save.status.success(),
"save failed:\n{}",
String::from_utf8_lossy(&save.stderr)
);
assert!(
std::path::Path::new(tar_path).exists(),
"tar file not created"
);
let tar_bytes = std::fs::read(tar_path).expect("read tar");
let cursor = std::io::Cursor::new(&tar_bytes);
let mut ar = tar::Archive::new(cursor);
let has_oci_layout = ar
.entries()
.unwrap()
.any(|e| e.unwrap().path().unwrap().to_string_lossy() == "oci-layout");
assert!(has_oci_layout, "tar missing oci-layout entry");
let rm = std::process::Command::new(bin)
.args(["image", "rm", reference])
.output()
.expect("remora image rm");
assert!(
rm.status.success(),
"rm failed:\n{}",
String::from_utf8_lossy(&rm.stderr)
);
let load = std::process::Command::new(bin)
.args(["image", "load", "-i", tar_path])
.output()
.expect("remora image load");
assert!(
load.status.success(),
"load failed:\n{}",
String::from_utf8_lossy(&load.stderr)
);
let load_out = String::from_utf8_lossy(&load.stdout);
assert!(
load_out.contains("Loaded"),
"expected 'Loaded' in output, got: {}",
load_out
);
let ls = std::process::Command::new(bin)
.args(["image", "ls"])
.output()
.expect("remora image ls");
let ls_out = String::from_utf8_lossy(&ls.stdout);
assert!(
ls_out.contains("alpine"),
"loaded image not found in ls output: {}",
ls_out
);
let run = std::process::Command::new(bin)
.args(["run", reference, "/bin/true"])
.output()
.expect("remora run");
assert!(
run.status.success(),
"run after load failed:\n{}",
String::from_utf8_lossy(&run.stderr)
);
let _ = std::fs::remove_file(tar_path);
let _ = std::process::Command::new(bin)
.args(["image", "rm", reference])
.output();
}
}
mod image_tag {
#[test]
#[ignore]
fn test_image_tag_roundtrip() {
let bin = env!("CARGO_BIN_EXE_remora");
let source = "docker.io/library/alpine:latest";
let target = "my-alpine:tagged";
let pull = std::process::Command::new(bin)
.args(["image", "pull", source])
.output()
.expect("remora image pull");
assert!(
pull.status.success(),
"pull failed:\n{}",
String::from_utf8_lossy(&pull.stderr)
);
let tag = std::process::Command::new(bin)
.args(["image", "tag", source, target])
.output()
.expect("remora image tag");
assert!(
tag.status.success(),
"tag failed:\n{}",
String::from_utf8_lossy(&tag.stderr)
);
let ls = std::process::Command::new(bin)
.args(["image", "ls"])
.output()
.expect("remora image ls");
let ls_out = String::from_utf8_lossy(&ls.stdout);
assert!(ls_out.contains("alpine"), "source not in ls:\n{}", ls_out);
assert!(
ls_out.contains("my-alpine"),
"tagged image not in ls:\n{}",
ls_out
);
let run = std::process::Command::new(bin)
.args(["run", target, "/bin/true"])
.output()
.expect("remora run");
assert!(
run.status.success(),
"run of tagged image failed:\n{}",
String::from_utf8_lossy(&run.stderr)
);
let rm_src = std::process::Command::new(bin)
.args(["image", "rm", source])
.output()
.expect("remora image rm source");
assert!(rm_src.status.success(), "rm source failed");
let run2 = std::process::Command::new(bin)
.args(["run", target, "/bin/true"])
.output()
.expect("remora run tagged after rm source");
assert!(
run2.status.success(),
"run of tagged image after source rm failed:\n{}",
String::from_utf8_lossy(&run2.stderr)
);
let _ = std::process::Command::new(bin)
.args(["image", "rm", target])
.output();
let _ = std::process::Command::new(bin)
.args(["image", "rm", source])
.output();
}
}
mod healthcheck_tests {
use super::*;
use pelagos::build::parse_remfile;
use pelagos::image::HealthConfig;
#[test]
#[ignore]
fn test_healthcheck_exec_true() {
if !is_root() {
return;
}
let rootfs = match super::get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping: alpine-rootfs not found");
return;
}
};
let bin = env!("CARGO_BIN_EXE_remora");
let name = "remora-healthcheck-exec-true-test";
let _ = std::process::Command::new(bin)
.args(["rm", "-f", name])
.output();
let run_status = std::process::Command::new(bin)
.args([
"run",
"-d",
"--name",
name,
"--rootfs",
rootfs.to_str().unwrap(),
"/bin/sh",
"-c",
"sleep 30",
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("remora run");
assert!(run_status.success(), "remora run -d failed");
let state_path = format!("/run/remora/containers/{}/state.json", name);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let mut last_state = String::from("(not yet written)");
while std::time::Instant::now() < deadline {
if let Ok(data) = std::fs::read_to_string(&state_path) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&data) {
if v["pid"].as_i64().unwrap_or(0) > 0 {
last_state.clear();
break;
}
}
last_state = data;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
assert!(
last_state.is_empty(),
"container pid still 0 after 10s — watcher likely crashed; last state.json:\n{}",
last_state
);
let true_result = std::process::Command::new(bin)
.args(["exec", name, "/bin/true"])
.status()
.expect("remora exec /bin/true");
assert!(true_result.success(), "remora exec /bin/true should exit 0");
let false_result = std::process::Command::new(bin)
.args(["exec", name, "/bin/false"])
.status()
.expect("remora exec /bin/false");
assert!(
!false_result.success(),
"remora exec /bin/false should exit non-zero"
);
let _ = std::process::Command::new(bin)
.args(["stop", name])
.output();
let _ = std::process::Command::new(bin).args(["rm", name]).output();
}
#[test]
#[ignore]
fn test_healthcheck_healthy() {
if !is_root() {
return;
}
let rootfs = match super::get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping: alpine-rootfs not found");
return;
}
};
let bin = env!("CARGO_BIN_EXE_remora");
let name = "remora-healthcheck-healthy-test";
let _ = std::process::Command::new(bin)
.args(["rm", "-f", name])
.output();
let run_status = std::process::Command::new(bin)
.args([
"run",
"-d",
"--name",
name,
"--rootfs",
rootfs.to_str().unwrap(),
"/bin/sh",
"-c",
"sleep 60",
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("remora run");
assert!(run_status.success(), "remora run -d failed");
let state_path = format!("/run/remora/containers/{}/state.json", name);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while std::time::Instant::now() < deadline {
if std::path::Path::new(&state_path).exists() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
assert!(
std::path::Path::new(&state_path).exists(),
"state.json not created within 10s"
);
let state_data = std::fs::read_to_string(&state_path).expect("read state.json");
let mut state: serde_json::Value = serde_json::from_str(&state_data).unwrap();
state["health_config"] = serde_json::json!({
"cmd": ["/bin/true"],
"interval_secs": 1,
"timeout_secs": 2,
"start_period_secs": 0,
"retries": 1
});
state["health"] = serde_json::json!("starting");
std::fs::write(&state_path, serde_json::to_string_pretty(&state).unwrap()).unwrap();
let patched_data = std::fs::read_to_string(&state_path).expect("read patched state.json");
let patched: serde_json::Value = serde_json::from_str(&patched_data).unwrap();
assert_eq!(
patched["health"].as_str(),
Some("starting"),
"health field should be 'starting' after patch"
);
assert!(
patched["health_config"]["cmd"].is_array(),
"health_config.cmd should be an array"
);
let mut final_state: serde_json::Value = serde_json::from_str(&patched_data).unwrap();
final_state["health"] = serde_json::json!("healthy");
std::fs::write(
&state_path,
serde_json::to_string_pretty(&final_state).unwrap(),
)
.unwrap();
let final_data = std::fs::read_to_string(&state_path).expect("read final state.json");
let final_val: serde_json::Value = serde_json::from_str(&final_data).unwrap();
assert_eq!(
final_val["health"].as_str(),
Some("healthy"),
"health field should be 'healthy'"
);
let _ = std::process::Command::new(bin)
.args(["stop", name])
.output();
let _ = std::process::Command::new(bin).args(["rm", name]).output();
}
#[test]
#[ignore]
fn test_healthcheck_unhealthy() {
if !is_root() {
return;
}
let rootfs = match super::get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping: alpine-rootfs not found");
return;
}
};
let bin = env!("CARGO_BIN_EXE_remora");
let name = "remora-healthcheck-unhealthy-test";
let _ = std::process::Command::new(bin)
.args(["rm", "-f", name])
.output();
let run_status = std::process::Command::new(bin)
.args([
"run",
"-d",
"--name",
name,
"--rootfs",
rootfs.to_str().unwrap(),
"/bin/sh",
"-c",
"sleep 60",
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("remora run");
assert!(run_status.success(), "remora run -d failed");
let state_path = format!("/run/remora/containers/{}/state.json", name);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while std::time::Instant::now() < deadline {
if std::path::Path::new(&state_path).exists() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
assert!(
std::path::Path::new(&state_path).exists(),
"state.json not created within 10s"
);
let state_data = std::fs::read_to_string(&state_path).expect("read state.json");
let mut state: serde_json::Value = serde_json::from_str(&state_data).unwrap();
state["health"] = serde_json::json!("unhealthy");
std::fs::write(&state_path, serde_json::to_string_pretty(&state).unwrap()).unwrap();
let final_data = std::fs::read_to_string(&state_path).expect("read final state.json");
let final_val: serde_json::Value = serde_json::from_str(&final_data).unwrap();
assert_eq!(
final_val["health"].as_str(),
Some("unhealthy"),
"health should be 'unhealthy'"
);
let _ = std::process::Command::new(bin)
.args(["stop", name])
.output();
let _ = std::process::Command::new(bin).args(["rm", name]).output();
}
#[test]
fn test_parse_healthcheck_instruction_roundtrip() {
let content = "FROM alpine\nHEALTHCHECK --interval=5s --retries=2 CMD /bin/check.sh";
let instrs = parse_remfile(content).unwrap();
match &instrs[1] {
pelagos::build::Instruction::Healthcheck {
cmd,
interval_secs,
retries,
..
} => {
assert_eq!(cmd, &["/bin/sh", "-c", "/bin/check.sh"]);
assert_eq!(*interval_secs, 5);
assert_eq!(*retries, 2);
}
other => panic!("expected Healthcheck, got {:?}", other),
}
let content2 =
r#"FROM alpine\nHEALTHCHECK CMD ["pg_isready", "-U", "postgres"]"#.replace("\\n", "\n");
let instrs2 = parse_remfile(&content2).unwrap();
match &instrs2[1] {
pelagos::build::Instruction::Healthcheck { cmd, .. } => {
assert_eq!(cmd, &["pg_isready", "-U", "postgres"]);
}
other => panic!("expected Healthcheck, got {:?}", other),
}
let content3 = "FROM alpine\nHEALTHCHECK NONE";
let instrs3 = parse_remfile(content3).unwrap();
match &instrs3[1] {
pelagos::build::Instruction::Healthcheck { cmd, .. } => {
assert!(cmd.is_empty(), "NONE should produce empty cmd");
}
other => panic!("expected Healthcheck, got {:?}", other),
}
}
#[test]
fn test_health_config_oci_json_roundtrip() {
let hc = HealthConfig {
cmd: vec!["pg_isready".into(), "-U".into(), "postgres".into()],
interval_secs: 20,
timeout_secs: 8,
start_period_secs: 5,
retries: 4,
};
let json = serde_json::to_string(&hc).unwrap();
let loaded: HealthConfig = serde_json::from_str(&json).unwrap();
assert_eq!(loaded.cmd, hc.cmd);
assert_eq!(loaded.interval_secs, 20);
assert_eq!(loaded.timeout_secs, 8);
assert_eq!(loaded.start_period_secs, 5);
assert_eq!(loaded.retries, 4);
}
#[test]
#[serial]
fn test_probe_child_pid_is_killable() {
if !is_root() {
eprintln!("Skipping test_probe_child_pid_is_killable (requires root)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping test_probe_child_pid_is_killable (no rootfs)");
return;
}
};
let mut container = Command::new("/bin/sleep")
.args(["60"])
.with_chroot(&rootfs)
.with_namespaces(Namespace::UTS | Namespace::MOUNT | Namespace::PID)
.with_proc_mount()
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("spawn container");
let container_pid = container.pid();
let mut probe = Command::new("sleep")
.args(["300"])
.with_chroot(format!("/proc/{}/root", container_pid))
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("spawn probe");
let probe_pid = probe.pid();
assert!(probe_pid > 0, "probe PID should be positive");
let alive = unsafe { libc::kill(probe_pid, 0) == 0 };
assert!(
alive,
"probe child (pid={}) should be alive before kill",
probe_pid
);
unsafe { libc::kill(probe_pid, libc::SIGKILL) };
let probe_status = probe.wait().expect("wait on probe child");
unsafe { libc::kill(container_pid, libc::SIGKILL) };
let _ = container.wait();
let still_alive = unsafe { libc::kill(probe_pid, 0) == 0 };
assert!(
!still_alive,
"probe child (pid={}) still visible after wait(); \
timed-out probe children would linger indefinitely",
probe_pid
);
let _ = probe_status; }
}
mod console_socket_tests {
use super::*;
use std::os::unix::io::AsRawFd;
use std::os::unix::net::UnixListener;
fn run_remora(args: &[&str]) -> (String, String, bool) {
if args.first() == Some(&"create") {
let tmp = tempfile::NamedTempFile::new().expect("tempfile for stderr");
let stderr_file = tmp.reopen().expect("reopen stderr tempfile");
let status = std::process::Command::new(env!("CARGO_BIN_EXE_remora"))
.args(args)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::from(stderr_file))
.status()
.expect("failed to run remora create");
let stderr = std::fs::read_to_string(tmp.path()).unwrap_or_default();
return (String::new(), stderr, status.success());
}
let output = std::process::Command::new(env!("CARGO_BIN_EXE_remora"))
.args(args)
.output()
.expect("failed to run remora binary");
(
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
output.status.success(),
)
}
fn recv_fd(sock_fd: i32) -> i32 {
let cmsg_space =
unsafe { libc::CMSG_SPACE(std::mem::size_of::<i32>() as libc::c_uint) as usize };
let mut cmsg_buf = vec![0u8; cmsg_space];
let mut iov_buf = [0u8; 1];
let mut iov = libc::iovec {
iov_base: iov_buf.as_mut_ptr() as *mut libc::c_void,
iov_len: 1,
};
let mut msg: libc::msghdr = unsafe { std::mem::zeroed() };
msg.msg_iov = &mut iov;
msg.msg_iovlen = 1;
msg.msg_control = cmsg_buf.as_mut_ptr() as *mut libc::c_void;
msg.msg_controllen = cmsg_space as _;
let ret = unsafe { libc::recvmsg(sock_fd, &mut msg, 0) };
if ret < 0 {
return -1;
}
let cmsg = unsafe { libc::CMSG_FIRSTHDR(&msg) };
if cmsg.is_null() {
return -1;
}
unsafe {
if (*cmsg).cmsg_level != libc::SOL_SOCKET || (*cmsg).cmsg_type != libc::SCM_RIGHTS {
return -1;
}
let data = libc::CMSG_DATA(cmsg) as *const i32;
*data
}
}
#[test]
fn test_oci_console_socket() {
if !is_root() {
eprintln!("Skipping test_oci_console_socket: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_console_socket: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
let rootfs_link = bundle_dir.path().join("rootfs");
std::os::unix::fs::symlink(&rootfs, &rootfs_link).unwrap();
let config = r#"{
"ociVersion": "1.0.2",
"root": {"path": "rootfs"},
"process": {
"terminal": true,
"args": ["/bin/sleep", "5"],
"cwd": "/",
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]
},
"linux": {
"namespaces": [
{"type": "mount"},
{"type": "uts"},
{"type": "pid"}
]
}
}"#;
std::fs::write(bundle_dir.path().join("config.json"), config).unwrap();
let socket_path = bundle_dir.path().join("console.sock");
let listener = UnixListener::bind(&socket_path).expect("bind console socket");
let id = format!("test-oci-console-{}", std::process::id());
let bundle_str = bundle_dir.path().to_str().unwrap().to_owned();
let socket_str = socket_path.to_str().unwrap().to_owned();
let id_clone = id.clone();
let create_thread = std::thread::spawn(move || {
run_remora(&[
"create",
"--bundle",
&bundle_str,
"--console-socket",
&socket_str,
&id_clone,
])
});
listener.set_nonblocking(false).unwrap();
let listener_fd = listener.as_raw_fd();
let mut poll_fd = libc::pollfd {
fd: listener_fd,
events: libc::POLLIN,
revents: 0,
};
let ready = unsafe { libc::poll(&mut poll_fd, 1, 5000) };
assert!(ready > 0, "console socket: no connection within 5s");
let (conn, _) = listener.accept().expect("accept console socket connection");
let received_fd = recv_fd(conn.as_raw_fd());
drop(conn);
let (_, stderr, ok) = create_thread.join().unwrap();
assert!(ok, "remora create failed: {}", stderr);
assert!(
received_fd >= 0,
"did not receive a valid fd via SCM_RIGHTS"
);
let is_tty = unsafe { libc::isatty(received_fd) };
assert_eq!(is_tty, 1, "received fd (={}) is not a TTY", received_fd);
unsafe { libc::close(received_fd) };
run_remora(&["kill", &id, "SIGKILL"]);
std::thread::sleep(std::time::Duration::from_millis(300));
run_remora(&["delete", &id]);
}
}