1use std::fs;
2use std::path::{Path, PathBuf};
3use std::time::SystemTime;
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8use crate::model::Certificate;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Snapshot {
12 pub version: u32,
13 pub source: String,
14 pub fetched_at: String,
15 pub certs: Vec<Certificate>,
16}
17
18pub const SNAPSHOT_VERSION: u32 = 2;
19
20pub fn cache_dir() -> PathBuf {
21 if let Ok(dir) = std::env::var("CT_CACHE_DIR") {
22 return PathBuf::from(dir);
23 }
24 let base = std::env::var("XDG_CACHE_HOME")
25 .ok()
26 .filter(|s| !s.is_empty())
27 .map(PathBuf::from)
28 .or_else(|| {
29 std::env::var("HOME")
30 .ok()
31 .map(|h| PathBuf::from(h).join(".cache"))
32 });
33 base.unwrap_or_else(std::env::temp_dir)
34 .join("pq-map")
35 .join("ct")
36}
37
38pub fn timestamp_of(t: SystemTime) -> String {
41 let dt: DateTime<Utc> = t.into();
42 format!(
43 "{}.{:09}Z",
44 dt.format("%Y%m%dT%H%M%S"),
45 dt.timestamp_subsec_nanos()
46 )
47}
48
49pub fn timestamp_now() -> String {
50 timestamp_of(SystemTime::now())
51}
52
53fn is_timestamp_name(stem: &str) -> bool {
55 let b = stem.as_bytes();
56 b.len() == 26
57 && b[8] == b'T'
58 && b[15] == b'.'
59 && b[25] == b'Z'
60 && b.iter()
61 .enumerate()
62 .all(|(i, c)| matches!(i, 8 | 15 | 25) || c.is_ascii_digit())
63}
64
65pub fn latest_file(base: &Path, domain: &str) -> Option<PathBuf> {
70 let mut best: Option<(String, PathBuf)> = None;
71 for entry in fs::read_dir(base.join(domain)).ok()?.flatten() {
72 let path = entry.path();
73 if path.extension().and_then(|e| e.to_str()) != Some("json") || !path.is_file() {
74 continue;
75 }
76 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
77 continue;
78 };
79 if !is_timestamp_name(stem) {
80 continue;
81 }
82 if best
83 .as_ref()
84 .is_none_or(|(newest, _)| stem > newest.as_str())
85 {
86 best = Some((stem.to_string(), path));
87 }
88 }
89 best.map(|(_, path)| path)
90}
91
92pub fn migrate_legacy_file(base: &Path, domain: &str) {
97 let legacy = base.join(format!("{domain}.json"));
98 if !legacy.exists() {
99 return;
100 }
101 let ts = fs::metadata(&legacy)
102 .and_then(|m| m.modified())
103 .map(timestamp_of)
104 .unwrap_or_else(|_| timestamp_now());
105 let dir = base.join(domain);
106 if let Err(e) = fs::create_dir_all(&dir) {
107 eprintln!("cache: migration failed: {e}");
108 return;
109 }
110 let target = dir.join(format!("{ts}.json"));
111 if target.exists() {
112 eprintln!(
113 "cache: leaving legacy snapshot in place: {}",
114 legacy.display()
115 );
116 return;
117 }
118 match fs::rename(&legacy, &target) {
119 Ok(()) => eprintln!("cache: migrated snapshot to {}", target.display()),
120 Err(e) => eprintln!("cache: migration failed for {}: {e}", legacy.display()),
121 }
122}
123
124pub fn domain_store(base: &Path, domain: &str, body: &str) -> std::io::Result<PathBuf> {
126 let dir = base.join(domain);
127 fs::create_dir_all(&dir)?;
128 let path = dir.join(format!("{}.json", timestamp_now()));
129 store(&path, body)?;
130 Ok(path)
131}
132
133pub fn age(path: &Path) -> Option<u64> {
134 let mtime = fs::metadata(path).ok()?.modified().ok()?;
135 SystemTime::now()
136 .duration_since(mtime)
137 .ok()
138 .map(|d| d.as_secs())
139}
140
141pub fn is_valid(path: &Path) -> bool {
142 load_snapshot(path).is_some()
143}
144
145pub fn is_fresh(path: &Path, ttl: u64) -> bool {
146 match age(path) {
147 Some(a) if a < ttl => is_valid(path),
148 _ => false,
149 }
150}
151
152pub fn load(path: &Path) -> std::io::Result<String> {
153 fs::read_to_string(path)
154}
155
156pub fn load_snapshot(path: &Path) -> Option<Snapshot> {
157 let body = fs::read_to_string(path).ok()?;
158 let snap: Snapshot = serde_json::from_str(&body).ok()?;
159 (snap.version == SNAPSHOT_VERSION).then_some(snap)
160}
161
162pub fn store_snapshot_for(domain: &str, snapshot: &Snapshot) -> std::io::Result<PathBuf> {
165 migrate_legacy_file(&cache_dir(), domain);
166 let body = serde_json::to_string_pretty(snapshot)
167 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
168 domain_store(&cache_dir(), domain, &body)
169}
170
171pub fn store(path: &Path, body: &str) -> std::io::Result<()> {
172 if let Some(dir) = path.parent() {
173 fs::create_dir_all(dir)?;
174 }
175 let tmp = path.with_extension(format!("tmp.{}", std::process::id()));
176 fs::write(&tmp, body)?;
177 fs::rename(&tmp, path)
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183 use std::fs::File;
184 use std::time::{Duration, UNIX_EPOCH};
185
186 fn snap_body() -> String {
187 serde_json::to_string(&Snapshot {
188 version: SNAPSHOT_VERSION,
189 source: "db".to_string(),
190 fetched_at: "2026-09-22T00:00:00Z".to_string(),
191 certs: Vec::new(),
192 })
193 .unwrap()
194 }
195
196 fn write(path: &Path, body: &str, mtime: SystemTime) {
197 fs::write(path, body).unwrap();
198 let f = File::options().write(true).open(path).unwrap();
199 f.set_times(
200 std::fs::FileTimes::new()
201 .set_accessed(mtime)
202 .set_modified(mtime),
203 )
204 .unwrap();
205 }
206
207 #[test]
208 fn freshness_follows_ttl_and_validity() {
209 let dir = std::env::temp_dir().join("pq-scope-cache-test");
210 fs::create_dir_all(&dir).unwrap();
211 let file = dir.join("fresh.example.com.json");
212
213 write(&file, &snap_body(), SystemTime::now());
214 assert!(is_valid(&file));
215 assert!(is_fresh(&file, 60));
216
217 write(&file, &snap_body(), UNIX_EPOCH);
218 assert!(is_valid(&file));
219 assert!(!is_fresh(&file, 60));
220
221 write(&file, "not json", SystemTime::now());
222 assert!(!is_valid(&file));
223 assert!(!is_fresh(&file, 60));
224
225 write(&file, "[]", SystemTime::now());
226 assert!(!is_valid(&file));
227
228 fs::remove_file(&file).unwrap();
229
230 let empty = dir.join("empty.example.com.json");
231 write(&empty, "", SystemTime::now());
232 assert!(!is_valid(&empty));
233 fs::remove_file(&empty).unwrap();
234 }
235
236 #[test]
237 fn snapshot_roundtrip_preserves_facts() {
238 let dir = std::env::temp_dir().join("pq-scope-cache-snap-test");
239 fs::create_dir_all(&dir).unwrap();
240 unsafe { std::env::set_var("CT_CACHE_DIR", &dir) };
241 let mut cert = crate::model::Certificate {
242 serial: "aa11".to_string(),
243 in_ct: true,
244 served_live: false,
245 currently_valid: true,
246 ct_ids: vec![7],
247 common_name: Some("www.example.com".to_string()),
248 identities: vec!["example.com".to_string(), "www.example.com".to_string()],
249 not_before: None,
250 not_after: None,
251 pubkey_alg: None,
252 sig_alg: None,
253 issuer: Some("CN=Test CA".to_string()),
254 key_algorithm: Some("RSA".to_string()),
255 key_size: Some(2048),
256 sig_key_algorithm: Some("RSA".to_string()),
257 sig_hash_algorithm: Some("SHA-256".to_string()),
258 cert_signature: Some("RSA-SHA256".to_string()),
259 chain: Vec::new(),
260 };
261 cert.not_before = Some(chrono::Utc::now() - chrono::Duration::days(1));
262 cert.not_after = Some(chrono::Utc::now() + chrono::Duration::days(1));
263 let snapshot = Snapshot {
264 version: SNAPSHOT_VERSION,
265 source: "db".to_string(),
266 fetched_at: "2026-09-22T00:00:00Z".to_string(),
267 certs: vec![cert],
268 };
269 let path = store_snapshot_for("roundtrip.example.com", &snapshot).unwrap();
270 let loaded = load_snapshot(&path).unwrap();
271 assert_eq!(loaded.source, "db");
272 assert_eq!(
273 loaded.certs[0].cert_signature.as_deref(),
274 Some("RSA-SHA256")
275 );
276 assert_eq!(loaded.certs[0].ct_ids, vec![7]);
277 fs::remove_dir_all(&dir).unwrap();
278 unsafe { std::env::remove_var("CT_CACHE_DIR") };
279 }
280
281 #[test]
282 fn latest_file_prefers_newest_timestamp() {
283 let dir = std::env::temp_dir().join("pq-scope-cache-latest-test");
284 let domain = "latest.example.com";
285 let dom_dir = dir.join(domain);
286 fs::create_dir_all(&dom_dir).unwrap();
287 fs::write(dom_dir.join("20260101T000000.000000000Z.json"), "old").unwrap();
288 fs::write(dom_dir.join("20260102T000000.000000000Z.json"), "new").unwrap();
289 fs::write(
291 dom_dir.join("20260103T000000.000000000Z.json.tmp.42"),
292 "junk",
293 )
294 .unwrap();
295 fs::write(dom_dir.join("notes.txt"), "x").unwrap();
296 let latest = latest_file(&dir, domain).unwrap();
297 assert_eq!(
298 latest.file_name().unwrap(),
299 "20260102T000000.000000000Z.json"
300 );
301 fs::remove_dir_all(&dir).unwrap();
302 }
303
304 #[test]
305 fn migrate_legacy_moves_flat_file_into_domain_dir() {
306 let dir = std::env::temp_dir().join("pq-scope-cache-migrate-test");
307 fs::create_dir_all(&dir).unwrap();
308 let domain = "migrate.example.com";
309 let legacy = dir.join(format!("{domain}.json"));
310 fs::write(&legacy, "{}").unwrap();
311 let old = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
312 let f = File::options().write(true).open(&legacy).unwrap();
313 f.set_times(
314 std::fs::FileTimes::new()
315 .set_accessed(old)
316 .set_modified(old),
317 )
318 .unwrap();
319
320 migrate_legacy_file(&dir, domain);
321 assert!(!legacy.exists(), "legacy file moved, not copied");
322 let target = latest_file(&dir, domain).expect("migrated snapshot found");
323 assert_eq!(fs::read_to_string(&target).unwrap(), "{}");
324 assert!(age(&target).unwrap() > 10);
326
327 migrate_legacy_file(&dir, domain);
329 assert_eq!(
330 latest_file(&dir, domain).unwrap(),
331 target,
332 "no new snapshot created"
333 );
334 fs::remove_dir_all(&dir).unwrap();
335 }
336
337 #[test]
338 fn store_is_atomic_rename() {
339 let dir = std::env::temp_dir().join("pq-scope-cache-store-test");
340 fs::create_dir_all(&dir).unwrap();
341 let file = dir.join("example.com.json");
342 store(&file, "[]").unwrap();
343 assert_eq!(fs::read_to_string(&file).unwrap(), "[]");
344 let leftovers: Vec<_> = fs::read_dir(&dir)
345 .unwrap()
346 .filter_map(|e| e.ok())
347 .filter(|e| e.file_name().to_string_lossy().contains("tmp."))
348 .collect();
349 assert!(leftovers.is_empty());
350 fs::remove_file(&file).unwrap();
351 }
352
353 #[test]
354 fn age_of_missing_file_is_none() {
355 assert!(age(Path::new("/nonexistent/x.json")).is_none());
356 }
357
358 #[test]
359 fn age_rejects_future_mtime() {
360 let dir = std::env::temp_dir().join("pq-scope-cache-age-test");
361 fs::create_dir_all(&dir).unwrap();
362 let file = dir.join("future.example.com.json");
363 let future = SystemTime::now()
364 .checked_add(Duration::from_secs(40 * 365 * 86400))
365 .unwrap();
366 write(&file, "[]", future);
367 assert!(age(&file).is_none());
368 fs::remove_file(&file).unwrap();
369 }
370}