Skip to main content

fsmon/
monitored.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::BTreeMap;
4use std::fs;
5use std::io::{BufRead, BufReader, Write};
6use std::path::{Path, PathBuf};
7
8use crate::config::chown_to_original_user;
9
10/// Sentinel value for the global cmd group (no specific process).
11pub const CMD_GLOBAL: &str = "_global";
12
13/// The monitored paths database, stored in the file configured by `[monitored].path`.
14///
15/// Monitored automatically by `fsmon add` and `fsmon remove`.
16///
17/// # JSONL Format (grouped by cmd)
18/// Each line must have a `cmd` field:
19/// ```json
20/// {"cmd":"bash","paths":{"/a":{"recursive":true},"/b":{"recursive":false,"types":["MODIFY"]}}}
21/// {"cmd":"_global","paths":{"/c":{"recursive":true}}}
22/// ```
23/// Use `"_global"` for the global group (no specific process).
24#[derive(Debug, Clone, Serialize, Deserialize, Default)]
25pub struct Monitored {
26    /// Monitored path groups, each keyed by cmd.
27    pub groups: Vec<CmdGroup>,
28}
29
30/// Per-process-name group of monitored paths.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct CmdGroup {
33    /// Process name for process-tree tracking. `"_global"` = match all processes.
34    pub cmd: String,
35    /// Map of path → per-path parameters.
36    pub paths: BTreeMap<PathBuf, PathParams>,
37}
38
39/// Per-path filtering parameters.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct PathParams {
42    /// Watch subdirectories recursively.
43    pub recursive: Option<bool>,
44    /// Only monitor specified event types (e.g. `["MODIFY", "CREATE"]`).
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub types: Option<Vec<String>>,
47    /// Size filter with comparison operator (e.g. >1MB, >=500KB, <100MB).
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub size: Option<String>,
50}
51
52/// A single monitored path entry (flat form) — used for internal transport
53/// between Monitored store, Monitor, socket, and CLI commands.
54/// Not serialized to monitored.jsonl anymore.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct PathEntry {
57    /// Process name for process-tree tracking.
58    /// `None` means `"_global"` (no specific process).
59    pub cmd: Option<String>,
60    /// Filesystem path to monitor.
61    pub path: PathBuf,
62    /// Watch subdirectories recursively.
63    pub recursive: Option<bool>,
64    /// Only monitor specified event types (e.g. `["MODIFY", "CREATE"]`).
65    pub types: Option<Vec<String>>,
66    /// Size filter with comparison operator (e.g. >1MB, >=500KB, <100MB).
67    pub size: Option<String>,
68}
69
70impl PathParams {
71    pub fn new(recursive: Option<bool>, types: Option<Vec<String>>, size: Option<String>) -> Self {
72        PathParams {
73            recursive,
74            types,
75            size,
76        }
77    }
78}
79
80impl From<&PathEntry> for PathParams {
81    fn from(e: &PathEntry) -> Self {
82        PathParams {
83            recursive: e.recursive,
84            types: e.types.clone(),
85            size: e.size.clone(),
86        }
87    }
88}
89
90impl Monitored {
91    /// Load Monitored from file (JSONL format). Returns empty Monitored if file doesn't exist.
92    /// Each line must be a valid `CmdGroup` JSON with a `cmd` field.
93    pub fn load(path: &Path) -> Result<Self> {
94        if !path.exists() {
95            return Ok(Monitored::default());
96        }
97        let file = fs::File::open(path)
98            .with_context(|| format!("Failed to open store {}", path.display()))?;
99        let reader = BufReader::new(file);
100        let mut groups = Vec::new();
101        for line in reader.lines() {
102            let line = line?;
103            let trimmed = line.trim();
104            if trimmed.is_empty() {
105                continue;
106            }
107            let group: CmdGroup = serde_json::from_str(trimmed).with_context(|| {
108                format!("Invalid JSON in store {}: {}", path.display(), trimmed)
109            })?;
110            groups.push(group);
111        }
112        let mut store = Monitored { groups };
113        store.validate();
114        Ok(store)
115    }
116
117    /// Validate and repair consistency issues in-place.
118    /// Deduplicate paths within each group. Remove empty groups.
119    /// Returns `true` if any repairs were made.
120    pub fn validate(&mut self) -> bool {
121        let mut repaired = false;
122        let mut deduped: Vec<CmdGroup> = Vec::with_capacity(self.groups.len());
123        for group in self.groups.drain(..) {
124            if group.paths.is_empty() {
125                repaired = true;
126                continue;
127            }
128            deduped.push(group);
129        }
130        self.groups = deduped;
131        repaired
132    }
133
134    /// Flatten all groups into a Vec<PathEntry> (compatibility with legacy code).
135    pub fn flatten(&self) -> Vec<PathEntry> {
136        let mut entries = Vec::new();
137        for group in &self.groups {
138            for (path, params) in &group.paths {
139                entries.push(PathEntry {
140                    cmd: Some(group.cmd.clone()),
141                    path: path.clone(),
142                    recursive: params.recursive,
143                    types: params.types.clone(),
144                    size: params.size.clone(),
145                });
146            }
147        }
148        entries
149    }
150
151    /// Save Monitored to file (JSONL format). Creates parent directories if needed.
152    pub fn save(&self, path: &Path) -> Result<()> {
153        let parent = path.parent().context("Monitored path has no parent")?;
154        fs::create_dir_all(parent)
155            .with_context(|| format!("Failed to create directory {}", parent.display()))?;
156        let mut file = fs::File::create(path)
157            .with_context(|| format!("Failed to create store {}", path.display()))?;
158        chown_to_original_user(path);
159        chown_to_original_user(parent);
160        for group in &self.groups {
161            let line = serde_json::to_string(group).context("Failed to serialize store group")?;
162            writeln!(file, "{}", line).context("Failed to write store group")?;
163        }
164        Ok(())
165    }
166
167    /// Add an entry. If entry.cmd is None, it's treated as `"_global"`.
168    pub fn add_entry(&mut self, entry: PathEntry) {
169        let cmd = entry.cmd.clone().unwrap_or_else(|| CMD_GLOBAL.to_string());
170        let params = PathParams::from(&entry);
171        if let Some(group) = self.groups.iter_mut().find(|g| g.cmd == cmd) {
172            group.paths.insert(entry.path.clone(), params);
173        } else {
174            let mut paths = BTreeMap::new();
175            paths.insert(entry.path.clone(), params);
176            self.groups.push(CmdGroup { cmd, paths });
177        }
178    }
179
180    /// Remove entries matching path and optionally cmd.
181    /// If cmd is Some, only removes from that cmd group.
182    /// If cmd is None, removes from `"_global"` group.
183    /// Returns `true` if any entry was removed.
184    pub fn remove_entry(&mut self, path: &Path, cmd: Option<&str>) -> bool {
185        let target = cmd.unwrap_or(CMD_GLOBAL);
186        let mut removed = false;
187        for group in self.groups.iter_mut() {
188            if group.cmd != target {
189                continue;
190            }
191            removed |= group.paths.remove(path).is_some();
192        }
193        self.groups.retain(|g| !g.paths.is_empty());
194        removed
195    }
196
197    /// Get an entry by (path, cmd) pair. cmd=None → `"_global"` group.
198    pub fn get(&self, path: &Path, cmd: Option<&str>) -> Option<PathEntry> {
199        let target = cmd.unwrap_or(CMD_GLOBAL);
200        for group in &self.groups {
201            if group.cmd != target {
202                continue;
203            }
204            if let Some(params) = group.paths.get(path) {
205                return Some(PathEntry {
206                    cmd: Some(group.cmd.clone()),
207                    path: path.to_path_buf(),
208                    recursive: params.recursive,
209                    types: params.types.clone(),
210                    size: params.size.clone(),
211                });
212            }
213        }
214        None
215    }
216
217    /// Check whether there are any entries.
218    pub fn is_empty(&self) -> bool {
219        self.groups.is_empty() || self.groups.iter().all(|g| g.paths.is_empty())
220    }
221
222    /// Total number of path entries across all groups.
223    pub fn entry_count(&self) -> usize {
224        self.groups.iter().map(|g| g.paths.len()).sum()
225    }
226
227    /// Remove an entire cmd group by cmd name (None = `"_global"`).
228    pub fn remove_cmd_group(&mut self, cmd: Option<&str>) -> bool {
229        let target = cmd.unwrap_or(CMD_GLOBAL);
230        let len_before = self.groups.len();
231        self.groups.retain(|g| g.cmd != target);
232        self.groups.len() < len_before
233    }
234
235    /// Check if a specific (path, cmd) entry exists.
236    /// cmd=None → `"_global"` group.
237    pub fn has_entry(&self, path: &Path, cmd: Option<&str>) -> bool {
238        let target = cmd.unwrap_or(CMD_GLOBAL);
239        self.groups
240            .iter()
241            .any(|g| g.cmd == target && g.paths.contains_key(path))
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use std::fs;
249
250    fn temp_path() -> (PathBuf, PathBuf) {
251        use std::sync::atomic::{AtomicU64, Ordering};
252        static COUNTER: AtomicU64 = AtomicU64::new(0);
253        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
254        let dir =
255            std::env::temp_dir().join(format!("fsmon_monitored_test_{}_{}", std::process::id(), n));
256        let _ = fs::remove_dir_all(&dir);
257        fs::create_dir_all(&dir).unwrap();
258        let monitored_path = dir.join("monitored.jsonl");
259        (dir, monitored_path)
260    }
261
262    fn make_entry(path: &str, cmd: Option<&str>, recursive: Option<bool>) -> PathEntry {
263        PathEntry {
264            path: PathBuf::from(path),
265            recursive,
266            types: None,
267            size: None,
268            cmd: cmd.map(|s| s.to_string()),
269        }
270    }
271
272    #[test]
273    fn test_load_returns_default_when_no_file() {
274        let (_dir, path) = temp_path();
275        assert!(!path.exists());
276        let store = Monitored::load(&path).unwrap();
277        assert!(store.groups.is_empty());
278    }
279
280    #[test]
281    fn test_add_entry_uses_cmd_as_key() {
282        let (_dir, path) = temp_path();
283        let mut store = Monitored::load(&path).unwrap();
284
285        store.add_entry(make_entry("/tmp", None, Some(true)));
286        assert_eq!(store.entry_count(), 1);
287        assert!(store.get(Path::new("/tmp"), None).is_some());
288        assert!(store.get(Path::new("/tmp"), Some("_global")).is_some());
289
290        store.add_entry(make_entry("/var/log", Some("bash"), Some(false)));
291        assert_eq!(store.entry_count(), 2);
292    }
293
294    #[test]
295    fn test_add_entry_replaces_same_path_and_cmd() {
296        let (_dir, path) = temp_path();
297        let mut store = Monitored::load(&path).unwrap();
298
299        store.add_entry(make_entry("/home", None, Some(true)));
300        assert_eq!(store.entry_count(), 1);
301
302        store.add_entry(make_entry("/home", None, Some(false)));
303        assert_eq!(store.entry_count(), 1);
304        let entry = store.get(Path::new("/home"), None).unwrap();
305        assert_eq!(entry.recursive, Some(false));
306    }
307
308    #[test]
309    fn test_add_entry_different_cmd_same_path() {
310        let (_dir, path) = temp_path();
311        let mut store = Monitored::load(&path).unwrap();
312
313        store.add_entry(make_entry("/home", Some("bash"), Some(true)));
314        store.add_entry(make_entry("/home", None, Some(false)));
315        assert_eq!(store.entry_count(), 2);
316        assert_eq!(store.groups.len(), 2);
317    }
318
319    #[test]
320    fn test_remove_entry_by_path() {
321        let (_dir, path) = temp_path();
322        let mut store = Monitored::load(&path).unwrap();
323
324        store.add_entry(make_entry("/tmp", None, None));
325        store.add_entry(make_entry("/var", None, None));
326
327        assert!(store.remove_entry(Path::new("/tmp"), None));
328        assert_eq!(store.entry_count(), 1);
329        assert!(store.get(Path::new("/var"), None).is_some());
330
331        assert!(!store.remove_entry(Path::new("/nonexistent"), None));
332        assert_eq!(store.entry_count(), 1);
333    }
334
335    #[test]
336    fn test_remove_entry_by_path_and_cmd() {
337        let (_dir, path) = temp_path();
338        let mut store = Monitored::load(&path).unwrap();
339
340        store.add_entry(make_entry("/tmp", Some("bash"), None));
341        store.add_entry(make_entry("/tmp", None, Some(true)));
342
343        assert_eq!(store.entry_count(), 2);
344
345        assert!(store.remove_entry(Path::new("/tmp"), Some("bash")));
346        assert_eq!(store.entry_count(), 1);
347
348        // /tmp in _global should still exist
349        assert!(store.get(Path::new("/tmp"), None).is_some());
350        assert!(store.get(Path::new("/tmp"), Some("bash")).is_none());
351    }
352
353    #[test]
354    fn test_save_and_load_round_trip() {
355        let (_dir, path) = temp_path();
356        let mut store = Monitored::load(&path).unwrap();
357
358        store.add_entry(PathEntry {
359            path: PathBuf::from("/srv"),
360            recursive: Some(true),
361            types: Some(vec!["CREATE".into(), "DELETE".into()]),
362            size: Some("1KB".into()),
363            cmd: None,
364        });
365
366        store.save(&path).unwrap();
367
368        let loaded = Monitored::load(&path).unwrap();
369        assert_eq!(loaded.entry_count(), 1);
370        let entry = loaded.get(Path::new("/srv"), None).unwrap();
371        assert_eq!(entry.recursive, Some(true));
372        assert_eq!(entry.types.as_ref().unwrap(), &["CREATE", "DELETE"]);
373        assert_eq!(entry.size.as_ref().unwrap(), "1KB");
374    }
375
376    #[test]
377    fn test_get_entry_by_path() {
378        let (_dir, path) = temp_path();
379        let mut store = Monitored::load(&path).unwrap();
380
381        store.add_entry(make_entry("/data", None, None));
382        assert!(store.get(Path::new("/data"), None).is_some());
383        assert!(store.get(Path::new("/nonexistent"), None).is_none());
384    }
385
386    #[test]
387    fn test_empty_monitored_defaults() {
388        let store = Monitored::default();
389        assert!(store.groups.is_empty());
390        assert!(store.is_empty());
391    }
392
393    #[test]
394    fn test_flatten_groups() {
395        let mut store = Monitored::default();
396        store.add_entry(make_entry("/a", Some("bash"), Some(true)));
397        store.add_entry(make_entry("/b", None, Some(false)));
398
399        let flat = store.flatten();
400        assert_eq!(flat.len(), 2);
401        assert!(
402            flat.iter()
403                .any(|e| e.path == Path::new("/a") && e.cmd.as_deref() == Some("bash"))
404        );
405        assert!(
406            flat.iter()
407                .any(|e| e.path == Path::new("/b") && e.cmd.as_deref() == Some("_global"))
408        );
409    }
410
411    #[test]
412    fn test_save_load_grouped_format() {
413        let (_dir, path) = temp_path();
414        let mut store = Monitored::default();
415
416        store.add_entry(make_entry("/a", Some("bash"), Some(true)));
417        store.add_entry(make_entry("/b", Some("bash"), Some(false)));
418        store.add_entry(make_entry("/c", None, Some(true)));
419
420        store.save(&path).unwrap();
421
422        let content = fs::read_to_string(&path).unwrap();
423        let lines: Vec<&str> = content.lines().collect();
424        assert_eq!(lines.len(), 2);
425
426        let line0: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
427        assert_eq!(line0["cmd"], "bash");
428        assert!(line0["paths"].is_object());
429
430        let line1: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
431        assert_eq!(line1["cmd"], "_global");
432        assert!(line1["paths"].is_object());
433
434        let loaded = Monitored::load(&path).unwrap();
435        assert_eq!(loaded.entry_count(), 3);
436        assert_eq!(loaded.groups.len(), 2);
437    }
438
439    #[test]
440    fn test_validate_removes_empty_groups() {
441        let mut store = Monitored {
442            groups: vec![
443                CmdGroup {
444                    cmd: "bash".into(),
445                    paths: BTreeMap::new(),
446                },
447                CmdGroup {
448                    cmd: CMD_GLOBAL.into(),
449                    paths: {
450                        let mut m = BTreeMap::new();
451                        m.insert(
452                            PathBuf::from("/tmp"),
453                            PathParams::new(Some(true), None, None),
454                        );
455                        m
456                    },
457                },
458            ],
459        };
460        assert!(store.validate());
461        assert_eq!(store.groups.len(), 1);
462    }
463
464    #[test]
465    fn test_validate_no_repair_on_unique_paths() {
466        let mut store = Monitored {
467            groups: vec![CmdGroup {
468                cmd: CMD_GLOBAL.into(),
469                paths: {
470                    let mut m = BTreeMap::new();
471                    m.insert(PathBuf::from("/a"), PathParams::new(None, None, None));
472                    m
473                },
474            }],
475        };
476        assert!(!store.validate());
477        assert_eq!(store.groups.len(), 1);
478    }
479
480    #[test]
481    fn test_validate_empty_noop() {
482        let mut store = Monitored::default();
483        assert!(!store.validate());
484    }
485
486    #[test]
487    fn test_jsonl_grouped_format_with_cmd() {
488        let jsonl = concat!(
489            r#"{"cmd":"bash","paths":{"/tmp":{"recursive":true},"/home":{"recursive":false,"types":["MODIFY"]}}}"#,
490            "\n",
491            r#"{"cmd":"_global","paths":{"/var":{"recursive":true,"size":">1MB"}}}"#,
492            "\n",
493        );
494        let (_dir, path) = temp_path();
495        fs::write(&path, jsonl).unwrap();
496        let store = Monitored::load(&path).unwrap();
497        assert_eq!(store.groups.len(), 2);
498        assert_eq!(store.entry_count(), 3);
499    }
500
501    /// Old format without cmd field should fail to load.
502    #[test]
503    fn test_jsonl_missing_cmd_field_fails() {
504        let jsonl = concat!(r#"{"paths":{"/tmp":{"recursive":true}}}"#, "\n",);
505        let (_dir, path) = temp_path();
506        fs::write(&path, jsonl).unwrap();
507        let result = Monitored::load(&path);
508        assert!(result.is_err(), "missing cmd field should fail");
509    }
510
511    /// Old flat PathEntry format should fail to load.
512    #[test]
513    fn test_jsonl_old_flat_format_fails() {
514        let jsonl = concat!(r#"{"path":"/tmp","recursive":true}"#, "\n",);
515        let (_dir, path) = temp_path();
516        fs::write(&path, jsonl).unwrap();
517        let result = Monitored::load(&path);
518        assert!(result.is_err(), "old flat format should fail");
519    }
520
521    #[test]
522    fn test_entry_count() {
523        let mut store = Monitored::default();
524        assert_eq!(store.entry_count(), 0);
525        store.add_entry(make_entry("/a", None, None));
526        assert_eq!(store.entry_count(), 1);
527        store.add_entry(make_entry("/b", Some("x"), None));
528        assert_eq!(store.entry_count(), 2);
529    }
530
531    #[test]
532    fn test_is_empty() {
533        assert!(Monitored::default().is_empty());
534    }
535
536    #[test]
537    fn test_flatten_no_groups() {
538        assert!(Monitored::default().flatten().is_empty());
539    }
540
541    #[test]
542    fn test_add_with_path_and_cmd_key() {
543        let (_dir, path) = temp_path();
544        let mut store = Monitored::load(&path).unwrap();
545
546        store.add_entry(make_entry("/tmp", Some("bash"), Some(true)));
547        store.add_entry(make_entry("/tmp", Some("nginx"), Some(false)));
548        assert_eq!(store.entry_count(), 2);
549        assert_eq!(store.groups.len(), 2);
550
551        let bash_entry = store.get(Path::new("/tmp"), Some("bash")).unwrap();
552        assert_eq!(bash_entry.recursive, Some(true));
553        let nginx_entry = store.get(Path::new("/tmp"), Some("nginx")).unwrap();
554        assert_eq!(nginx_entry.recursive, Some(false));
555    }
556
557    #[test]
558    fn test_global_group_explicit() {
559        let (_dir, path) = temp_path();
560        let mut store = Monitored::load(&path).unwrap();
561
562        // Adding with explicit _global cmd
563        store.add_entry(make_entry("/x", Some("_global"), Some(true)));
564        // Adding with None cmd — should merge into same group
565        store.add_entry(make_entry("/y", None, Some(false)));
566
567        assert_eq!(store.groups.len(), 1);
568        assert_eq!(store.groups[0].cmd, "_global");
569        assert_eq!(store.entry_count(), 2);
570    }
571
572    #[test]
573    fn test_remove_cmd_group_global() {
574        let (_dir, _path) = temp_path();
575        let mut store = Monitored::default();
576        store.add_entry(make_entry("/a", None, None));
577        store.add_entry(make_entry("/b", Some("x"), None));
578
579        assert!(store.remove_cmd_group(None)); // removes _global
580        assert_eq!(store.entry_count(), 1);
581        assert!(store.get(Path::new("/b"), Some("x")).is_some());
582    }
583
584    #[test]
585    fn test_has_entry() {
586        let mut store = Monitored::default();
587        store.add_entry(make_entry("/a", None, None));
588        store.add_entry(make_entry("/b", Some("x"), None));
589
590        assert!(store.has_entry(Path::new("/a"), None));
591        assert!(store.has_entry(Path::new("/a"), Some("_global")));
592        assert!(store.has_entry(Path::new("/b"), Some("x")));
593        assert!(!store.has_entry(Path::new("/b"), None));
594        assert!(!store.has_entry(Path::new("/x"), None));
595    }
596}