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
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}