agentsec-core 0.1.0

AgentSec core library — scan / web / paste logic, pure Rust
Documentation
//! Snapshot persistence: serialize a [`ScanReport`] to
//! `<home>/snapshots/<UTC-ts>.json` and load the newest snapshot back.
//!
//! ## Filename format
//!
//! `%Y-%m-%d-%H%M%S.json` (UTC). Lexicographic order on the filename
//! matches chronological order, which [`load_latest`] relies on to find
//! the previous snapshot without a separate index file.
//!
//! ## Loading
//!
//! [`load_latest`] returns `Ok(None)` when no snapshot directory exists yet
//! (first-ever run); any JSON parse failure on an existing snapshot is
//! surfaced as [`Error::Scan`].

use crate::Paths;
use crate::error::{Error, Result};
use crate::scan::ScanReport;
use std::fs;
use std::path::{Path, PathBuf};

/// Persist a [`ScanReport`] under `<paths.home>/snapshots/<UTC-ts>.json`
/// and return the path written.
///
/// # Errors
///
/// Returns [`crate::Error::Io`] if the snapshot dir cannot be created or
/// written, or [`crate::Error::Json`] on serialization failure.
pub fn save(paths: &Paths, report: &ScanReport) -> Result<PathBuf> {
    let dir = paths.snapshots();
    fs::create_dir_all(&dir)?;
    let stamp = report.scanned_at.format("%Y-%m-%d-%H%M%S").to_string();
    let path = dir.join(format!("{stamp}.json"));
    let body = serde_json::to_string_pretty(report)?;
    fs::write(&path, body)?;
    Ok(path)
}

/// Load the newest snapshot (lexicographic = chronological order for the
/// stamp format). Returns `Ok(None)` when the directory does not yet exist
/// (first-ever scan).
pub fn load_latest(paths: &Paths) -> Result<Option<ScanReport>> {
    let dir = paths.snapshots();
    if !dir.exists() {
        return Ok(None);
    }
    let mut newest: Option<PathBuf> = None;
    for entry in fs::read_dir(&dir)? {
        let entry = entry?;
        let p = entry.path();
        if p.extension().and_then(|s| s.to_str()) != Some("json") {
            continue;
        }
        newest = match newest {
            Some(cur) if cur >= p => Some(cur),
            _ => Some(p),
        };
    }
    match newest {
        Some(p) => Ok(Some(load(&p)?)),
        None => Ok(None),
    }
}

fn load(path: &Path) -> Result<ScanReport> {
    let body = fs::read_to_string(path)?;
    let report: ScanReport =
        serde_json::from_str(&body).map_err(|e| Error::Scan(format!("snapshot parse: {e}")))?;
    Ok(report)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::scan::inventory::PathEntry;

    fn sample() -> ScanReport {
        ScanReport {
            scanned_at: chrono::Utc::now(),
            paths: vec![PathEntry {
                path: "/tmp/x".into(),
                category: "test".into(),
                sha256: "0".repeat(64),
                size: 1,
            }],
        }
    }

    fn paths_for(tmp: &tempfile::TempDir) -> Paths {
        Paths {
            home: tmp.path().to_path_buf(),
            user_home: tmp.path().to_path_buf(),
        }
    }

    #[test]
    fn save_then_load_roundtrip() {
        // No env writes here: we build a Paths literal pointing at the
        // tempdir and pass it down. Parallel-test-safe.
        let tmp = tempfile::tempdir().unwrap();
        let paths = paths_for(&tmp);
        let report = sample();
        let written = save(&paths, &report).unwrap();
        assert!(written.exists());
        let loaded = load_latest(&paths).unwrap().unwrap();
        assert_eq!(loaded.paths, report.paths);
    }

    #[test]
    fn load_latest_returns_none_when_dir_absent() {
        let tmp = tempfile::tempdir().unwrap();
        // Don't pre-create snapshots/ — the function must treat the
        // absence as "no baseline yet" rather than erroring.
        let paths = paths_for(&tmp);
        assert!(load_latest(&paths).unwrap().is_none());
    }
}