Skip to main content

pq_map/discover/
probe_cache.rs

1use std::collections::BTreeMap;
2use std::fs;
3use std::path::PathBuf;
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8use crate::model::{Endpoint, PortScan};
9
10use super::cache;
11
12pub const SNAPSHOT_VERSION: u32 = 2;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Snapshot {
16    pub version: u32,
17    pub fetched_at: DateTime<Utc>,
18    pub probes: BTreeMap<String, Endpoint>,
19    #[serde(default)]
20    pub ports: BTreeMap<String, BTreeMap<u16, PortScan>>,
21}
22
23fn cache_dir() -> PathBuf {
24    if let Ok(dir) = std::env::var("PROBE_CACHE_DIR") {
25        return PathBuf::from(dir);
26    }
27    let base = std::env::var("XDG_CACHE_HOME")
28        .ok()
29        .filter(|s| !s.is_empty())
30        .map(PathBuf::from)
31        .or_else(|| {
32            std::env::var("HOME")
33                .ok()
34                .map(|h| PathBuf::from(h).join(".cache"))
35        });
36    base.unwrap_or_else(std::env::temp_dir)
37        .join("pq-map")
38        .join("probe")
39}
40
41fn cache_file(domain: &str) -> PathBuf {
42    cache_dir().join(format!("{domain}.json"))
43}
44
45pub fn load(domain: &str) -> Option<Snapshot> {
46    let body = fs::read_to_string(cache_file(domain)).ok()?;
47    let snap: Snapshot = serde_json::from_str(&body).ok()?;
48    (snap.version == SNAPSHOT_VERSION).then_some(snap)
49}
50
51pub fn store(
52    domain: &str,
53    probes: &BTreeMap<String, Endpoint>,
54    ports: &BTreeMap<String, BTreeMap<u16, PortScan>>,
55) {
56    let snapshot = Snapshot {
57        version: SNAPSHOT_VERSION,
58        fetched_at: Utc::now(),
59        probes: probes.clone(),
60        ports: ports.clone(),
61    };
62    let Ok(body) = serde_json::to_string_pretty(&snapshot) else {
63        eprintln!("probes: caching failed: serialization error");
64        return;
65    };
66    let file = cache_file(domain);
67    if let Some(dir) = file.parent() {
68        if let Err(e) = fs::create_dir_all(dir) {
69            eprintln!("probes: caching failed: {e}");
70            return;
71        }
72    }
73    match cache::store(&file, &body) {
74        Ok(()) => eprintln!("probes: snapshot cached: {}", file.display()),
75        Err(e) => eprintln!("probes: caching failed: {e}"),
76    }
77}
78
79pub fn is_fresh(endpoint: &Endpoint, ttl: u64, now: DateTime<Utc>) -> bool {
80    endpoint
81        .observed_at
82        .map(|t| ((now - t).num_seconds().max(0) as u64) <= ttl)
83        .unwrap_or(false)
84}
85
86pub fn scan_fresh(scan: &PortScan, ttl: u64, now: DateTime<Utc>) -> bool {
87    is_fresh_time(scan.observed_at, ttl, now)
88}
89
90fn is_fresh_time(t: Option<DateTime<Utc>>, ttl: u64, now: DateTime<Utc>) -> bool {
91    t.map(|t| ((now - t).num_seconds().max(0) as u64) <= ttl)
92        .unwrap_or(false)
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use crate::model::ProbeStatus;
99
100    fn timeout_endpoint() -> Endpoint {
101        let mut ep = Endpoint::failed(ProbeStatus::Timeout { secs: 8 });
102        ep.observed_at = Some(Utc::now());
103        ep
104    }
105
106    #[test]
107    fn snapshot_roundtrip_keeps_failure_endpoints() {
108        let dir = std::env::temp_dir().join("pq-scope-probe-cache-test");
109        fs::create_dir_all(&dir).unwrap();
110        unsafe { std::env::set_var("PROBE_CACHE_DIR", &dir) };
111
112        let mut probes = BTreeMap::new();
113        probes.insert("vpn.nbp.pl".to_string(), timeout_endpoint());
114
115        let mut ports = BTreeMap::new();
116        let mut scans = BTreeMap::new();
117        scans.insert(
118            465u16,
119            crate::model::PortScan {
120                port: 465,
121                state: crate::model::ScanState::Open,
122                served: None,
123                negotiated: None,
124                observed_at: Some(Utc::now()),
125            },
126        );
127        ports.insert("mx1.nbp.pl".to_string(), scans);
128
129        store("roundtrip.nbp.pl", &probes, &ports);
130        let loaded = load("roundtrip.nbp.pl").expect("snapshot loads");
131        let ep = loaded.probes.get("vpn.nbp.pl").unwrap();
132        assert_eq!(ep.status, ProbeStatus::Timeout { secs: 8 });
133        assert!(is_fresh(ep, 86_400, Utc::now()));
134        let scans = loaded.ports.get("mx1.nbp.pl").unwrap();
135        let scan = scans.get(&465).unwrap();
136        assert_eq!(scan.state, crate::model::ScanState::Open);
137        assert!(scan_fresh(scan, 86_400, Utc::now()));
138
139        fs::remove_dir_all(&dir).unwrap();
140        unsafe { std::env::remove_var("PROBE_CACHE_DIR") };
141    }
142
143    #[test]
144    fn is_fresh_respects_ttl_and_missing_time() {
145        let now = Utc::now();
146        let mut ep = timeout_endpoint();
147        ep.observed_at = Some(now - chrono::Duration::hours(23));
148        assert!(is_fresh(&ep, 86_400, now));
149
150        ep.observed_at = Some(now - chrono::Duration::hours(25));
151        assert!(!is_fresh(&ep, 86_400, now));
152
153        ep.observed_at = None;
154        assert!(
155            !is_fresh(&ep, 86_400, now),
156            "synthesized endpoints never reusable"
157        );
158    }
159}