Skip to main content

agentsec_core/scan/
snapshot.rs

1//! Snapshot persistence: serialize a [`ScanReport`] to
2//! `<home>/snapshots/<UTC-ts>.json` and load the newest snapshot back.
3//!
4//! ## Filename format
5//!
6//! `%Y-%m-%d-%H%M%S.json` (UTC). Lexicographic order on the filename
7//! matches chronological order, which [`load_latest`] relies on to find
8//! the previous snapshot without a separate index file.
9//!
10//! ## Loading
11//!
12//! [`load_latest`] returns `Ok(None)` when no snapshot directory exists yet
13//! (first-ever run); any JSON parse failure on an existing snapshot is
14//! surfaced as [`Error::Scan`].
15
16use crate::Paths;
17use crate::error::{Error, Result};
18use crate::scan::ScanReport;
19use std::fs;
20use std::path::{Path, PathBuf};
21
22/// Persist a [`ScanReport`] under `<paths.home>/snapshots/<UTC-ts>.json`
23/// and return the path written.
24///
25/// # Errors
26///
27/// Returns [`crate::Error::Io`] if the snapshot dir cannot be created or
28/// written, or [`crate::Error::Json`] on serialization failure.
29pub fn save(paths: &Paths, report: &ScanReport) -> Result<PathBuf> {
30    let dir = paths.snapshots();
31    fs::create_dir_all(&dir)?;
32    let stamp = report.scanned_at.format("%Y-%m-%d-%H%M%S").to_string();
33    let path = dir.join(format!("{stamp}.json"));
34    let body = serde_json::to_string_pretty(report)?;
35    fs::write(&path, body)?;
36    Ok(path)
37}
38
39/// Load the newest snapshot (lexicographic = chronological order for the
40/// stamp format). Returns `Ok(None)` when the directory does not yet exist
41/// (first-ever scan).
42pub fn load_latest(paths: &Paths) -> Result<Option<ScanReport>> {
43    let dir = paths.snapshots();
44    if !dir.exists() {
45        return Ok(None);
46    }
47    let mut newest: Option<PathBuf> = None;
48    for entry in fs::read_dir(&dir)? {
49        let entry = entry?;
50        let p = entry.path();
51        if p.extension().and_then(|s| s.to_str()) != Some("json") {
52            continue;
53        }
54        newest = match newest {
55            Some(cur) if cur >= p => Some(cur),
56            _ => Some(p),
57        };
58    }
59    match newest {
60        Some(p) => Ok(Some(load(&p)?)),
61        None => Ok(None),
62    }
63}
64
65fn load(path: &Path) -> Result<ScanReport> {
66    let body = fs::read_to_string(path)?;
67    let report: ScanReport =
68        serde_json::from_str(&body).map_err(|e| Error::Scan(format!("snapshot parse: {e}")))?;
69    Ok(report)
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75    use crate::scan::inventory::PathEntry;
76
77    fn sample() -> ScanReport {
78        ScanReport {
79            scanned_at: chrono::Utc::now(),
80            paths: vec![PathEntry {
81                path: "/tmp/x".into(),
82                category: "test".into(),
83                sha256: "0".repeat(64),
84                size: 1,
85            }],
86        }
87    }
88
89    fn paths_for(tmp: &tempfile::TempDir) -> Paths {
90        Paths {
91            home: tmp.path().to_path_buf(),
92            user_home: tmp.path().to_path_buf(),
93        }
94    }
95
96    #[test]
97    fn save_then_load_roundtrip() {
98        // No env writes here: we build a Paths literal pointing at the
99        // tempdir and pass it down. Parallel-test-safe.
100        let tmp = tempfile::tempdir().unwrap();
101        let paths = paths_for(&tmp);
102        let report = sample();
103        let written = save(&paths, &report).unwrap();
104        assert!(written.exists());
105        let loaded = load_latest(&paths).unwrap().unwrap();
106        assert_eq!(loaded.paths, report.paths);
107    }
108
109    #[test]
110    fn load_latest_returns_none_when_dir_absent() {
111        let tmp = tempfile::tempdir().unwrap();
112        // Don't pre-create snapshots/ — the function must treat the
113        // absence as "no baseline yet" rather than erroring.
114        let paths = paths_for(&tmp);
115        assert!(load_latest(&paths).unwrap().is_none());
116    }
117}