use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicIsize, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum OpType {
Lookup,
Getattr,
Readdir,
Open,
Read,
Write,
Release,
Readlink,
Create,
Mkdir,
Unlink,
Rmdir,
Rename,
Link,
Symlink,
Statfs,
Denied,
}
impl OpType {
pub fn label(&self) -> &'static str {
match self {
Self::Lookup => "LOOKUP",
Self::Getattr => "GETATTR",
Self::Readdir => "READDIR",
Self::Open => "OPEN",
Self::Read => "READ",
Self::Write => "WRITE",
Self::Release => "RELEASE",
Self::Readlink => "READLINK",
Self::Create => "CREATE",
Self::Mkdir => "MKDIR",
Self::Unlink => "UNLINK",
Self::Rmdir => "RMDIR",
Self::Rename => "RENAME",
Self::Link => "LINK",
Self::Symlink => "SYMLINK",
Self::Statfs => "STATFS",
Self::Denied => "DENIED",
}
}
pub const ALL: &'static [OpType] = &[
Self::Lookup,
Self::Getattr,
Self::Readdir,
Self::Open,
Self::Read,
Self::Write,
Self::Release,
Self::Readlink,
Self::Create,
Self::Mkdir,
Self::Unlink,
Self::Rmdir,
Self::Rename,
Self::Link,
Self::Symlink,
Self::Statfs,
Self::Denied,
];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccessKind {
Allowed,
Denied,
Bypassed,
}
#[derive(Debug, Clone)]
struct PathEntry {
path: PathBuf,
pid: u32,
process_name: String,
access: AccessKind,
hit_count: usize,
last_op: OpType,
}
#[derive(Debug, Clone)]
pub struct Snapshot {
pub ops: BTreeMap<OpType, (u64, u64)>,
pub recent_paths: Vec<PathSnapshot>,
pub open_handles: isize,
pub uptime: Duration,
pub source: PathBuf,
pub mountpoint: PathBuf,
}
#[derive(Debug, Clone)]
pub struct PathSnapshot {
pub path: PathBuf,
pub pid: u32,
pub process_name: String,
pub access: AccessKind,
pub hit_count: usize,
pub last_op: OpType,
}
pub struct StatsCollector {
op_totals: [AtomicU64; 18],
op_ticks: [AtomicU64; 18],
recent: Mutex<VecDeque<PathEntry>>,
open_handles: AtomicIsize,
created_at: Instant,
source: Mutex<PathBuf>,
mountpoint: Mutex<PathBuf>,
}
use std::collections::VecDeque;
impl StatsCollector {
pub fn new() -> Arc<Self> {
Arc::new(Self {
op_totals: [
AtomicU64::new(0),
AtomicU64::new(0),
AtomicU64::new(0),
AtomicU64::new(0),
AtomicU64::new(0),
AtomicU64::new(0),
AtomicU64::new(0),
AtomicU64::new(0),
AtomicU64::new(0),
AtomicU64::new(0),
AtomicU64::new(0),
AtomicU64::new(0),
AtomicU64::new(0),
AtomicU64::new(0),
AtomicU64::new(0),
AtomicU64::new(0),
AtomicU64::new(0),
AtomicU64::new(0),
],
op_ticks: [
AtomicU64::new(0),
AtomicU64::new(0),
AtomicU64::new(0),
AtomicU64::new(0),
AtomicU64::new(0),
AtomicU64::new(0),
AtomicU64::new(0),
AtomicU64::new(0),
AtomicU64::new(0),
AtomicU64::new(0),
AtomicU64::new(0),
AtomicU64::new(0),
AtomicU64::new(0),
AtomicU64::new(0),
AtomicU64::new(0),
AtomicU64::new(0),
AtomicU64::new(0),
AtomicU64::new(0),
],
recent: Mutex::new(VecDeque::with_capacity(11)),
open_handles: AtomicIsize::new(0),
created_at: Instant::now(),
source: Mutex::new(PathBuf::new()),
mountpoint: Mutex::new(PathBuf::new()),
})
}
pub fn set_source(&self, source: PathBuf) {
*self.source.lock().unwrap() = source;
}
pub fn set_mountpoint(&self, mountpoint: PathBuf) {
*self.mountpoint.lock().unwrap() = mountpoint;
}
fn op_idx(op: OpType) -> usize {
op as usize
}
pub fn record_op(&self, op: OpType, path: &std::path::Path, pid: u32, kind: AccessKind) {
let idx = Self::op_idx(op);
self.op_totals[idx].fetch_add(1, Ordering::Relaxed);
self.op_ticks[idx].fetch_add(1, Ordering::Relaxed);
let mut recent = self.recent.lock().unwrap();
if let Some(pos) = recent.iter().position(|e| e.path == path) {
let mut entry = recent.remove(pos).unwrap();
entry.hit_count += 1;
entry.access = kind;
entry.pid = pid;
entry.last_op = op;
recent.push_front(entry);
} else {
recent.push_front(PathEntry {
path: path.to_path_buf(),
pid,
process_name: crate::tools::get_process_name_cached(pid)
.unwrap_or_else(|| "<unknown>".to_string()),
access: kind,
hit_count: 1,
last_op: op,
});
while recent.len() > 10 {
recent.pop_back();
}
}
}
pub fn record_handle_open(&self) {
self.open_handles.fetch_add(1, Ordering::Relaxed);
}
pub fn record_handle_close(&self) {
self.open_handles.fetch_sub(1, Ordering::Relaxed);
}
pub fn snapshot(&self) -> Snapshot {
let mut ops = BTreeMap::new();
for op_type in OpType::ALL {
let idx = Self::op_idx(*op_type);
let total = self.op_totals[idx].load(Ordering::Relaxed);
let ticks = self.op_ticks[idx].swap(0, Ordering::Relaxed);
ops.insert(*op_type, (total, ticks));
}
let recent = self.recent.lock().unwrap();
let recent_paths: Vec<PathSnapshot> = recent
.iter()
.map(|e| PathSnapshot {
path: e.path.clone(),
pid: e.pid,
process_name: e.process_name.clone(),
access: e.access,
hit_count: e.hit_count,
last_op: e.last_op,
})
.collect();
let open_handles = self.open_handles.load(Ordering::Relaxed);
Snapshot {
ops,
recent_paths,
open_handles,
uptime: self.created_at.elapsed(),
source: self.source.lock().unwrap().clone(),
mountpoint: self.mountpoint.lock().unwrap().clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn new_stats_collector_starts_empty() {
let stats = StatsCollector::new();
let snap = stats.snapshot();
for (total, ticks) in snap.ops.values() {
assert_eq!(*total, 0);
assert_eq!(*ticks, 0);
}
assert!(snap.recent_paths.is_empty());
assert_eq!(snap.open_handles, 0);
}
#[test]
fn record_single_op_reflected_in_snapshot() {
let stats = StatsCollector::new();
let p = Path::new("/tmp/test.txt");
stats.record_op(OpType::Read, p, 100, AccessKind::Allowed);
let snap = stats.snapshot();
assert_eq!(snap.ops[&OpType::Read].0, 1); assert_eq!(snap.ops[&OpType::Read].1, 1); }
#[test]
fn snapshot_resets_tick_counters() {
let stats = StatsCollector::new();
let p = Path::new("/tmp/test.txt");
stats.record_op(OpType::Read, p, 100, AccessKind::Allowed);
let snap1 = stats.snapshot();
assert_eq!(snap1.ops[&OpType::Read].1, 1);
let snap2 = stats.snapshot();
assert_eq!(snap2.ops[&OpType::Read].1, 0);
assert_eq!(snap2.ops[&OpType::Read].0, 1);
}
#[test]
fn multiple_ops_accumulate_totals() {
let stats = StatsCollector::new();
let p = Path::new("/tmp/test.txt");
for _ in 0..10 {
stats.record_op(OpType::Lookup, p, 100, AccessKind::Allowed);
}
for _ in 0..5 {
stats.record_op(OpType::Read, p, 100, AccessKind::Allowed);
}
let snap = stats.snapshot();
assert_eq!(snap.ops[&OpType::Lookup].0, 10);
assert_eq!(snap.ops[&OpType::Read].0, 5);
}
#[test]
fn recent_paths_maintains_unique_entries() {
let stats = StatsCollector::new();
let p1 = Path::new("/tmp/file1.txt");
let p2 = Path::new("/tmp/file2.txt");
stats.record_op(OpType::Read, p1, 100, AccessKind::Allowed);
stats.record_op(OpType::Read, p2, 101, AccessKind::Allowed);
stats.record_op(OpType::Read, p1, 100, AccessKind::Allowed);
let snap = stats.snapshot();
assert_eq!(snap.recent_paths.len(), 2);
assert!(snap.recent_paths[0].path.ends_with("file1.txt"));
assert_eq!(snap.recent_paths[0].hit_count, 2);
assert!(snap.recent_paths[1].path.ends_with("file2.txt"));
assert_eq!(snap.recent_paths[1].hit_count, 1);
}
#[test]
fn recent_paths_max_10_oldest_dropped() {
let stats = StatsCollector::new();
for i in 0..12 {
let p_str = format!("/tmp/file{i}.txt");
let p = Path::new(&p_str);
stats.record_op(OpType::Read, p, 100, AccessKind::Allowed);
}
let snap = stats.snapshot();
assert_eq!(snap.recent_paths.len(), 10);
assert!(
!snap
.recent_paths
.iter()
.any(|ps| ps.path.ends_with("file0.txt"))
);
assert!(
!snap
.recent_paths
.iter()
.any(|ps| ps.path.ends_with("file1.txt"))
);
assert!(snap.recent_paths[0].path.ends_with("file11.txt"));
}
#[test]
fn recent_paths_updates_hit_count_on_re_access() {
let stats = StatsCollector::new();
let p = Path::new("/tmp/repeated.txt");
for _ in 0..5 {
stats.record_op(OpType::Read, p, 100, AccessKind::Allowed);
}
let snap = stats.snapshot();
assert_eq!(snap.recent_paths.len(), 1);
assert_eq!(snap.recent_paths[0].hit_count, 5);
}
#[test]
fn recent_paths_tracks_process_info() {
let stats = StatsCollector::new();
let p = Path::new("/tmp/proc_test.txt");
let our_pid = std::process::id();
stats.record_op(OpType::Read, p, our_pid, AccessKind::Allowed);
let snap = stats.snapshot();
assert_eq!(snap.recent_paths[0].pid, our_pid);
assert!(!snap.recent_paths[0].process_name.is_empty());
}
#[test]
fn recent_paths_records_access_kind() {
let stats = StatsCollector::new();
let p = Path::new("/tmp/secret.txt");
stats.record_op(OpType::Read, p, 100, AccessKind::Denied);
let snap = stats.snapshot();
assert_eq!(snap.recent_paths[0].access, AccessKind::Denied);
}
#[test]
fn open_handles_inc_dec() {
let stats = StatsCollector::new();
stats.record_handle_open();
stats.record_handle_open();
stats.record_handle_open();
let snap1 = stats.snapshot();
assert_eq!(snap1.open_handles, 3);
stats.record_handle_close();
let snap2 = stats.snapshot();
assert_eq!(snap2.open_handles, 2);
}
#[test]
fn uptime_increases() {
let stats = StatsCollector::new();
let snap1 = stats.snapshot();
std::thread::sleep(std::time::Duration::from_millis(10));
let snap2 = stats.snapshot();
assert!(snap2.uptime > snap1.uptime);
}
#[test]
fn source_and_mountpoint_persist() {
let stats = StatsCollector::new();
stats.set_source(PathBuf::from("/src"));
stats.set_mountpoint(PathBuf::from("/mnt"));
let snap = stats.snapshot();
assert_eq!(snap.source, PathBuf::from("/src"));
assert_eq!(snap.mountpoint, PathBuf::from("/mnt"));
}
#[test]
fn denied_op_tracked_separately() {
let stats = StatsCollector::new();
let p = Path::new("/tmp/hidden.txt");
stats.record_op(OpType::Denied, p, 100, AccessKind::Denied);
let snap = stats.snapshot();
assert!(snap.ops[&OpType::Denied].0 >= 1);
assert_eq!(snap.recent_paths[0].access, AccessKind::Denied);
}
}