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
10pub const CMD_GLOBAL: &str = "_global";
12
13#[derive(Debug, Clone, Serialize, Deserialize, Default)]
25pub struct Monitored {
26 pub groups: Vec<CmdGroup>,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct CmdGroup {
33 pub cmd: String,
35 pub paths: BTreeMap<PathBuf, PathParams>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct PathParams {
42 pub recursive: Option<bool>,
44 #[serde(skip_serializing_if = "Option::is_none")]
46 pub types: Option<Vec<String>>,
47 #[serde(skip_serializing_if = "Option::is_none")]
49 pub size: Option<String>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct PathEntry {
57 pub cmd: Option<String>,
60 pub path: PathBuf,
62 pub recursive: Option<bool>,
64 pub types: Option<Vec<String>>,
66 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 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 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 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 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 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 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 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 pub fn is_empty(&self) -> bool {
219 self.groups.is_empty() || self.groups.iter().all(|g| g.paths.is_empty())
220 }
221
222 pub fn entry_count(&self) -> usize {
224 self.groups.iter().map(|g| g.paths.len()).sum()
225 }
226
227 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 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 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 #[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 #[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 store.add_entry(make_entry("/x", Some("_global"), Some(true)));
564 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)); 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}