use super::*;
use crate::fid_parser::mask_to_event_types;
use crate::filters::PathOptions;
use crate::monitored::PathEntry;
use crate::utils::{SizeFilter, SizeOp};
use crate::{EventType, FileEvent};
use fanotify_fid::consts::{FAN_CREATE, FAN_DELETE, FAN_EVENT_ON_CHILD, FAN_MODIFY, FAN_ONDIR};
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
#[test]
fn test_mask_to_event_types_single() {
let types = mask_to_event_types(FAN_CREATE);
assert_eq!(types.len(), 1);
assert_eq!(types[0], EventType::Create);
}
#[test]
fn test_mask_to_event_types_multiple() {
let mask = FAN_CREATE | FAN_DELETE | FAN_MODIFY;
let types = mask_to_event_types(mask);
assert_eq!(types.len(), 3);
assert!(types.contains(&EventType::Create));
assert!(types.contains(&EventType::Delete));
assert!(types.contains(&EventType::Modify));
}
#[test]
fn test_mask_to_event_types_none() {
let types = mask_to_event_types(0);
assert!(types.is_empty());
}
#[test]
fn test_mask_to_event_types_all() {
use fanotify_fid::consts::{
FAN_ACCESS, FAN_ATTRIB, FAN_CLOSE_NOWRITE, FAN_CLOSE_WRITE, FAN_DELETE_SELF, FAN_FS_ERROR,
FAN_MOVE_SELF, FAN_MOVED_FROM, FAN_MOVED_TO, FAN_OPEN, FAN_OPEN_EXEC,
};
let mask = FAN_ACCESS
| FAN_MODIFY
| FAN_CLOSE_WRITE
| FAN_CLOSE_NOWRITE
| FAN_OPEN
| FAN_OPEN_EXEC
| FAN_ATTRIB
| FAN_CREATE
| FAN_DELETE
| FAN_DELETE_SELF
| FAN_FS_ERROR
| FAN_MOVED_FROM
| FAN_MOVED_TO
| FAN_MOVE_SELF;
let types = mask_to_event_types(mask);
assert_eq!(types.len(), 14);
}
#[test]
fn test_mask_to_event_types_with_flags() {
let mask = FAN_CREATE | FAN_EVENT_ON_CHILD | FAN_ONDIR;
let types = mask_to_event_types(mask);
assert_eq!(types.len(), 1);
assert_eq!(types[0], EventType::Create);
}
fn options(
size_filter: Option<SizeFilter>,
event_types: Option<Vec<EventType>>,
recursive: bool,
) -> PathOptions {
PathOptions {
size_filter,
event_types,
recursive,
cmd: None,
}
}
fn make_monitor(
paths: Vec<&str>,
size_filter: Option<SizeFilter>,
event_types: Option<Vec<EventType>>,
recursive: bool,
) -> Monitor {
Monitor::new(
paths
.into_iter()
.map(|p| {
(
PathBuf::from(p),
options(size_filter, event_types.clone(), recursive),
)
})
.collect(),
None,
None,
None,
None,
false,
None,
None,
None,
None,
false,
None,
)
.unwrap()
}
#[test]
fn test_should_output_no_filters() {
let m = make_monitor(vec!["/tmp"], None, None, false);
let event = make_event("/tmp/test.txt", EventType::Create, 1000, 1024);
assert!(m.should_output(&event));
}
#[test]
fn test_should_output_type_filter_match() {
let m = make_monitor(
vec!["/tmp"],
None,
Some(vec![EventType::Create, EventType::Delete]),
false,
);
assert!(m.should_output(&make_event("/tmp/a", EventType::Create, 1, 0)));
assert!(m.should_output(&make_event("/tmp/a", EventType::Delete, 1, 0)));
assert!(!m.should_output(&make_event("/tmp/a", EventType::Modify, 1, 0)));
}
#[test]
fn test_should_output_size_filter() {
let m = make_monitor(
vec!["/tmp"],
Some(SizeFilter {
op: SizeOp::Ge,
bytes: 1000,
}),
None,
false,
);
assert!(m.should_output(&make_event("/tmp/a", EventType::Create, 1, 2000)));
assert!(!m.should_output(&make_event("/tmp/a", EventType::Create, 1, 500)));
}
#[test]
fn test_should_output_combined_filters() {
let m = make_monitor(
vec!["/tmp"],
Some(SizeFilter {
op: SizeOp::Ge,
bytes: 100,
}),
Some(vec![EventType::Create]),
false,
);
assert!(m.should_output(&make_event("/tmp/data", EventType::Create, 1, 200)));
assert!(!m.should_output(&make_event("/tmp/data", EventType::Delete, 1, 200)));
assert!(!m.should_output(&make_event("/tmp/data", EventType::Create, 1, 50)));
}
#[test]
fn test_is_path_in_scope_recursive() {
let m = make_monitor(vec!["/tmp"], None, None, true);
assert!(m.is_path_in_scope(Path::new("/tmp")));
assert!(m.is_path_in_scope(Path::new("/tmp/sub")));
assert!(m.is_path_in_scope(Path::new("/tmp/sub/deep/file.txt")));
assert!(!m.is_path_in_scope(Path::new("/var/log")));
assert!(!m.is_path_in_scope(Path::new("/tmpfile")));
}
#[test]
fn test_is_path_in_scope_non_recursive() {
let m = make_monitor(vec!["/tmp"], None, None, false);
assert!(m.is_path_in_scope(Path::new("/tmp")));
assert!(m.is_path_in_scope(Path::new("/tmp/file.txt")));
assert!(!m.is_path_in_scope(Path::new("/tmp/sub/file.txt")));
assert!(!m.is_path_in_scope(Path::new("/var/log")));
}
#[test]
fn test_is_path_in_scope_multiple_paths() {
let m = make_monitor(vec!["/tmp", "/var/log"], None, None, true);
assert!(m.is_path_in_scope(Path::new("/tmp/file")));
assert!(m.is_path_in_scope(Path::new("/var/log/syslog")));
assert!(!m.is_path_in_scope(Path::new("/etc/passwd")));
}
#[test]
fn test_file_size_cache_eviction() {
use lru::LruCache;
use std::num::NonZeroUsize;
let mut cache = LruCache::new(NonZeroUsize::new(3).unwrap());
cache.put(PathBuf::from("/a"), 100);
cache.put(PathBuf::from("/b"), 200);
cache.put(PathBuf::from("/c"), 300);
assert_eq!(cache.len(), 3);
cache.put(PathBuf::from("/d"), 400);
assert_eq!(cache.len(), 3);
assert!(cache.get(&PathBuf::from("/a")).is_none());
assert_eq!(cache.get(&PathBuf::from("/b")), Some(&200));
assert_eq!(cache.get(&PathBuf::from("/d")), Some(&400));
cache.get(&PathBuf::from("/b"));
cache.put(PathBuf::from("/e"), 500);
assert!(cache.get(&PathBuf::from("/c")).is_none());
assert_eq!(cache.get(&PathBuf::from("/b")), Some(&200));
}
#[test]
fn test_reject_cmd_fsmon_at_startup() {
let opts = PathOptions {
size_filter: None,
event_types: None,
recursive: true,
cmd: Some("fsmon".to_string()),
};
let result = Monitor::new(
vec![(PathBuf::from("/tmp"), opts)],
None,
None,
None,
None,
false,
None,
None,
None,
None,
false,
None,
);
assert!(result.is_err(), "Monitor::new() should reject cmd=fsmon");
let err = result.err().unwrap().to_string();
assert!(
err.contains("Cannot monitor 'fsmon' process"),
"Error should mention fsmon rejection, got: {}",
err
);
}
#[test]
fn test_monitor_buffer_size_validation() {
let opts = options(None, None, false);
let result = Monitor::new(
vec![(PathBuf::from("/tmp"), opts.clone())],
None,
None,
Some(1024),
None,
false,
None,
None,
None,
None,
false,
None,
);
assert!(result.is_err());
assert!(result.err().unwrap().to_string().contains("at least 4096"));
let result = Monitor::new(
vec![(PathBuf::from("/tmp"), opts.clone())],
None,
None,
Some(2 * 1024 * 1024),
None,
false,
None,
None,
None,
None,
false,
None,
);
assert!(result.is_err());
assert!(result.err().unwrap().to_string().contains("not exceed"));
let result = Monitor::new(
vec![(PathBuf::from("/tmp"), opts.clone())],
None,
None,
Some(65536),
None,
false,
None,
None,
None,
None,
false,
None,
);
assert!(result.is_ok());
}
#[test]
fn test_add_path_and_remove_path() {
let mut m = Monitor::new(
vec![],
None,
None,
None,
None,
false,
None,
None,
None,
None,
false,
None,
)
.unwrap();
let entry = PathEntry {
cmd: None,
path: PathBuf::from("/tmp/test_add"),
recursive: Some(true),
types: None,
size: None,
};
let result = m.add_path(&entry);
assert!(result.is_ok());
assert!(
m.pending_paths
.iter()
.any(|(p, _)| p == Path::new("/tmp/test_add"))
);
assert!(!m.paths.contains(&PathBuf::from("/tmp/test_add")));
let result = m.remove_path(Path::new("/nonexistent"), None);
assert!(result.is_err());
}
#[test]
fn test_delete_self_canonical_root_is_recorded() {
use fanotify_fid::types::FidEvent;
let mut m = Monitor::new(
vec![(
std::path::PathBuf::from("/tmp/fsmon_test_delete_self"),
PathOptions {
size_filter: None,
event_types: None,
recursive: true,
cmd: None,
},
)],
None,
None,
None,
None,
false,
None,
None,
None,
None,
false,
None,
)
.unwrap();
m.canonical_paths = vec![std::path::PathBuf::from("/tmp/fsmon_test_delete_self")];
let event = FidEvent {
mask: fanotify_fid::consts::FAN_DELETE_SELF,
pid: 1234,
path: std::path::PathBuf::from("/tmp/fsmon_test_delete_self"),
dfid_name_handle: None,
dfid_name_filename: None,
self_handle: None,
};
let pending = m.process_event_batch(&[event]);
assert!(
pending
.iter()
.any(|pe| pe.event.event_type == crate::EventType::DeleteSelf),
"DELETE_SELF for canonical root should be recorded"
);
assert!(
m.pending_paths
.iter()
.any(|(p, _)| p == &std::path::PathBuf::from("/tmp/fsmon_test_delete_self")),
"canonical root should move to pending_paths after DELETE_SELF"
);
assert!(
!m.monitored_entries
.iter()
.any(|(p, _)| p == &std::path::PathBuf::from("/tmp/fsmon_test_delete_self")),
"canonical root should be removed from monitored_entries"
);
}
fn make_event(path: &str, event_type: EventType, pid: u32, size: u64) -> FileEvent {
FileEvent {
time: chrono::Utc::now(),
event_type,
path: PathBuf::from(path),
pid,
cmd: "test".to_string(),
user: "root".to_string(),
file_size: size,
ppid: 0,
tgid: 0,
chain: String::new(),
}
}
#[test]
#[ignore]
fn test_fanotify_init() {
let fd = fanotify_init(
FAN_CLOEXEC
| FAN_NONBLOCK
| FAN_CLASS_NOTIF
| FAN_REPORT_FID
| FAN_REPORT_DIR_FID
| FAN_REPORT_NAME,
(libc::O_CLOEXEC | libc::O_RDONLY) as u32,
);
assert!(fd.is_ok(), "fanotify_init should succeed with root");
}
#[test]
#[ignore]
fn test_fanotify_mark_directory() {
let test_dir = std::env::temp_dir().join("fsmon_test_mark");
std::fs::create_dir_all(&test_dir).unwrap();
let fd = fanotify_init(
FAN_CLOEXEC
| FAN_NONBLOCK
| FAN_CLASS_NOTIF
| FAN_REPORT_FID
| FAN_REPORT_DIR_FID
| FAN_REPORT_NAME,
(libc::O_CLOEXEC | libc::O_RDONLY) as u32,
)
.unwrap();
let mask = FAN_CREATE | FAN_DELETE | FAN_CLOSE_WRITE;
let result = fanotify_mark(
&fd,
FAN_MARK_ADD | FAN_MARK_FILESYSTEM,
mask,
AT_FDCWD,
&test_dir,
);
assert!(
result.is_ok(),
"fanotify_mark should succeed on existing directory"
);
drop(fd);
let _ = std::fs::remove_dir_all(&test_dir);
}
#[test]
#[ignore]
fn test_fanotify_mark_nonexistent_path() {
let fd = fanotify_init(
FAN_CLOEXEC
| FAN_NONBLOCK
| FAN_CLASS_NOTIF
| FAN_REPORT_FID
| FAN_REPORT_DIR_FID
| FAN_REPORT_NAME,
(libc::O_CLOEXEC | libc::O_RDONLY) as u32,
)
.unwrap();
let mask = FAN_CREATE;
let result = fanotify_mark(
&fd,
FAN_MARK_ADD,
mask,
AT_FDCWD,
Path::new("/nonexistent_path_12345"),
);
assert!(
result.is_err(),
"fanotify_mark should fail on nonexistent path"
);
drop(fd);
}
#[test]
fn test_fanotify_mark_null_byte_path_no_root() {
let mask = FAN_CREATE | FAN_DELETE;
let bad_path = Path::new("/tmp/ok\0evil");
let dev_null = std::fs::File::open("/dev/null").expect("/dev/null must exist on Linux");
let dummy_fd: std::os::fd::OwnedFd = dev_null.into();
let result = fanotify_mark(&dummy_fd, FAN_MARK_ADD, mask, AT_FDCWD, bad_path);
match result {
Err(FanotifyError::Mark(code)) => {
assert_eq!(
code,
libc::EINVAL,
"null byte path should return EINVAL, got errno={}",
code
);
}
other => panic!("expected Err(Mark(EINVAL)), got {:?}", other),
}
}
#[test]
#[ignore]
fn test_monitor_run_captures_events() {
use std::io::Write;
use std::sync::atomic::{AtomicUsize, Ordering};
let test_dir = std::env::temp_dir().join("fsmon_test_events");
std::fs::create_dir_all(&test_dir).unwrap();
let test_dir_for_cleanup = test_dir.clone();
let rt = tokio::runtime::Runtime::new().unwrap();
let counter = Arc::new(AtomicUsize::new(0));
let counter_clone = counter.clone();
let test_dir_clone = test_dir.clone();
let handle = rt.spawn(async move {
let fd = fanotify_init(
FAN_CLOEXEC
| FAN_NONBLOCK
| FAN_CLASS_NOTIF
| FAN_REPORT_FID
| FAN_REPORT_DIR_FID
| FAN_REPORT_NAME,
(libc::O_CLOEXEC | libc::O_RDONLY) as u32,
)
.unwrap();
let mask = FAN_CREATE | FAN_CLOSE_WRITE | FAN_EVENT_ON_CHILD | FAN_ONDIR;
fanotify_mark(
&fd,
FAN_MARK_ADD | FAN_MARK_FILESYSTEM,
mask,
AT_FDCWD,
&test_dir_clone,
)
.unwrap();
let mut buf = vec![0u8; 4096];
let start = std::time::Instant::now();
while start.elapsed() < std::time::Duration::from_millis(200) {
if let Ok(events) = fanotify_fid::read::read_fid_events(&fd, &[], &mut buf, None)
&& !events.is_empty()
{
counter_clone.fetch_add(events.len(), Ordering::SeqCst);
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
drop(fd);
});
std::thread::sleep(std::time::Duration::from_millis(50));
for i in 0..3 {
let path = test_dir.join(format!("test_{}.txt", i));
let mut f = std::fs::File::create(&path).unwrap();
writeln!(f, "content {}", i).unwrap();
}
rt.block_on(handle).unwrap();
let events_captured = counter.load(Ordering::SeqCst);
assert!(
events_captured > 0,
"Should capture at least some events, got {}",
events_captured
);
let _ = std::fs::remove_dir_all(&test_dir_for_cleanup);
}
#[test]
fn test_chains_contain_exact() {
assert!(chains_contain("bash → myapp → fsmon", "myapp"));
}
#[test]
fn test_chains_contain_not_found() {
assert!(!chains_contain("bash → other → fsmon", "myapp"));
}
#[test]
fn test_chains_contain_empty_chain() {
assert!(!chains_contain("", "myapp"));
}
#[test]
fn test_chains_contain_partial_name_not_match() {
assert!(!chains_contain("bash → myapp-backup → fsmon", "myapp"));
}
#[tokio::test]
async fn test_subscriber_task_receives_events() {
let (tx, mut rx) = tokio::sync::broadcast::channel(64);
let mut rx2 = tx.subscribe();
let event = FileEvent {
time: chrono::Utc::now(),
event_type: EventType::Create,
path: PathBuf::from("/tmp/test.txt"),
pid: 1234,
cmd: "test-cmd".to_string(),
user: "root".to_string(),
file_size: 100,
ppid: 0,
tgid: 0,
chain: "bash → test-cmd".to_string(),
};
tx.send(event.clone()).unwrap();
let received1 = rx.recv().await.unwrap();
let received2 = rx2.recv().await.unwrap();
assert_eq!(received1.path, PathBuf::from("/tmp/test.txt"));
assert_eq!(received2.path, PathBuf::from("/tmp/test.txt"));
}
#[tokio::test]
async fn test_subscriber_task_filters_by_cmd() {
assert!(chains_contain("bash → myapp", "myapp"));
assert!(!chains_contain("bash → myapp", "other-app"));
}
#[tokio::test]
async fn test_subscriber_task_filters_by_type() {
let allowed = [EventType::Delete, EventType::CloseWrite];
let create_event = FileEvent {
time: chrono::Utc::now(),
event_type: EventType::Create,
path: PathBuf::from("/tmp/ignored.txt"),
pid: 1,
cmd: "test".to_string(),
user: "root".to_string(),
file_size: 0,
ppid: 0,
tgid: 0,
chain: String::new(),
};
assert!(!allowed.contains(&create_event.event_type));
let delete_event = FileEvent {
time: chrono::Utc::now(),
event_type: EventType::Delete,
path: PathBuf::from("/tmp/deleted.txt"),
pid: 2,
cmd: "test".to_string(),
user: "root".to_string(),
file_size: 0,
ppid: 0,
tgid: 0,
chain: String::new(),
};
assert!(allowed.contains(&delete_event.event_type));
}
#[tokio::test]
async fn test_subscriber_task_handles_lagged() {
let (tx, mut rx) = tokio::sync::broadcast::channel(4);
for i in 0..10 {
let _ = tx.send(FileEvent {
time: chrono::Utc::now(),
event_type: EventType::Create,
path: PathBuf::from(format!("/tmp/batch_{}.txt", i)),
pid: 100 + i as u32,
cmd: "test".to_string(),
user: "root".to_string(),
file_size: i as u64,
ppid: 0,
tgid: 0,
chain: String::new(),
});
}
let result = rx.recv().await;
match result {
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
assert!(n > 0, "should lag with >0 dropped events, got {}", n);
}
Ok(event) => {
assert!(
event.file_size >= 6,
"should be a recent event, got file_size={}",
event.file_size
);
}
Err(e) => panic!("unexpected error: {:?}", e),
}
}