use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use crate::config::chown_to_original_user;
pub const CMD_GLOBAL: &str = "_global";
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Monitored {
pub groups: Vec<CmdGroup>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CmdGroup {
pub cmd: String,
pub paths: BTreeMap<PathBuf, PathParams>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PathParams {
pub recursive: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub types: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub size: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PathEntry {
pub cmd: Option<String>,
pub path: PathBuf,
pub recursive: Option<bool>,
pub types: Option<Vec<String>>,
pub size: Option<String>,
}
impl PathParams {
pub fn new(recursive: Option<bool>, types: Option<Vec<String>>, size: Option<String>) -> Self {
PathParams {
recursive,
types,
size,
}
}
}
impl From<&PathEntry> for PathParams {
fn from(e: &PathEntry) -> Self {
PathParams {
recursive: e.recursive,
types: e.types.clone(),
size: e.size.clone(),
}
}
}
impl Monitored {
pub fn load(path: &Path) -> Result<Self> {
if !path.exists() {
return Ok(Monitored::default());
}
let file = fs::File::open(path)
.with_context(|| format!("Failed to open store {}", path.display()))?;
let reader = BufReader::new(file);
let mut groups = Vec::new();
for line in reader.lines() {
let line = line?;
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let group: CmdGroup = serde_json::from_str(trimmed).with_context(|| {
format!("Invalid JSON in store {}: {}", path.display(), trimmed)
})?;
groups.push(group);
}
let mut store = Monitored { groups };
store.validate();
Ok(store)
}
pub fn validate(&mut self) -> bool {
let mut repaired = false;
let mut deduped: Vec<CmdGroup> = Vec::with_capacity(self.groups.len());
for group in self.groups.drain(..) {
if group.paths.is_empty() {
repaired = true;
continue;
}
deduped.push(group);
}
self.groups = deduped;
repaired
}
pub fn flatten(&self) -> Vec<PathEntry> {
let mut entries = Vec::new();
for group in &self.groups {
for (path, params) in &group.paths {
entries.push(PathEntry {
cmd: Some(group.cmd.clone()),
path: path.clone(),
recursive: params.recursive,
types: params.types.clone(),
size: params.size.clone(),
});
}
}
entries
}
pub fn save(&self, path: &Path) -> Result<()> {
let parent = path.parent().context("Monitored path has no parent")?;
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory {}", parent.display()))?;
let mut file = fs::File::create(path)
.with_context(|| format!("Failed to create store {}", path.display()))?;
chown_to_original_user(path);
chown_to_original_user(parent);
for group in &self.groups {
let line = serde_json::to_string(group).context("Failed to serialize store group")?;
writeln!(file, "{}", line).context("Failed to write store group")?;
}
Ok(())
}
pub fn add_entry(&mut self, entry: PathEntry) {
let cmd = entry.cmd.clone().unwrap_or_else(|| CMD_GLOBAL.to_string());
let params = PathParams::from(&entry);
if let Some(group) = self.groups.iter_mut().find(|g| g.cmd == cmd) {
group.paths.insert(entry.path.clone(), params);
} else {
let mut paths = BTreeMap::new();
paths.insert(entry.path.clone(), params);
self.groups.push(CmdGroup { cmd, paths });
}
}
pub fn remove_entry(&mut self, path: &Path, cmd: Option<&str>) -> bool {
let target = cmd.unwrap_or(CMD_GLOBAL);
let mut removed = false;
for group in self.groups.iter_mut() {
if group.cmd != target {
continue;
}
removed |= group.paths.remove(path).is_some();
}
self.groups.retain(|g| !g.paths.is_empty());
removed
}
pub fn get(&self, path: &Path, cmd: Option<&str>) -> Option<PathEntry> {
let target = cmd.unwrap_or(CMD_GLOBAL);
for group in &self.groups {
if group.cmd != target {
continue;
}
if let Some(params) = group.paths.get(path) {
return Some(PathEntry {
cmd: Some(group.cmd.clone()),
path: path.to_path_buf(),
recursive: params.recursive,
types: params.types.clone(),
size: params.size.clone(),
});
}
}
None
}
pub fn is_empty(&self) -> bool {
self.groups.is_empty() || self.groups.iter().all(|g| g.paths.is_empty())
}
pub fn entry_count(&self) -> usize {
self.groups.iter().map(|g| g.paths.len()).sum()
}
pub fn remove_cmd_group(&mut self, cmd: Option<&str>) -> bool {
let target = cmd.unwrap_or(CMD_GLOBAL);
let len_before = self.groups.len();
self.groups.retain(|g| g.cmd != target);
self.groups.len() < len_before
}
pub fn has_entry(&self, path: &Path, cmd: Option<&str>) -> bool {
let target = cmd.unwrap_or(CMD_GLOBAL);
self.groups
.iter()
.any(|g| g.cmd == target && g.paths.contains_key(path))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn temp_path() -> (PathBuf, PathBuf) {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir =
std::env::temp_dir().join(format!("fsmon_monitored_test_{}_{}", std::process::id(), n));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let monitored_path = dir.join("monitored.jsonl");
(dir, monitored_path)
}
fn make_entry(path: &str, cmd: Option<&str>, recursive: Option<bool>) -> PathEntry {
PathEntry {
path: PathBuf::from(path),
recursive,
types: None,
size: None,
cmd: cmd.map(|s| s.to_string()),
}
}
#[test]
fn test_load_returns_default_when_no_file() {
let (_dir, path) = temp_path();
assert!(!path.exists());
let store = Monitored::load(&path).unwrap();
assert!(store.groups.is_empty());
}
#[test]
fn test_add_entry_uses_cmd_as_key() {
let (_dir, path) = temp_path();
let mut store = Monitored::load(&path).unwrap();
store.add_entry(make_entry("/tmp", None, Some(true)));
assert_eq!(store.entry_count(), 1);
assert!(store.get(Path::new("/tmp"), None).is_some());
assert!(store.get(Path::new("/tmp"), Some("_global")).is_some());
store.add_entry(make_entry("/var/log", Some("bash"), Some(false)));
assert_eq!(store.entry_count(), 2);
}
#[test]
fn test_add_entry_replaces_same_path_and_cmd() {
let (_dir, path) = temp_path();
let mut store = Monitored::load(&path).unwrap();
store.add_entry(make_entry("/home", None, Some(true)));
assert_eq!(store.entry_count(), 1);
store.add_entry(make_entry("/home", None, Some(false)));
assert_eq!(store.entry_count(), 1);
let entry = store.get(Path::new("/home"), None).unwrap();
assert_eq!(entry.recursive, Some(false));
}
#[test]
fn test_add_entry_different_cmd_same_path() {
let (_dir, path) = temp_path();
let mut store = Monitored::load(&path).unwrap();
store.add_entry(make_entry("/home", Some("bash"), Some(true)));
store.add_entry(make_entry("/home", None, Some(false)));
assert_eq!(store.entry_count(), 2);
assert_eq!(store.groups.len(), 2);
}
#[test]
fn test_remove_entry_by_path() {
let (_dir, path) = temp_path();
let mut store = Monitored::load(&path).unwrap();
store.add_entry(make_entry("/tmp", None, None));
store.add_entry(make_entry("/var", None, None));
assert!(store.remove_entry(Path::new("/tmp"), None));
assert_eq!(store.entry_count(), 1);
assert!(store.get(Path::new("/var"), None).is_some());
assert!(!store.remove_entry(Path::new("/nonexistent"), None));
assert_eq!(store.entry_count(), 1);
}
#[test]
fn test_remove_entry_by_path_and_cmd() {
let (_dir, path) = temp_path();
let mut store = Monitored::load(&path).unwrap();
store.add_entry(make_entry("/tmp", Some("bash"), None));
store.add_entry(make_entry("/tmp", None, Some(true)));
assert_eq!(store.entry_count(), 2);
assert!(store.remove_entry(Path::new("/tmp"), Some("bash")));
assert_eq!(store.entry_count(), 1);
assert!(store.get(Path::new("/tmp"), None).is_some());
assert!(store.get(Path::new("/tmp"), Some("bash")).is_none());
}
#[test]
fn test_save_and_load_round_trip() {
let (_dir, path) = temp_path();
let mut store = Monitored::load(&path).unwrap();
store.add_entry(PathEntry {
path: PathBuf::from("/srv"),
recursive: Some(true),
types: Some(vec!["CREATE".into(), "DELETE".into()]),
size: Some("1KB".into()),
cmd: None,
});
store.save(&path).unwrap();
let loaded = Monitored::load(&path).unwrap();
assert_eq!(loaded.entry_count(), 1);
let entry = loaded.get(Path::new("/srv"), None).unwrap();
assert_eq!(entry.recursive, Some(true));
assert_eq!(entry.types.as_ref().unwrap(), &["CREATE", "DELETE"]);
assert_eq!(entry.size.as_ref().unwrap(), "1KB");
}
#[test]
fn test_get_entry_by_path() {
let (_dir, path) = temp_path();
let mut store = Monitored::load(&path).unwrap();
store.add_entry(make_entry("/data", None, None));
assert!(store.get(Path::new("/data"), None).is_some());
assert!(store.get(Path::new("/nonexistent"), None).is_none());
}
#[test]
fn test_empty_monitored_defaults() {
let store = Monitored::default();
assert!(store.groups.is_empty());
assert!(store.is_empty());
}
#[test]
fn test_flatten_groups() {
let mut store = Monitored::default();
store.add_entry(make_entry("/a", Some("bash"), Some(true)));
store.add_entry(make_entry("/b", None, Some(false)));
let flat = store.flatten();
assert_eq!(flat.len(), 2);
assert!(
flat.iter()
.any(|e| e.path == Path::new("/a") && e.cmd.as_deref() == Some("bash"))
);
assert!(
flat.iter()
.any(|e| e.path == Path::new("/b") && e.cmd.as_deref() == Some("_global"))
);
}
#[test]
fn test_save_load_grouped_format() {
let (_dir, path) = temp_path();
let mut store = Monitored::default();
store.add_entry(make_entry("/a", Some("bash"), Some(true)));
store.add_entry(make_entry("/b", Some("bash"), Some(false)));
store.add_entry(make_entry("/c", None, Some(true)));
store.save(&path).unwrap();
let content = fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 2);
let line0: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
assert_eq!(line0["cmd"], "bash");
assert!(line0["paths"].is_object());
let line1: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
assert_eq!(line1["cmd"], "_global");
assert!(line1["paths"].is_object());
let loaded = Monitored::load(&path).unwrap();
assert_eq!(loaded.entry_count(), 3);
assert_eq!(loaded.groups.len(), 2);
}
#[test]
fn test_validate_removes_empty_groups() {
let mut store = Monitored {
groups: vec![
CmdGroup {
cmd: "bash".into(),
paths: BTreeMap::new(),
},
CmdGroup {
cmd: CMD_GLOBAL.into(),
paths: {
let mut m = BTreeMap::new();
m.insert(
PathBuf::from("/tmp"),
PathParams::new(Some(true), None, None),
);
m
},
},
],
};
assert!(store.validate());
assert_eq!(store.groups.len(), 1);
}
#[test]
fn test_validate_no_repair_on_unique_paths() {
let mut store = Monitored {
groups: vec![CmdGroup {
cmd: CMD_GLOBAL.into(),
paths: {
let mut m = BTreeMap::new();
m.insert(PathBuf::from("/a"), PathParams::new(None, None, None));
m
},
}],
};
assert!(!store.validate());
assert_eq!(store.groups.len(), 1);
}
#[test]
fn test_validate_empty_noop() {
let mut store = Monitored::default();
assert!(!store.validate());
}
#[test]
fn test_jsonl_grouped_format_with_cmd() {
let jsonl = concat!(
r#"{"cmd":"bash","paths":{"/tmp":{"recursive":true},"/home":{"recursive":false,"types":["MODIFY"]}}}"#,
"\n",
r#"{"cmd":"_global","paths":{"/var":{"recursive":true,"size":">1MB"}}}"#,
"\n",
);
let (_dir, path) = temp_path();
fs::write(&path, jsonl).unwrap();
let store = Monitored::load(&path).unwrap();
assert_eq!(store.groups.len(), 2);
assert_eq!(store.entry_count(), 3);
}
#[test]
fn test_jsonl_missing_cmd_field_fails() {
let jsonl = concat!(r#"{"paths":{"/tmp":{"recursive":true}}}"#, "\n",);
let (_dir, path) = temp_path();
fs::write(&path, jsonl).unwrap();
let result = Monitored::load(&path);
assert!(result.is_err(), "missing cmd field should fail");
}
#[test]
fn test_jsonl_old_flat_format_fails() {
let jsonl = concat!(r#"{"path":"/tmp","recursive":true}"#, "\n",);
let (_dir, path) = temp_path();
fs::write(&path, jsonl).unwrap();
let result = Monitored::load(&path);
assert!(result.is_err(), "old flat format should fail");
}
#[test]
fn test_entry_count() {
let mut store = Monitored::default();
assert_eq!(store.entry_count(), 0);
store.add_entry(make_entry("/a", None, None));
assert_eq!(store.entry_count(), 1);
store.add_entry(make_entry("/b", Some("x"), None));
assert_eq!(store.entry_count(), 2);
}
#[test]
fn test_is_empty() {
assert!(Monitored::default().is_empty());
}
#[test]
fn test_flatten_no_groups() {
assert!(Monitored::default().flatten().is_empty());
}
#[test]
fn test_add_with_path_and_cmd_key() {
let (_dir, path) = temp_path();
let mut store = Monitored::load(&path).unwrap();
store.add_entry(make_entry("/tmp", Some("bash"), Some(true)));
store.add_entry(make_entry("/tmp", Some("nginx"), Some(false)));
assert_eq!(store.entry_count(), 2);
assert_eq!(store.groups.len(), 2);
let bash_entry = store.get(Path::new("/tmp"), Some("bash")).unwrap();
assert_eq!(bash_entry.recursive, Some(true));
let nginx_entry = store.get(Path::new("/tmp"), Some("nginx")).unwrap();
assert_eq!(nginx_entry.recursive, Some(false));
}
#[test]
fn test_global_group_explicit() {
let (_dir, path) = temp_path();
let mut store = Monitored::load(&path).unwrap();
store.add_entry(make_entry("/x", Some("_global"), Some(true)));
store.add_entry(make_entry("/y", None, Some(false)));
assert_eq!(store.groups.len(), 1);
assert_eq!(store.groups[0].cmd, "_global");
assert_eq!(store.entry_count(), 2);
}
#[test]
fn test_remove_cmd_group_global() {
let (_dir, _path) = temp_path();
let mut store = Monitored::default();
store.add_entry(make_entry("/a", None, None));
store.add_entry(make_entry("/b", Some("x"), None));
assert!(store.remove_cmd_group(None)); assert_eq!(store.entry_count(), 1);
assert!(store.get(Path::new("/b"), Some("x")).is_some());
}
#[test]
fn test_has_entry() {
let mut store = Monitored::default();
store.add_entry(make_entry("/a", None, None));
store.add_entry(make_entry("/b", Some("x"), None));
assert!(store.has_entry(Path::new("/a"), None));
assert!(store.has_entry(Path::new("/a"), Some("_global")));
assert!(store.has_entry(Path::new("/b"), Some("x")));
assert!(!store.has_entry(Path::new("/b"), None));
assert!(!store.has_entry(Path::new("/x"), None));
}
}