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}