pq_map/discover/
chain_cache.rs1use 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
39pub fn load(domain: &str) -> Option<Snapshot> {
40 cache::migrate_legacy_file(&cache_dir(), domain);
41 let file = cache::latest_file(&cache_dir(), domain)?;
42 let body = fs::read_to_string(&file).ok()?;
43 let snap: Snapshot = serde_json::from_str(&body).ok()?;
44 if snap.version != SNAPSHOT_VERSION {
45 return None;
46 }
47 match cache::age(&file) {
48 Some(age) => eprintln!(
49 "CT: loading chain snapshot {} ({domain}, {} min old)",
50 file.display(),
51 age / 60
52 ),
53 None => eprintln!("CT: loading chain snapshot {} ({domain})", file.display()),
54 }
55 Some(snap)
56}
57
58pub fn age(domain: &str) -> Option<u64> {
59 cache::age(&cache::latest_file(&cache_dir(), domain)?)
60}
61
62pub fn store(domain: &str, chains: &BTreeMap<u64, Vec<ChainEntry>>) {
63 let snapshot = Snapshot {
64 version: SNAPSHOT_VERSION,
65 fetched_at: Utc::now(),
66 chains: chains.clone(),
67 };
68 let Ok(body) = serde_json::to_string_pretty(&snapshot) else {
69 eprintln!("CT: chain caching failed: serialization error");
70 return;
71 };
72 cache::migrate_legacy_file(&cache_dir(), domain);
73 match cache::domain_store(&cache_dir(), domain, &body) {
74 Ok(path) => eprintln!("CT: chain snapshot cached: {}", path.display()),
75 Err(e) => eprintln!("CT: chain caching failed: {e}"),
76 }
77}
78
79#[cfg(test)]
80mod tests {
81 use super::*;
82
83 fn entry(leaf: u64, depth: u32) -> (u64, ChainEntry) {
84 (
85 leaf,
86 ChainEntry {
87 depth,
88 certificate_id: leaf * 10 + depth as u64,
89 common_name: Some(format!("ca{depth}")),
90 ski: Some("aa".to_string()),
91 aki: None,
92 not_before: None,
93 not_after: None,
94 key_algorithm: Some("RSA".to_string()),
95 key_size: Some(2048),
96 signature_key_algorithm: Some("RSA".to_string()),
97 signature_hash_algorithm: Some("SHA-256".to_string()),
98 issuer_ca_name: None,
99 },
100 )
101 }
102
103 #[test]
104 fn snapshot_roundtrip() {
105 let dir = std::env::temp_dir().join("pq-chain-cache-test");
106 fs::create_dir_all(&dir).unwrap();
107 unsafe { std::env::set_var("CHAIN_CACHE_DIR", &dir) };
108
109 let mut chains = BTreeMap::new();
110 let (leaf, e0) = entry(9612296601, 0);
111 let (_, e1) = entry(9612296601, 1);
112 chains.insert(leaf, vec![e0, e1]);
113
114 store("roundtrip.example.pl", &chains);
115 let loaded = load("roundtrip.example.pl").expect("snapshot loads");
116 let entries = loaded.chains.get(&leaf).unwrap();
117 assert_eq!(entries.len(), 2);
118 assert_eq!(entries[1].depth, 1);
119 assert_eq!(entries[1].common_name.as_deref(), Some("ca1"));
120
121 fs::remove_dir_all(&dir).unwrap();
122 unsafe { std::env::remove_var("CHAIN_CACHE_DIR") };
123 }
124}