Skip to main content

pq_map/discover/
chain_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::ChainEntry;
9
10use super::cache;
11
12pub const SNAPSHOT_VERSION: u32 = 1;
13
14#[derive(Debug, Serialize, Deserialize)]
15pub struct Snapshot {
16    pub version: u32,
17    pub fetched_at: DateTime<Utc>,
18    pub chains: BTreeMap<u64, Vec<ChainEntry>>,
19}
20
21fn cache_dir() -> PathBuf {
22    if let Ok(dir) = std::env::var("CHAIN_CACHE_DIR") {
23        return PathBuf::from(dir);
24    }
25    let base = std::env::var("XDG_CACHE_HOME")
26        .ok()
27        .filter(|s| !s.is_empty())
28        .map(PathBuf::from)
29        .or_else(|| {
30            std::env::var("HOME")
31                .ok()
32                .map(|h| PathBuf::from(h).join(".cache"))
33        });
34    base.unwrap_or_else(std::env::temp_dir)
35        .join("pq-map")
36        .join("chain")
37}
38
39fn cache_file(domain: &str) -> PathBuf {
40    cache_dir().join(format!("{domain}.json"))
41}
42
43pub fn load(domain: &str) -> Option<Snapshot> {
44    let body = fs::read_to_string(cache_file(domain)).ok()?;
45    let snap: Snapshot = serde_json::from_str(&body).ok()?;
46    (snap.version == SNAPSHOT_VERSION).then_some(snap)
47}
48
49pub fn age(domain: &str) -> Option<u64> {
50    cache::age(&cache_file(domain))
51}
52
53pub fn store(domain: &str, chains: &BTreeMap<u64, Vec<ChainEntry>>) {
54    let snapshot = Snapshot {
55        version: SNAPSHOT_VERSION,
56        fetched_at: Utc::now(),
57        chains: chains.clone(),
58    };
59    let Ok(body) = serde_json::to_string_pretty(&snapshot) else {
60        eprintln!("CT: chain caching failed: serialization error");
61        return;
62    };
63    let file = cache_file(domain);
64    if let Some(dir) = file.parent() {
65        if let Err(e) = fs::create_dir_all(dir) {
66            eprintln!("CT: chain caching failed: {e}");
67            return;
68        }
69    }
70    match cache::store(&file, &body) {
71        Ok(()) => eprintln!("CT: chain snapshot cached: {}", file.display()),
72        Err(e) => eprintln!("CT: chain caching failed: {e}"),
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    fn entry(leaf: u64, depth: u32) -> (u64, ChainEntry) {
81        (
82            leaf,
83            ChainEntry {
84                depth,
85                certificate_id: leaf * 10 + depth as u64,
86                common_name: Some(format!("ca{depth}")),
87                ski: Some("aa".to_string()),
88                aki: None,
89                not_before: None,
90                not_after: None,
91                key_algorithm: Some("RSA".to_string()),
92                key_size: Some(2048),
93                signature_key_algorithm: Some("RSA".to_string()),
94                signature_hash_algorithm: Some("SHA-256".to_string()),
95                issuer_ca_name: None,
96            },
97        )
98    }
99
100    #[test]
101    fn snapshot_roundtrip() {
102        let dir = std::env::temp_dir().join("pq-chain-cache-test");
103        fs::create_dir_all(&dir).unwrap();
104        unsafe { std::env::set_var("CHAIN_CACHE_DIR", &dir) };
105
106        let mut chains = BTreeMap::new();
107        let (leaf, e0) = entry(9612296601, 0);
108        let (_, e1) = entry(9612296601, 1);
109        chains.insert(leaf, vec![e0, e1]);
110
111        store("roundtrip.example.pl", &chains);
112        let loaded = load("roundtrip.example.pl").expect("snapshot loads");
113        let entries = loaded.chains.get(&leaf).unwrap();
114        assert_eq!(entries.len(), 2);
115        assert_eq!(entries[1].depth, 1);
116        assert_eq!(entries[1].common_name.as_deref(), Some("ca1"));
117
118        fs::remove_dir_all(&dir).unwrap();
119        unsafe { std::env::remove_var("CHAIN_CACHE_DIR") };
120    }
121}