1use std::collections::{BTreeMap, BTreeSet};
27use std::path::PathBuf;
28
29use chrono::{DateTime, Utc};
30use serde::{Deserialize, Serialize};
31
32use crate::error::{Result, SeerError};
33
34const MAX_DOMAINS: usize = 1000;
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct SubdomainBaseline {
43 pub recorded_at: DateTime<Utc>,
45 #[serde(default)]
47 pub source: String,
48 #[serde(default)]
50 pub names: BTreeSet<String>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, Default)]
55pub struct SubdomainBaselines {
56 #[serde(default)]
58 pub domains: BTreeMap<String, SubdomainBaseline>,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct SubdomainBaselineDiff {
67 pub domain: String,
69 pub baseline_recorded_at: Option<DateTime<Utc>>,
71 pub added: Vec<String>,
73 pub removed: Vec<String>,
77 pub unchanged_count: usize,
79 pub baseline_missing: bool,
83}
84
85impl SubdomainBaselineDiff {
86 pub fn has_new_names(&self) -> bool {
90 !self.added.is_empty()
91 }
92
93 pub fn between(domain: &str, baseline: Option<&SubdomainBaseline>, fresh: &[String]) -> Self {
98 let Some(baseline) = baseline else {
99 return Self {
100 domain: domain.to_string(),
101 baseline_recorded_at: None,
102 added: Vec::new(),
103 removed: Vec::new(),
104 unchanged_count: 0,
105 baseline_missing: true,
106 };
107 };
108
109 let fresh_set: BTreeSet<String> = fresh.iter().map(|n| normalize_name(n)).collect();
110
111 let added: Vec<String> = fresh_set.difference(&baseline.names).cloned().collect();
112 let removed: Vec<String> = baseline.names.difference(&fresh_set).cloned().collect();
113 let unchanged_count = fresh_set.intersection(&baseline.names).count();
114
115 Self {
116 domain: domain.to_string(),
117 baseline_recorded_at: Some(baseline.recorded_at),
118 added,
119 removed,
120 unchanged_count,
121 baseline_missing: false,
122 }
123 }
124}
125
126fn normalize_name(name: &str) -> String {
130 name.trim().to_lowercase()
131}
132
133impl SubdomainBaselines {
134 pub fn path() -> Option<PathBuf> {
137 dirs::home_dir().map(|h| h.join(".seer").join("subdomain_baselines.json"))
138 }
139
140 pub fn load() -> Self {
147 let Some(path) = Self::path() else {
148 return Self::default();
149 };
150 Self::load_from_path(&path)
151 }
152
153 pub(crate) fn load_from_path(path: &std::path::Path) -> Self {
157 if !path.exists() {
158 return Self::default();
159 }
160 match std::fs::read_to_string(path) {
161 Ok(content) => match serde_json::from_str::<SubdomainBaselines>(&content) {
162 Ok(b) => b,
163 Err(e) => {
164 let backup = path.with_extension("corrupt");
165 if let Err(rename_err) = std::fs::rename(path, &backup) {
166 tracing::error!(
167 path = %path.display(),
168 error = %rename_err,
169 "failed to back up corrupt subdomain baselines",
170 );
171 } else {
172 tracing::warn!(
173 path = %path.display(),
174 backup = %backup.display(),
175 error = %e,
176 "subdomain baselines file corrupt; moved to backup",
177 );
178 }
179 SubdomainBaselines::default()
180 }
181 },
182 Err(_) => Self::default(),
183 }
184 }
185
186 pub fn save(&self) -> Result<()> {
196 let path = Self::path()
197 .ok_or_else(|| SeerError::ConfigError("Cannot determine home directory".to_string()))?;
198 self.save_to_path(&path)
199 }
200
201 pub(crate) fn save_to_path(&self, path: &std::path::Path) -> Result<()> {
204 let content = serde_json::to_string_pretty(self)
205 .map_err(|e| SeerError::ConfigError(e.to_string()))?;
206 crate::fsutil::write_atomic_owner_only(path, &content, "json")
207 }
208
209 pub fn record(&mut self, domain: &str, names: &[String], source: &str) {
213 self.domains.insert(
214 domain.to_lowercase(),
215 SubdomainBaseline {
216 recorded_at: Utc::now(),
217 source: source.to_string(),
218 names: names.iter().map(|n| normalize_name(n)).collect(),
219 },
220 );
221 while self.domains.len() > MAX_DOMAINS {
222 let Some(victim) = self
223 .domains
224 .iter()
225 .min_by_key(|(_, b)| b.recorded_at)
226 .map(|(k, _)| k.clone())
227 else {
228 break;
229 };
230 self.domains.remove(&victim);
231 }
232 }
233
234 pub fn get(&self, domain: &str) -> Option<&SubdomainBaseline> {
236 self.domains.get(&domain.to_lowercase())
237 }
238
239 pub fn diff(&self, domain: &str, fresh: &[String]) -> SubdomainBaselineDiff {
242 SubdomainBaselineDiff::between(domain, self.get(domain), fresh)
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 fn names(items: &[&str]) -> Vec<String> {
251 items.iter().map(|s| s.to_string()).collect()
252 }
253
254 #[test]
255 fn diff_reports_added_removed_and_unchanged() {
256 let mut store = SubdomainBaselines::default();
257 store.record(
258 "example.com",
259 &names(&["api.example.com", "mail.example.com", "old.example.com"]),
260 "crt.sh",
261 );
262 let report = store.diff(
263 "example.com",
264 &names(&["api.example.com", "mail.example.com", "new.example.com"]),
265 );
266
267 assert!(!report.baseline_missing);
268 assert!(report.baseline_recorded_at.is_some());
269 assert_eq!(report.added, vec!["new.example.com"]);
270 assert_eq!(report.removed, vec!["old.example.com"]);
271 assert_eq!(report.unchanged_count, 2);
272 assert!(report.has_new_names());
273 }
274
275 #[test]
276 fn removals_alone_are_not_material() {
277 let mut store = SubdomainBaselines::default();
280 store.record(
281 "example.com",
282 &names(&["a.example.com", "b.example.com"]),
283 "crt.sh",
284 );
285 let report = store.diff("example.com", &names(&["a.example.com"]));
286 assert_eq!(report.removed, vec!["b.example.com"]);
287 assert!(!report.has_new_names(), "removals must be non-fatal");
288 }
289
290 #[test]
291 fn identical_sets_show_no_changes() {
292 let mut store = SubdomainBaselines::default();
293 store.record("example.com", &names(&["a.example.com"]), "crt.sh");
294 let report = store.diff("example.com", &names(&["a.example.com"]));
295 assert!(report.added.is_empty());
296 assert!(report.removed.is_empty());
297 assert_eq!(report.unchanged_count, 1);
298 assert!(!report.has_new_names());
299 }
300
301 #[test]
302 fn missing_baseline_yields_empty_non_material_diff() {
303 let store = SubdomainBaselines::default();
305 let report = store.diff("example.com", &names(&["a.example.com"]));
306 assert!(report.baseline_missing);
307 assert!(report.baseline_recorded_at.is_none());
308 assert!(report.added.is_empty());
309 assert!(report.removed.is_empty());
310 assert_eq!(report.unchanged_count, 0);
311 assert!(!report.has_new_names());
312 }
313
314 #[test]
315 fn empty_fresh_set_reports_all_baseline_names_removed() {
316 let mut store = SubdomainBaselines::default();
317 store.record(
318 "example.com",
319 &names(&["a.example.com", "b.example.com"]),
320 "crt.sh",
321 );
322 let report = store.diff("example.com", &[]);
323 assert!(report.added.is_empty());
324 assert_eq!(report.removed, vec!["a.example.com", "b.example.com"]);
325 assert_eq!(report.unchanged_count, 0);
326 assert!(!report.has_new_names(), "a wiped result set must not alert");
327 }
328
329 #[test]
330 fn case_and_whitespace_churn_is_not_a_change() {
331 let mut store = SubdomainBaselines::default();
332 store.record("Example.COM", &names(&["API.example.com "]), "crt.sh");
333 let report = store.diff("example.com", &names(&["api.example.com"]));
334 assert!(report.added.is_empty(), "case churn: {:?}", report.added);
335 assert!(report.removed.is_empty());
336 assert_eq!(report.unchanged_count, 1);
337 }
338
339 #[test]
340 fn record_replaces_previous_baseline() {
341 let mut store = SubdomainBaselines::default();
342 store.record("example.com", &names(&["a.example.com"]), "crt.sh");
343 store.record("example.com", &names(&["b.example.com"]), "certspotter");
344 let baseline = store.get("example.com").expect("baseline");
345 assert_eq!(baseline.source, "certspotter");
346 assert!(baseline.names.contains("b.example.com"));
347 assert!(!baseline.names.contains("a.example.com"));
348 }
349
350 #[test]
351 fn domain_cap_evicts_oldest_baseline() {
352 let mut store = SubdomainBaselines::default();
353 for i in 0..(MAX_DOMAINS + 5) {
354 store.record(&format!("d{i}.example"), &names(&["x.d.example"]), "t");
355 if let Some(b) = store.domains.get_mut(&format!("d{i}.example")) {
357 b.recorded_at = Utc::now() + chrono::Duration::seconds(i as i64);
358 }
359 }
360 assert!(
361 store.domains.len() <= MAX_DOMAINS,
362 "distinct domains must be capped at {MAX_DOMAINS}, got {}",
363 store.domains.len()
364 );
365 assert!(
366 store.get("d0.example").is_none(),
367 "oldest baseline should be evicted first"
368 );
369 }
370
371 fn unique_temp_path(tag: &str) -> PathBuf {
374 let mut dir = std::env::temp_dir();
375 dir.push(format!(
376 "seer-subdomain-baseline-test-{}-{}",
377 tag,
378 std::process::id()
379 ));
380 let _ = std::fs::create_dir_all(&dir);
381 dir.push("subdomain_baselines.json");
382 dir
383 }
384
385 #[test]
386 fn record_save_load_round_trip() {
387 let path = unique_temp_path("roundtrip");
388 let _ = std::fs::remove_file(&path);
389
390 let mut store = SubdomainBaselines::default();
391 store.record(
392 "example.com",
393 &names(&["api.example.com", "mail.example.com"]),
394 "crt.sh",
395 );
396 store.save_to_path(&path).expect("save");
397
398 let loaded = SubdomainBaselines::load_from_path(&path);
399 let baseline = loaded.get("example.com").expect("baseline survives");
400 assert_eq!(baseline.source, "crt.sh");
401 assert_eq!(baseline.names.len(), 2);
402 assert!(baseline.names.contains("api.example.com"));
403
404 if let Some(parent) = path.parent() {
405 let _ = std::fs::remove_dir_all(parent);
406 }
407 }
408
409 #[cfg(unix)]
410 #[test]
411 fn save_applies_owner_only_permissions() {
412 use std::os::unix::fs::PermissionsExt;
413 let path = unique_temp_path("perms");
414 let _ = std::fs::remove_file(&path);
415
416 let mut store = SubdomainBaselines::default();
417 store.record("example.com", &names(&["a.example.com"]), "crt.sh");
418 store.save_to_path(&path).expect("save");
419
420 let mode = std::fs::metadata(&path).expect("metadata").permissions();
421 assert_eq!(
422 mode.mode() & 0o777,
423 0o600,
424 "baseline file must be owner-only"
425 );
426
427 if let Some(parent) = path.parent() {
428 let _ = std::fs::remove_dir_all(parent);
429 }
430 }
431
432 #[test]
433 fn load_from_path_backs_up_corrupt_file_and_returns_default() {
434 let path = unique_temp_path("corrupt");
435 let backup = path.with_extension("corrupt");
436 let _ = std::fs::remove_file(&path);
437 let _ = std::fs::remove_file(&backup);
438
439 std::fs::write(&path, b"{ not valid json ").expect("seed file");
440
441 let loaded = SubdomainBaselines::load_from_path(&path);
442 assert!(
443 loaded.domains.is_empty(),
444 "corrupt load must return default"
445 );
446 assert!(!path.exists(), "corrupt file should be renamed away");
447 assert!(
448 backup.exists(),
449 "backup .corrupt file should exist at {}",
450 backup.display()
451 );
452
453 let _ = std::fs::remove_file(&backup);
454 if let Some(parent) = path.parent() {
455 let _ = std::fs::remove_dir_all(parent);
456 }
457 }
458
459 #[test]
460 fn load_from_path_returns_default_when_missing() {
461 let path = unique_temp_path("missing");
462 let _ = std::fs::remove_file(&path);
463 let loaded = SubdomainBaselines::load_from_path(&path);
464 assert!(loaded.domains.is_empty());
465 if let Some(parent) = path.parent() {
466 let _ = std::fs::remove_dir_all(parent);
467 }
468 }
469
470 #[test]
471 fn baseline_json_missing_optional_fields_still_parses() {
472 let json = r#"{"domains":{"example.com":{"recorded_at":"2026-06-01T00:00:00Z"}}}"#;
476 let store: SubdomainBaselines = serde_json::from_str(json).expect("parses");
477 let baseline = store.get("example.com").expect("baseline");
478 assert!(baseline.names.is_empty());
479 assert!(baseline.source.is_empty());
480 }
481}