use std::{
future::Future,
process::{Child, Command, Output},
sync::atomic::{AtomicU32, Ordering},
};
mod bridge;
mod veth;
pub use bridge::LabBridge;
pub use veth::LabVeth;
use tracing::warn;
use crate::{
Result, Route,
netlink::{AsyncProtocolInit, Connection, ProtocolState, namespace},
};
static NAMESPACE_COUNTER: AtomicU32 = AtomicU32::new(0);
fn unique_ns_name(prefix: &str) -> String {
let id = NAMESPACE_COUNTER.fetch_add(1, Ordering::SeqCst);
let pid = std::process::id();
format!("nlink-lab-{prefix}-{pid}-{id}")
}
pub struct LabNamespace {
name: String,
}
impl LabNamespace {
pub fn new(prefix: &str) -> Result<Self> {
let name = unique_ns_name(prefix);
namespace::create(&name)?;
Ok(Self { name })
}
pub fn named(name: &str) -> Result<Self> {
namespace::create(name)?;
Ok(Self {
name: name.to_string(),
})
}
pub fn name(&self) -> &str {
&self.name
}
pub fn connection(&self) -> Result<Connection<Route>> {
namespace::connection_for(&self.name)
}
pub fn connection_for<P>(&self) -> Result<Connection<P>>
where
P: ProtocolState
+ Default
+ crate::netlink::construction::SyncConstructible,
{
namespace::connection_for(&self.name)
}
pub async fn connection_for_async<P>(&self) -> Result<Connection<P>>
where
P: AsyncProtocolInit + crate::netlink::construction::AsyncConstructible,
{
namespace::connection_for_async(&self.name).await
}
pub fn spawn(&self, cmd: Command) -> Result<Child> {
namespace::spawn(&self.name, cmd)
}
pub fn spawn_output(&self, cmd: Command) -> Result<Output> {
namespace::spawn_output(&self.name, cmd)
}
pub fn exec(&self, cmd: &str, args: &[&str]) -> Result<String> {
let mut command = Command::new(cmd);
command.args(args);
let output = namespace::spawn_output(&self.name, command)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(crate::Error::InvalidMessage(format!(
"command failed: {cmd} {args:?}: {stderr}"
)));
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
pub fn exec_ignore(&self, cmd: &str, args: &[&str]) {
let mut command = Command::new(cmd);
command.args(args);
let _ = namespace::spawn_output(&self.name, command);
}
pub fn connect_to(
&self,
peer_ns: &LabNamespace,
local_name: &str,
remote_name: &str,
) -> Result<()> {
let mut cmd = Command::new("ip");
cmd.args([
"link",
"add",
local_name,
"type",
"veth",
"peer",
"name",
remote_name,
]);
let output = namespace::spawn_output(&self.name, cmd)?;
if !output.status.success() {
return Err(crate::Error::InvalidMessage(
"failed to create veth pair".into(),
));
}
let mut cmd = Command::new("ip");
cmd.args(["link", "set", remote_name, "netns", &peer_ns.name]);
let output = namespace::spawn_output(&self.name, cmd)?;
if !output.status.success() {
return Err(crate::Error::InvalidMessage(
"failed to move veth peer".into(),
));
}
Ok(())
}
pub fn add_dummy(&self, name: &str) -> Result<()> {
self.exec("ip", &["link", "add", name, "type", "dummy"])?;
Ok(())
}
pub fn link_up(&self, name: &str) -> Result<()> {
self.exec("ip", &["link", "set", name, "up"])?;
Ok(())
}
pub fn add_addr(&self, dev: &str, addr: &str) -> Result<()> {
self.exec("ip", &["addr", "add", addr, "dev", dev])?;
Ok(())
}
}
impl Drop for LabNamespace {
fn drop(&mut self) {
if let Err(e) = namespace::delete(&self.name) {
warn!(
namespace = %self.name,
error = %e,
"LabNamespace::drop failed to delete namespace — may need manual cleanup via `ip netns del {}`",
self.name,
);
}
}
}
pub async fn with_namespace<F, Fut, T>(prefix: &str, f: F) -> Result<T>
where
F: FnOnce(LabNamespace) -> Fut,
Fut: Future<Output = Result<T>>,
{
let ns = LabNamespace::new(prefix)?;
f(ns).await
}
pub fn is_root() -> bool {
unsafe { libc::geteuid() == 0 }
}
pub fn is_host_root() -> bool {
if !is_root() {
return false;
}
std::fs::read_to_string("/proc/self/uid_map").is_ok_and(|map| uid_map_is_identity(&map))
}
fn uid_map_is_identity(map: &str) -> bool {
map.lines().any(|line| {
let mut f = line.split_whitespace();
let (Some(inside), Some(outside), Some(count)) = (f.next(), f.next(), f.next()) else {
return false;
};
inside == "0" && outside == "0" && count.parse::<u64>() == Ok(u64::from(u32::MAX))
})
}
pub fn strict_host_root() -> bool {
matches!(
std::env::var("NLINK_TEST_STRICT_HOST_ROOT").as_deref(),
Ok("1") | Ok("true") | Ok("yes")
)
}
pub fn has_module(name: &str) -> bool {
if name.is_empty() || name.contains('/') || name.contains('\0') {
return false;
}
if std::path::Path::new("/sys/module").join(name).exists() {
return true;
}
module_index().contains(name)
}
fn module_index() -> &'static std::collections::HashSet<String> {
use std::sync::OnceLock;
static INDEX: OnceLock<std::collections::HashSet<String>> = OnceLock::new();
INDEX.get_or_init(|| {
let mut out = std::collections::HashSet::new();
let Ok(release) = std::fs::read_to_string("/proc/sys/kernel/osrelease") else {
return out;
};
let dir = format!("/lib/modules/{}", release.trim());
for file in ["modules.builtin", "modules.dep"] {
let Ok(text) = std::fs::read_to_string(format!("{dir}/{file}")) else {
continue;
};
for line in text.lines() {
let path = line.split(':').next().unwrap_or(line).trim();
let Some(base) = path.rsplit('/').next() else {
continue;
};
let name = base.split(".ko").next().unwrap_or(base);
if !name.is_empty() {
out.insert(name.to_string());
}
}
}
out
})
}
pub fn strict_modules(name: &str) -> bool {
let Ok(list) = std::env::var("NLINK_TEST_STRICT_MODULES") else {
return false;
};
list.split(',').map(str::trim).any(|m| m == name)
}
pub fn init_test_tracing() {
use std::sync::Once;
static ONCE: Once = Once::new();
ONCE.call_once(|| {
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
let _ = tracing_subscriber::fmt()
.with_env_filter(filter)
.with_test_writer()
.try_init();
});
}
#[macro_export]
macro_rules! require_root {
() => {
$crate::lab::init_test_tracing();
if !$crate::lab::is_root() {
eprintln!("Skipping test: requires root");
return Ok(());
}
};
}
#[macro_export]
macro_rules! require_host_root {
() => {
if !$crate::lab::is_host_root() {
let msg = "requires root in the initial user namespace; \
a rootless container's root cannot mount over /etc, \
remount /sys, install a TC police rate table, or \
create a WireGuard device (#357)";
assert!(!$crate::lab::strict_host_root(), "{msg}");
eprintln!("Skipping test: {msg}");
return Ok(());
}
};
}
#[macro_export]
macro_rules! require_host_root_void {
() => {
if !$crate::lab::is_host_root() {
let msg = "requires root in the initial user namespace; \
a rootless container's root cannot mount over /etc, \
remount /sys, install a TC police rate table, or \
create a WireGuard device (#357)";
assert!(!$crate::lab::strict_host_root(), "{msg}");
eprintln!("Skipping test: {msg}");
return;
}
};
}
#[macro_export]
macro_rules! require_root_void {
() => {
$crate::lab::init_test_tracing();
if !$crate::lab::is_root() {
eprintln!("Skipping test: requires root");
return;
}
};
}
#[macro_export]
macro_rules! require_module {
($name:expr) => {
if !$crate::lab::has_module($name) {
let msg = format!(
"kernel module '{}' is not loaded, not built in, and not \
available to load",
$name
);
assert!(!$crate::lab::strict_modules($name), "{msg}");
eprintln!("Skipping test: {msg}");
return Ok(());
}
};
}
#[macro_export]
macro_rules! require_module_void {
($name:expr) => {
if !$crate::lab::has_module($name) {
let msg = format!(
"kernel module '{}' is not loaded, not built in, and not \
available to load",
$name
);
assert!(!$crate::lab::strict_modules($name), "{msg}");
eprintln!("Skipping test: {msg}");
return;
}
};
}
#[macro_export]
macro_rules! require_modules {
($($name:expr),+ $(,)?) => {
$( $crate::require_module!($name); )+
};
}
#[macro_export]
macro_rules! require_writable_sysctl {
($path:expr) => {
match std::fs::OpenOptions::new().write(true).open($path) {
Ok(_) => {}
Err(e) => {
eprintln!(
"Skipping test: sysctl '{}' not writable ({}); \
/proc/sys may be read-only in this environment",
$path, e
);
return Ok(());
}
}
};
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_unique_ns_name() {
let n1 = unique_ns_name("probe");
let n2 = unique_ns_name("probe");
assert_ne!(n1, n2);
assert!(n1.starts_with("nlink-lab-probe-"));
}
#[test]
fn has_module_returns_false_for_unknown_name() {
assert!(!has_module("nlink_definitely_not_a_real_module_xyzzy"));
}
#[test]
fn has_module_rejects_path_traversal() {
assert!(!has_module(""));
assert!(!has_module("/etc/passwd"));
assert!(!has_module("../../etc/passwd"));
assert!(!has_module("nf_conntrack/foo"));
assert!(!has_module("nf\0conntrack"));
}
#[test]
fn has_module_rejects_names_that_could_escape_sys_module() {
assert!(!has_module(""));
assert!(!has_module("../../etc"));
assert!(!has_module("/etc/passwd"));
assert!(!has_module("foo\0bar"));
}
#[test]
fn has_module_sees_more_than_sys_module() {
let index = module_index();
if index.is_empty() {
return;
}
let mut checked = 0;
let mut invisible_to_sysfs = 0;
for name in index.iter().take(200) {
assert!(has_module(name), "index knows {name} but has_module says no");
if !std::path::Path::new("/sys/module").join(name).exists() {
invisible_to_sysfs += 1;
}
checked += 1;
}
assert!(checked > 0);
eprintln!("{invisible_to_sysfs} of {checked} indexed modules have no /sys/module entry");
}
#[test]
fn has_module_says_no_to_something_that_does_not_exist() {
assert!(!has_module("nlink_definitely_not_a_module_xyzzy"));
}
#[test]
fn only_the_initial_namespaces_uid_map_is_the_identity_mapping() {
assert!(uid_map_is_identity(" 0 0 4294967295\n"));
assert!(!uid_map_is_identity(
" 0 1000 1\n 1 100000 65536\n"
));
assert!(!uid_map_is_identity(" 0 0 1\n"));
assert!(!uid_map_is_identity(""));
assert!(!uid_map_is_identity("0 0"));
assert!(!uid_map_is_identity("0 0 not-a-number"));
}
}