use std::{
io::Write,
path::{Path, PathBuf},
};
use crate::{Error, Result};
pub const DEFAULT_SYSFS_ROOT: &str = "/sys";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum TriggerAction {
#[default]
Change,
Add,
Remove,
Bind,
Unbind,
Move,
Online,
Offline,
}
impl TriggerAction {
pub fn as_str(self) -> &'static str {
match self {
Self::Add => "add",
Self::Remove => "remove",
Self::Change => "change",
Self::Move => "move",
Self::Online => "online",
Self::Offline => "offline",
Self::Bind => "bind",
Self::Unbind => "unbind",
}
}
}
impl std::fmt::Display for TriggerAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Default)]
pub struct TriggerSummary {
pub triggered: usize,
pub failures: Vec<(PathBuf, std::io::Error)>,
}
impl TriggerSummary {
pub fn attempted(&self) -> usize {
self.triggered + self.failures.len()
}
pub fn is_complete(&self) -> bool {
self.failures.is_empty()
}
}
#[derive(Debug, Clone)]
pub struct UeventTrigger {
sysfs_root: PathBuf,
action: TriggerAction,
}
impl Default for UeventTrigger {
fn default() -> Self {
Self::new()
}
}
impl UeventTrigger {
pub fn new() -> Self {
Self {
sysfs_root: PathBuf::from(DEFAULT_SYSFS_ROOT),
action: TriggerAction::default(),
}
}
pub fn sysfs_root(mut self, root: impl Into<PathBuf>) -> Self {
self.sysfs_root = root.into();
self
}
pub fn action(mut self, action: TriggerAction) -> Self {
self.action = action;
self
}
pub fn build(self) -> Self {
self
}
pub fn configured_action(&self) -> TriggerAction {
self.action
}
pub fn uevent_path(&self, devpath: &str) -> PathBuf {
self.sysfs_root
.join(devpath.trim_start_matches('/'))
.join("uevent")
}
pub fn can_trigger(&self) -> bool {
let probe = self.sysfs_root.join("devices");
match std::fs::metadata(&probe) {
Ok(_) => access_writable(&probe),
Err(_) => false,
}
}
pub fn device(&self, devpath: &str) -> Result<()> {
let path = self.uevent_path(devpath);
write_uevent(&path, self.action).map_err(|e| {
tracing::debug!(path = %path.display(), action = %self.action, error = %e,
"uevent trigger failed");
Error::Io(e)
})
}
#[tracing::instrument(level = "info", skip(self), fields(action = %self.action))]
pub fn subtree(&self, relative: &str) -> Result<TriggerSummary> {
let root = self.sysfs_root.join(relative.trim_start_matches('/'));
std::fs::read_dir(&root).map_err(Error::Io)?;
let mut summary = TriggerSummary::default();
let mut stack = vec![root];
while let Some(dir) = stack.pop() {
let uevent = dir.join("uevent");
if uevent.is_file() {
match write_uevent(&uevent, self.action) {
Ok(()) => summary.triggered += 1,
Err(e) => summary.failures.push((uevent, e)),
}
}
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_dir() {
stack.push(entry.path());
}
}
}
tracing::info!(
triggered = summary.triggered,
failed = summary.failures.len(),
"uevent subtree trigger complete"
);
Ok(summary)
}
}
pub fn resync_factory<T: Send + 'static>(
trigger: UeventTrigger,
relative: impl Into<String>,
) -> impl FnMut() -> std::pin::Pin<Box<dyn Future<Output = Result<Vec<T>>> + Send>> + Unpin {
let relative = relative.into();
move || {
let trigger = trigger.clone();
let relative = relative.clone();
Box::pin(async move {
let summary =
tokio::task::spawn_blocking(move || trigger.subtree(&relative)).await
.map_err(|e| {
Error::Io(std::io::Error::other(format!("trigger task panicked: {e}")))
})??;
tracing::info!(
triggered = summary.triggered,
failed = summary.failures.len(),
"uevent resync requested; re-announcements will arrive as live events"
);
Ok(Vec::new())
})
}
}
fn write_uevent(path: &Path, action: TriggerAction) -> std::io::Result<()> {
let mut file = std::fs::OpenOptions::new().write(true).open(path)?;
file.write_all(action.as_str().as_bytes())
}
fn access_writable(path: &Path) -> bool {
use std::os::unix::ffi::OsStrExt;
let Ok(c_path) = std::ffi::CString::new(path.as_os_str().as_bytes()) else {
return false;
};
unsafe { libc::access(c_path.as_ptr(), libc::W_OK) == 0 }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn action_tokens_match_the_kernel_vocabulary() {
assert_eq!(TriggerAction::Add.as_str(), "add");
assert_eq!(TriggerAction::Remove.as_str(), "remove");
assert_eq!(TriggerAction::Change.as_str(), "change");
assert_eq!(TriggerAction::Move.as_str(), "move");
assert_eq!(TriggerAction::Online.as_str(), "online");
assert_eq!(TriggerAction::Offline.as_str(), "offline");
assert_eq!(TriggerAction::Bind.as_str(), "bind");
assert_eq!(TriggerAction::Unbind.as_str(), "unbind");
}
#[test]
fn default_action_is_change() {
assert_eq!(TriggerAction::default(), TriggerAction::Change);
assert_eq!(
UeventTrigger::new().configured_action(),
TriggerAction::Change
);
}
#[test]
fn devpath_is_joined_relative_to_the_sysfs_root() {
let trigger = UeventTrigger::new();
assert_eq!(
trigger.uevent_path("/devices/virtual/net/veth0"),
PathBuf::from("/sys/devices/virtual/net/veth0/uevent")
);
assert_eq!(
trigger.uevent_path("devices/virtual/net/veth0"),
PathBuf::from("/sys/devices/virtual/net/veth0/uevent")
);
}
#[test]
fn sysfs_root_is_honoured() {
let trigger = UeventTrigger::new().sysfs_root("/mnt/sys").build();
assert_eq!(
trigger.uevent_path("/devices/x"),
PathBuf::from("/mnt/sys/devices/x/uevent")
);
}
#[test]
fn summary_arithmetic() {
let mut s = TriggerSummary {
triggered: 3,
failures: Vec::new(),
};
assert!(s.is_complete());
assert_eq!(s.attempted(), 3);
s.failures.push((
PathBuf::from("/sys/devices/x/uevent"),
std::io::Error::from(std::io::ErrorKind::PermissionDenied),
));
assert!(!s.is_complete());
assert_eq!(s.attempted(), 4);
}
#[test]
fn missing_subtree_root_is_an_error_not_an_empty_summary() {
let trigger = UeventTrigger::new()
.sysfs_root("/nonexistent-sysfs-for-tests")
.build();
let err = trigger.subtree("devices").unwrap_err();
assert!(err.is_not_found(), "{err}");
}
#[test]
fn cannot_trigger_when_the_sysfs_root_is_not_there() {
let trigger = UeventTrigger::new()
.sysfs_root("/nonexistent-sysfs-for-tests")
.build();
assert!(!trigger.can_trigger());
}
fn fake_sysfs(tag: &str) -> PathBuf {
let base = std::env::temp_dir().join(format!(
"nlink-uevent-trigger-{tag}-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&base);
base
}
#[test]
fn device_writes_the_action_to_the_devpath() {
let base = fake_sysfs("device");
let dev = base.join("devices/virtual/net/veth0");
std::fs::create_dir_all(&dev).unwrap();
std::fs::write(dev.join("uevent"), b"").unwrap();
let other = base.join("devices/virtual/net/veth1");
std::fs::create_dir_all(&other).unwrap();
std::fs::write(other.join("uevent"), b"").unwrap();
let trigger = UeventTrigger::new()
.sysfs_root(&base)
.action(TriggerAction::Add)
.build();
trigger.device("/devices/virtual/net/veth0").unwrap();
assert_eq!(
std::fs::read_to_string(dev.join("uevent")).unwrap(),
"add"
);
assert_eq!(std::fs::read_to_string(other.join("uevent")).unwrap(), "");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn device_reports_a_missing_devpath() {
let base = fake_sysfs("missing");
std::fs::create_dir_all(base.join("devices")).unwrap();
let trigger = UeventTrigger::new().sysfs_root(&base).build();
let err = trigger.device("/devices/nope").unwrap_err();
assert!(err.is_not_found(), "{err}");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
#[cfg(unix)]
fn subtree_does_not_follow_symlinks() {
let base = fake_sysfs("symlink");
let dev = base.join("devices/virtual/net/veth0");
std::fs::create_dir_all(&dev).unwrap();
std::fs::write(dev.join("uevent"), b"").unwrap();
let class = base.join("class/net");
std::fs::create_dir_all(&class).unwrap();
std::os::unix::fs::symlink(&dev, class.join("veth0")).unwrap();
let trigger = UeventTrigger::new().sysfs_root(&base).build();
let summary = trigger.subtree("class").unwrap();
assert_eq!(
summary.attempted(),
0,
"the walk followed a symlink out of class/"
);
let _ = std::fs::remove_dir_all(&base);
}
#[tokio::test]
async fn resync_factory_triggers_and_returns_no_dump_items() {
let base = fake_sysfs("resync");
let dev = base.join("devices/virtual/net/veth0");
std::fs::create_dir_all(&dev).unwrap();
std::fs::write(dev.join("uevent"), b"").unwrap();
let trigger = UeventTrigger::new().sysfs_root(&base).build();
let mut factory = resync_factory::<u32>(trigger, "devices");
let items = factory().await.unwrap();
assert!(items.is_empty(), "the factory must not fabricate a dump");
assert_eq!(
std::fs::read_to_string(dev.join("uevent")).unwrap(),
"change"
);
std::fs::write(dev.join("uevent"), b"").unwrap();
assert!(factory().await.unwrap().is_empty());
assert_eq!(
std::fs::read_to_string(dev.join("uevent")).unwrap(),
"change"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn action_display_matches_the_written_token() {
for action in [
TriggerAction::Add,
TriggerAction::Change,
TriggerAction::Bind,
TriggerAction::Offline,
] {
assert_eq!(action.to_string(), action.as_str());
}
}
#[test]
fn subtree_walks_directories_and_collects_failures() {
let base = std::env::temp_dir().join(format!("nlink-uevent-trigger-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let devices = base.join("devices");
let child = devices.join("virtual").join("net").join("veth0");
std::fs::create_dir_all(&child).unwrap();
std::fs::write(child.join("uevent"), b"").unwrap();
std::fs::write(devices.join("uevent"), b"").unwrap();
std::fs::create_dir_all(devices.join("virtual").join("block")).unwrap();
let trigger = UeventTrigger::new().sysfs_root(&base).build();
let summary = trigger.subtree("devices").unwrap();
assert_eq!(summary.attempted(), 2);
assert_eq!(summary.triggered, 2);
assert!(summary.is_complete());
assert_eq!(
std::fs::read_to_string(child.join("uevent")).unwrap(),
"change"
);
let readonly = devices.join("uevent");
let mut perms = std::fs::metadata(&readonly).unwrap().permissions();
#[allow(clippy::permissions_set_readonly_false)]
perms.set_readonly(true);
std::fs::set_permissions(&readonly, perms).unwrap();
let summary = trigger.subtree("devices").unwrap();
assert_eq!(summary.attempted(), 2);
assert!(summary.triggered >= 1);
assert_eq!(summary.triggered + summary.failures.len(), 2);
let _ = std::fs::remove_dir_all(&base);
}
}