Skip to main content

pq_map/discover/
cache.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::time::SystemTime;
4
5use serde::{Deserialize, Serialize};
6
7use crate::model::Certificate;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Snapshot {
11    pub version: u32,
12    pub source: String,
13    pub fetched_at: String,
14    pub certs: Vec<Certificate>,
15}
16
17pub const SNAPSHOT_VERSION: u32 = 2;
18
19pub fn cache_dir() -> PathBuf {
20    if let Ok(dir) = std::env::var("CT_CACHE_DIR") {
21        return PathBuf::from(dir);
22    }
23    let base = std::env::var("XDG_CACHE_HOME")
24        .ok()
25        .filter(|s| !s.is_empty())
26        .map(PathBuf::from)
27        .or_else(|| {
28            std::env::var("HOME")
29                .ok()
30                .map(|h| PathBuf::from(h).join(".cache"))
31        });
32    base.unwrap_or_else(std::env::temp_dir)
33        .join("pq-map")
34        .join("ct")
35}
36
37pub fn cache_file(domain: &str) -> PathBuf {
38    cache_dir().join(format!("{domain}.json"))
39}
40
41pub fn age(path: &Path) -> Option<u64> {
42    let mtime = fs::metadata(path).ok()?.modified().ok()?;
43    SystemTime::now()
44        .duration_since(mtime)
45        .ok()
46        .map(|d| d.as_secs())
47}
48
49pub fn is_valid(path: &Path) -> bool {
50    load_snapshot(path).is_some()
51}
52
53pub fn is_fresh(path: &Path, ttl: u64) -> bool {
54    match age(path) {
55        Some(a) if a < ttl => is_valid(path),
56        _ => false,
57    }
58}
59
60pub fn load(path: &Path) -> std::io::Result<String> {
61    fs::read_to_string(path)
62}
63
64pub fn load_snapshot(path: &Path) -> Option<Snapshot> {
65    let body = fs::read_to_string(path).ok()?;
66    let snap: Snapshot = serde_json::from_str(&body).ok()?;
67    (snap.version == SNAPSHOT_VERSION).then_some(snap)
68}
69
70pub fn store_snapshot(path: &Path, snapshot: &Snapshot) -> std::io::Result<()> {
71    let body = serde_json::to_string_pretty(snapshot)
72        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
73    store(path, &body)
74}
75
76pub fn store(path: &Path, body: &str) -> std::io::Result<()> {
77    if let Some(dir) = path.parent() {
78        fs::create_dir_all(dir)?;
79    }
80    let tmp = path.with_extension(format!("tmp.{}", std::process::id()));
81    fs::write(&tmp, body)?;
82    fs::rename(&tmp, path)
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use std::fs::File;
89    use std::time::{Duration, UNIX_EPOCH};
90
91    fn snap_body() -> String {
92        serde_json::to_string(&Snapshot {
93            version: SNAPSHOT_VERSION,
94            source: "db".to_string(),
95            fetched_at: "2026-09-22T00:00:00Z".to_string(),
96            certs: Vec::new(),
97        })
98        .unwrap()
99    }
100
101    fn write(path: &Path, body: &str, mtime: SystemTime) {
102        fs::write(path, body).unwrap();
103        let f = File::options().write(true).open(path).unwrap();
104        f.set_times(
105            std::fs::FileTimes::new()
106                .set_accessed(mtime)
107                .set_modified(mtime),
108        )
109        .unwrap();
110    }
111
112    #[test]
113    fn freshness_follows_ttl_and_validity() {
114        let dir = std::env::temp_dir().join("pq-scope-cache-test");
115        fs::create_dir_all(&dir).unwrap();
116        let file = dir.join("fresh.example.com.json");
117
118        write(&file, &snap_body(), SystemTime::now());
119        assert!(is_valid(&file));
120        assert!(is_fresh(&file, 60));
121
122        write(&file, &snap_body(), UNIX_EPOCH);
123        assert!(is_valid(&file));
124        assert!(!is_fresh(&file, 60));
125
126        write(&file, "not json", SystemTime::now());
127        assert!(!is_valid(&file));
128        assert!(!is_fresh(&file, 60));
129
130        write(&file, "[]", SystemTime::now());
131        assert!(!is_valid(&file));
132
133        fs::remove_file(&file).unwrap();
134
135        let empty = dir.join("empty.example.com.json");
136        write(&empty, "", SystemTime::now());
137        assert!(!is_valid(&empty));
138        fs::remove_file(&empty).unwrap();
139    }
140
141    #[test]
142    fn snapshot_roundtrip_preserves_facts() {
143        let dir = std::env::temp_dir().join("pq-scope-cache-snap-test");
144        fs::create_dir_all(&dir).unwrap();
145        let file = dir.join("roundtrip.example.com.json");
146        let mut cert = crate::model::Certificate {
147            serial: "aa11".to_string(),
148            in_ct: true,
149            served_live: false,
150            currently_valid: true,
151            ct_ids: vec![7],
152            common_name: Some("www.example.com".to_string()),
153            identities: vec!["example.com".to_string(), "www.example.com".to_string()],
154            not_before: None,
155            not_after: None,
156            pubkey_alg: None,
157            sig_alg: None,
158            issuer: Some("CN=Test CA".to_string()),
159            key_algorithm: Some("RSA".to_string()),
160            key_size: Some(2048),
161            sig_key_algorithm: Some("RSA".to_string()),
162            sig_hash_algorithm: Some("SHA-256".to_string()),
163            cert_signature: Some("RSA-SHA256".to_string()),
164            chain: Vec::new(),
165        };
166        cert.not_before = Some(chrono::Utc::now() - chrono::Duration::days(1));
167        cert.not_after = Some(chrono::Utc::now() + chrono::Duration::days(1));
168        let snapshot = Snapshot {
169            version: SNAPSHOT_VERSION,
170            source: "db".to_string(),
171            fetched_at: "2026-09-22T00:00:00Z".to_string(),
172            certs: vec![cert],
173        };
174        store_snapshot(&file, &snapshot).unwrap();
175        let loaded = load_snapshot(&file).unwrap();
176        assert_eq!(loaded.source, "db");
177        assert_eq!(
178            loaded.certs[0].cert_signature.as_deref(),
179            Some("RSA-SHA256")
180        );
181        assert_eq!(loaded.certs[0].ct_ids, vec![7]);
182        fs::remove_file(&file).unwrap();
183    }
184
185    #[test]
186    fn store_is_atomic_rename() {
187        let dir = std::env::temp_dir().join("pq-scope-cache-store-test");
188        fs::create_dir_all(&dir).unwrap();
189        let file = dir.join("example.com.json");
190        store(&file, "[]").unwrap();
191        assert_eq!(fs::read_to_string(&file).unwrap(), "[]");
192        let leftovers: Vec<_> = fs::read_dir(&dir)
193            .unwrap()
194            .filter_map(|e| e.ok())
195            .filter(|e| e.file_name().to_string_lossy().contains("tmp."))
196            .collect();
197        assert!(leftovers.is_empty());
198        fs::remove_file(&file).unwrap();
199    }
200
201    #[test]
202    fn age_of_missing_file_is_none() {
203        assert!(age(Path::new("/nonexistent/x.json")).is_none());
204    }
205
206    #[test]
207    fn age_rejects_future_mtime() {
208        let dir = std::env::temp_dir().join("pq-scope-cache-age-test");
209        fs::create_dir_all(&dir).unwrap();
210        let file = dir.join("future.example.com.json");
211        let future = SystemTime::now()
212            .checked_add(Duration::from_secs(40 * 365 * 86400))
213            .unwrap();
214        write(&file, "[]", future);
215        assert!(age(&file).is_none());
216        fs::remove_file(&file).unwrap();
217    }
218}