use std::{
borrow::Cow,
fs,
io::{self, BufRead as _, BufReader, Write as _},
marker::PhantomData,
os::fd::{AsFd, BorrowedFd},
path::{Path, PathBuf},
process,
sync::atomic::{AtomicU64, Ordering},
};
use libc::if_nametoindex;
use crate::{netlink_set_link_up, sys::NetlinkError};
const CGROUP_PROCS: &str = "cgroup.procs";
const PROC_MOUNTS: &str = "/proc/self/mounts";
#[derive(Debug, thiserror::Error)]
pub enum AyaTestError {
#[error("failed to {op}: {path}: {source}")]
Io {
op: &'static str,
path: PathBuf,
#[source]
source: io::Error,
},
#[error("mount entry from {PROC_MOUNTS} has no {field} field: {mount_entry}")]
MissingMountEntryField {
field: &'static str,
mount_entry: String,
},
#[error("could not find a cgroup2 mount entry in {PROC_MOUNTS}")]
Cgroup2NotFound,
#[error(
"syscall {syscall} failed{}: {source}",
if let Some(path) = &.path {
format!(": {}", path.display())
} else {
String::new()
}
)]
Syscall {
syscall: &'static str,
path: Option<PathBuf>,
#[source]
source: nix::errno::Errno,
},
#[error("errors: {0:?}")]
Multi(Vec<Self>),
#[error(transparent)]
Netlink(#[from] NetlinkError),
}
pub type AyaTestResult<T> = Result<T, AyaTestError>;
pub struct ChildCgroup<'a> {
parent: &'a Cgroup<'a>,
path: Cow<'a, Path>,
}
pub enum Cgroup<'a> {
Root(PathBuf),
Child(ChildCgroup<'a>),
}
impl Cgroup<'static> {
pub fn root() -> AyaTestResult<Self> {
const CGROUP2: &str = "cgroup2";
{
let mounts = fs::File::open(PROC_MOUNTS).map_err(|source| AyaTestError::Io {
op: "open",
path: PathBuf::from(PROC_MOUNTS),
source,
})?;
for mount_entry in BufReader::new(mounts).lines() {
let mount_entry = mount_entry.map_err(|source| AyaTestError::Io {
op: "retrieve a line",
path: PathBuf::from(PROC_MOUNTS),
source,
})?;
let mut parts = mount_entry.split_whitespace();
let device = parts
.next()
.ok_or_else(|| AyaTestError::MissingMountEntryField {
field: "device",
mount_entry: mount_entry.clone(),
})?;
let mountpoint =
parts
.next()
.ok_or_else(|| AyaTestError::MissingMountEntryField {
field: "mountpoint",
mount_entry: mount_entry.clone(),
})?;
let fstype = parts
.next()
.ok_or_else(|| AyaTestError::MissingMountEntryField {
field: "fstype",
mount_entry: mount_entry.clone(),
})?;
if device == CGROUP2 && fstype == CGROUP2 {
return Ok(Self::Root(PathBuf::from(mountpoint)));
}
}
}
Err(AyaTestError::Cgroup2NotFound)
}
}
impl<'a> Cgroup<'a> {
fn path(&self) -> &Path {
match self {
Self::Root(path) => path,
Self::Child(ChildCgroup { parent: _, path }) => path,
}
}
pub fn create_child(&'a self, name: &str) -> AyaTestResult<ChildCgroup<'a>> {
let path = self.path().join(name);
fs::create_dir(&path).map_err(|source| AyaTestError::Io {
op: "create directory",
path: path.clone(),
source,
})?;
Ok(ChildCgroup {
parent: self,
path: path.into(),
})
}
pub fn write_pid(&self, pid: u32) -> AyaTestResult<()> {
let cgroup_procs = self.path().join(CGROUP_PROCS);
fs::write(&cgroup_procs, format!("{pid}\n")).map_err(move |source| AyaTestError::Io {
op: "write",
path: cgroup_procs,
source,
})
}
}
impl<'a> ChildCgroup<'a> {
pub fn fd(&self) -> AyaTestResult<fs::File> {
let Self { parent: _, path } = self;
fs::OpenOptions::new()
.read(true)
.open(path.as_ref())
.map_err(|source| AyaTestError::Io {
op: "open",
path: path.to_path_buf(),
source,
})
}
pub const fn into_cgroup(self) -> Cgroup<'a> {
Cgroup::Child(self)
}
}
impl Drop for ChildCgroup<'_> {
#[expect(
clippy::print_stderr,
reason = "drop handlers avoid panic-in-panic by logging errors"
)]
#[expect(
clippy::use_debug,
reason = "debug formatting preserves error context in drop"
)]
#[expect(clippy::panic, reason = "drop handlers can't return a result")]
fn drop(&mut self) {
use anyhow::{Context as _, Result};
let Self { parent, path } = self;
match (|| -> Result<()> {
let dst = parent.path().join(CGROUP_PROCS);
let mut dst = fs::OpenOptions::new()
.append(true)
.open(&dst)
.with_context(|| {
format!(
"fs::OpenOptions::new().append(true).open(\"{}\")",
dst.display()
)
})?;
let pids = path.as_ref().join(CGROUP_PROCS);
let pids = fs::read_to_string(&pids)
.with_context(|| format!("fs::read_to_string(\"{}\")", pids.display()))?;
for pid in pids.split_inclusive('\n') {
dst.write_all(pid.as_bytes())
.with_context(|| format!("dst.write_all(\"{pid}\")"))?;
}
fs::remove_dir(&path)
.with_context(|| format!("fs::remove_dir(\"{}\")", path.display()))?;
Ok(())
})() {
Ok(()) => (),
Err(err) => {
if std::thread::panicking() {
eprintln!("{err:?}");
} else {
panic!("{err:?}");
}
}
}
}
}
pub struct NetNsGuard {
name: String,
old_ns: fs::File,
new_ns: fs::File,
_not_send: PhantomData<*mut ()>,
}
impl NetNsGuard {
const PERSIST_DIR: &str = "/var/run/netns/";
const THREAD_NETNS: &str = "/proc/thread-self/ns/net";
#[expect(
clippy::print_stdout,
reason = "integration tests print namespace transitions for diagnostics"
)]
pub fn new() -> Result<Self, AyaTestError> {
let old_ns = fs::File::open(Self::THREAD_NETNS).map_err(|source| AyaTestError::Io {
op: "open",
path: PathBuf::from(Self::THREAD_NETNS),
source,
})?;
static COUNTER: AtomicU64 = AtomicU64::new(0);
let pid = process::id();
let name = format!("aya-test-{pid}-{}", COUNTER.fetch_add(1, Ordering::Relaxed));
fs::create_dir_all(Self::PERSIST_DIR).map_err(|source| AyaTestError::Io {
op: "create directory",
path: PathBuf::from(Self::PERSIST_DIR),
source,
})?;
let ns_path = Path::new(Self::PERSIST_DIR).join(&name);
let _unused: fs::File = fs::File::create(&ns_path).map_err(|source| AyaTestError::Io {
op: "create file",
path: ns_path.clone(),
source,
})?;
nix::sched::unshare(nix::sched::CloneFlags::CLONE_NEWNET).map_err(|source| {
let mut errs = vec![AyaTestError::Syscall {
syscall: "unshare",
path: None,
source,
}];
fs::remove_file(&ns_path).unwrap_or_else(|source| {
errs.push(AyaTestError::Io {
op: "remove file",
path: ns_path.clone(),
source,
})
});
AyaTestError::Multi(errs)
})?;
let new_ns = fs::File::open(Self::THREAD_NETNS).map_err(|source| {
let mut errs = vec![AyaTestError::Io {
op: "open",
path: PathBuf::from(Self::THREAD_NETNS),
source,
}];
nix::sched::setns(&old_ns, nix::sched::CloneFlags::CLONE_NEWNET).unwrap_or_else(
|source| {
errs.push(AyaTestError::Syscall {
syscall: "setns",
path: Some(PathBuf::from(Self::THREAD_NETNS)),
source,
});
},
);
fs::remove_file(&ns_path).unwrap_or_else(|source| {
errs.push(AyaTestError::Io {
op: "remove file",
path: ns_path.clone(),
source,
})
});
AyaTestError::Multi(errs)
})?;
nix::mount::mount(
Some(Self::THREAD_NETNS),
&ns_path,
Some("none"),
nix::mount::MsFlags::MS_BIND,
None::<&str>,
)
.map_err(|source| {
let mut errs = vec![AyaTestError::Syscall {
syscall: "mount",
path: Some(ns_path.clone()),
source,
}];
nix::sched::setns(&old_ns, nix::sched::CloneFlags::CLONE_NEWNET).unwrap_or_else(
|source| {
errs.push(AyaTestError::Syscall {
syscall: "setns",
path: Some(PathBuf::from(Self::THREAD_NETNS)),
source,
})
},
);
fs::remove_file(&ns_path).unwrap_or_else(|source| {
errs.push(AyaTestError::Io {
op: "remove file",
path: ns_path,
source,
})
});
AyaTestError::Multi(errs)
})?;
println!("entered network namespace {name}");
let ns = Self {
name,
old_ns,
new_ns,
_not_send: PhantomData,
};
let lo = c"lo";
unsafe {
let idx = if_nametoindex(lo.as_ptr());
if idx == 0 {
return Err(AyaTestError::Io {
op: "lookup interface index",
path: PathBuf::from("lo"),
source: io::Error::last_os_error(),
});
}
netlink_set_link_up(idx as i32)?;
}
Ok(ns)
}
}
impl AsFd for NetNsGuard {
fn as_fd(&self) -> BorrowedFd<'_> {
self.new_ns.as_fd()
}
}
impl Drop for NetNsGuard {
#[expect(
clippy::print_stderr,
reason = "drop handlers avoid panic-in-panic by logging errors"
)]
#[expect(
clippy::use_debug,
reason = "debug formatting preserves error context in drop"
)]
#[expect(clippy::panic, reason = "drop handlers can't return a result")]
fn drop(&mut self) {
use anyhow::{Context as _, Result};
let Self {
old_ns,
name,
new_ns: _,
_not_send: _,
} = self;
match (|| -> Result<()> {
nix::sched::setns(old_ns, nix::sched::CloneFlags::CLONE_NEWNET)
.context("nix::sched::setns(_, CLONE_NEWNET)")?;
let ns_path = Path::new(Self::PERSIST_DIR).join(&name);
nix::mount::umount2(&ns_path, nix::mount::MntFlags::MNT_DETACH).with_context(|| {
format!("nix::mount::umount2(\"{}\", MNT_DETACH)", ns_path.display())
})?;
fs::remove_file(&ns_path)
.with_context(|| format!("fs::remove_file(\"{}\")", ns_path.display()))?;
Ok(())
})() {
Ok(()) => (),
Err(err) => {
if std::thread::panicking() {
eprintln!("{err:?}");
} else {
panic!("{err:?}");
}
}
}
}
}
#[doc(hidden)]
#[macro_export]
macro_rules! __aya_kernel_assert {
($cond:expr, $version:expr $(,)?) => {
let current = $crate::util::KernelVersion::current().unwrap();
let required: $crate::util::KernelVersion = $version;
if current >= required {
assert!($cond, "{current} >= {required}");
} else {
assert!(!$cond, "{current} < {required}");
}
};
}
#[doc(hidden)]
#[macro_export]
macro_rules! __aya_kernel_assert_eq {
($left:expr, $right:expr, $version:expr $(,)?) => {
let current = $crate::util::KernelVersion::current().unwrap();
let required: $crate::util::KernelVersion = $version;
if current >= required {
assert_eq!($left, $right, "{current} >= {required}");
} else {
assert_ne!($left, $right, "{current} < {required}");
}
};
}
pub use crate::__aya_kernel_assert as kernel_assert;
pub use crate::__aya_kernel_assert_eq as kernel_assert_eq;