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 if let Some(parent) = path.parent() {
205 std::fs::create_dir_all(parent).map_err(|e| SeerError::ConfigError(e.to_string()))?;
206 #[cfg(unix)]
210 {
211 use std::os::unix::fs::PermissionsExt;
212 let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
213 }
214 }
215 let content = serde_json::to_string_pretty(self)
216 .map_err(|e| SeerError::ConfigError(e.to_string()))?;
217 let tmp_path = path.with_extension(format!("json.{}.tmp", std::process::id()));
218 std::fs::write(&tmp_path, content).map_err(|e| SeerError::ConfigError(e.to_string()))?;
219 #[cfg(unix)]
220 {
221 use std::os::unix::fs::PermissionsExt;
222 let _ = std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o600));
223 }
224 std::fs::rename(&tmp_path, path).map_err(|e| {
225 let _ = std::fs::remove_file(&tmp_path);
227 SeerError::ConfigError(e.to_string())
228 })?;
229 Ok(())
230 }
231
232 pub fn record(&mut self, domain: &str, names: &[String], source: &str) {
236 self.domains.insert(
237 domain.to_lowercase(),
238 SubdomainBaseline {
239 recorded_at: Utc::now(),
240 source: source.to_string(),
241 names: names.iter().map(|n| normalize_name(n)).collect(),
242 },
243 );
244 while self.domains.len() > MAX_DOMAINS {
245 let Some(victim) = self
246 .domains
247 .iter()
248 .min_by_key(|(_, b)| b.recorded_at)
249 .map(|(k, _)| k.clone())
250 else {
251 break;
252 };
253 self.domains.remove(&victim);
254 }
255 }
256
257 pub fn get(&self, domain: &str) -> Option<&SubdomainBaseline> {
259 self.domains.get(&domain.to_lowercase())
260 }
261
262 pub fn diff(&self, domain: &str, fresh: &[String]) -> SubdomainBaselineDiff {
265 SubdomainBaselineDiff::between(domain, self.get(domain), fresh)
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272
273 fn names(items: &[&str]) -> Vec<String> {
274 items.iter().map(|s| s.to_string()).collect()
275 }
276
277 #[test]
278 fn diff_reports_added_removed_and_unchanged() {
279 let mut store = SubdomainBaselines::default();
280 store.record(
281 "example.com",
282 &names(&["api.example.com", "mail.example.com", "old.example.com"]),
283 "crt.sh",
284 );
285 let report = store.diff(
286 "example.com",
287 &names(&["api.example.com", "mail.example.com", "new.example.com"]),
288 );
289
290 assert!(!report.baseline_missing);
291 assert!(report.baseline_recorded_at.is_some());
292 assert_eq!(report.added, vec!["new.example.com"]);
293 assert_eq!(report.removed, vec!["old.example.com"]);
294 assert_eq!(report.unchanged_count, 2);
295 assert!(report.has_new_names());
296 }
297
298 #[test]
299 fn removals_alone_are_not_material() {
300 let mut store = SubdomainBaselines::default();
303 store.record(
304 "example.com",
305 &names(&["a.example.com", "b.example.com"]),
306 "crt.sh",
307 );
308 let report = store.diff("example.com", &names(&["a.example.com"]));
309 assert_eq!(report.removed, vec!["b.example.com"]);
310 assert!(!report.has_new_names(), "removals must be non-fatal");
311 }
312
313 #[test]
314 fn identical_sets_show_no_changes() {
315 let mut store = SubdomainBaselines::default();
316 store.record("example.com", &names(&["a.example.com"]), "crt.sh");
317 let report = store.diff("example.com", &names(&["a.example.com"]));
318 assert!(report.added.is_empty());
319 assert!(report.removed.is_empty());
320 assert_eq!(report.unchanged_count, 1);
321 assert!(!report.has_new_names());
322 }
323
324 #[test]
325 fn missing_baseline_yields_empty_non_material_diff() {
326 let store = SubdomainBaselines::default();
328 let report = store.diff("example.com", &names(&["a.example.com"]));
329 assert!(report.baseline_missing);
330 assert!(report.baseline_recorded_at.is_none());
331 assert!(report.added.is_empty());
332 assert!(report.removed.is_empty());
333 assert_eq!(report.unchanged_count, 0);
334 assert!(!report.has_new_names());
335 }
336
337 #[test]
338 fn empty_fresh_set_reports_all_baseline_names_removed() {
339 let mut store = SubdomainBaselines::default();
340 store.record(
341 "example.com",
342 &names(&["a.example.com", "b.example.com"]),
343 "crt.sh",
344 );
345 let report = store.diff("example.com", &[]);
346 assert!(report.added.is_empty());
347 assert_eq!(report.removed, vec!["a.example.com", "b.example.com"]);
348 assert_eq!(report.unchanged_count, 0);
349 assert!(!report.has_new_names(), "a wiped result set must not alert");
350 }
351
352 #[test]
353 fn case_and_whitespace_churn_is_not_a_change() {
354 let mut store = SubdomainBaselines::default();
355 store.record("Example.COM", &names(&["API.example.com "]), "crt.sh");
356 let report = store.diff("example.com", &names(&["api.example.com"]));
357 assert!(report.added.is_empty(), "case churn: {:?}", report.added);
358 assert!(report.removed.is_empty());
359 assert_eq!(report.unchanged_count, 1);
360 }
361
362 #[test]
363 fn record_replaces_previous_baseline() {
364 let mut store = SubdomainBaselines::default();
365 store.record("example.com", &names(&["a.example.com"]), "crt.sh");
366 store.record("example.com", &names(&["b.example.com"]), "certspotter");
367 let baseline = store.get("example.com").expect("baseline");
368 assert_eq!(baseline.source, "certspotter");
369 assert!(baseline.names.contains("b.example.com"));
370 assert!(!baseline.names.contains("a.example.com"));
371 }
372
373 #[test]
374 fn domain_cap_evicts_oldest_baseline() {
375 let mut store = SubdomainBaselines::default();
376 for i in 0..(MAX_DOMAINS + 5) {
377 store.record(&format!("d{i}.example"), &names(&["x.d.example"]), "t");
378 if let Some(b) = store.domains.get_mut(&format!("d{i}.example")) {
380 b.recorded_at = Utc::now() + chrono::Duration::seconds(i as i64);
381 }
382 }
383 assert!(
384 store.domains.len() <= MAX_DOMAINS,
385 "distinct domains must be capped at {MAX_DOMAINS}, got {}",
386 store.domains.len()
387 );
388 assert!(
389 store.get("d0.example").is_none(),
390 "oldest baseline should be evicted first"
391 );
392 }
393
394 fn unique_temp_path(tag: &str) -> PathBuf {
397 let mut dir = std::env::temp_dir();
398 dir.push(format!(
399 "seer-subdomain-baseline-test-{}-{}",
400 tag,
401 std::process::id()
402 ));
403 let _ = std::fs::create_dir_all(&dir);
404 dir.push("subdomain_baselines.json");
405 dir
406 }
407
408 #[test]
409 fn record_save_load_round_trip() {
410 let path = unique_temp_path("roundtrip");
411 let _ = std::fs::remove_file(&path);
412
413 let mut store = SubdomainBaselines::default();
414 store.record(
415 "example.com",
416 &names(&["api.example.com", "mail.example.com"]),
417 "crt.sh",
418 );
419 store.save_to_path(&path).expect("save");
420
421 let loaded = SubdomainBaselines::load_from_path(&path);
422 let baseline = loaded.get("example.com").expect("baseline survives");
423 assert_eq!(baseline.source, "crt.sh");
424 assert_eq!(baseline.names.len(), 2);
425 assert!(baseline.names.contains("api.example.com"));
426
427 if let Some(parent) = path.parent() {
428 let _ = std::fs::remove_dir_all(parent);
429 }
430 }
431
432 #[cfg(unix)]
433 #[test]
434 fn save_applies_owner_only_permissions() {
435 use std::os::unix::fs::PermissionsExt;
436 let path = unique_temp_path("perms");
437 let _ = std::fs::remove_file(&path);
438
439 let mut store = SubdomainBaselines::default();
440 store.record("example.com", &names(&["a.example.com"]), "crt.sh");
441 store.save_to_path(&path).expect("save");
442
443 let mode = std::fs::metadata(&path).expect("metadata").permissions();
444 assert_eq!(
445 mode.mode() & 0o777,
446 0o600,
447 "baseline file must be owner-only"
448 );
449
450 if let Some(parent) = path.parent() {
451 let _ = std::fs::remove_dir_all(parent);
452 }
453 }
454
455 #[test]
456 fn load_from_path_backs_up_corrupt_file_and_returns_default() {
457 let path = unique_temp_path("corrupt");
458 let backup = path.with_extension("corrupt");
459 let _ = std::fs::remove_file(&path);
460 let _ = std::fs::remove_file(&backup);
461
462 std::fs::write(&path, b"{ not valid json ").expect("seed file");
463
464 let loaded = SubdomainBaselines::load_from_path(&path);
465 assert!(
466 loaded.domains.is_empty(),
467 "corrupt load must return default"
468 );
469 assert!(!path.exists(), "corrupt file should be renamed away");
470 assert!(
471 backup.exists(),
472 "backup .corrupt file should exist at {}",
473 backup.display()
474 );
475
476 let _ = std::fs::remove_file(&backup);
477 if let Some(parent) = path.parent() {
478 let _ = std::fs::remove_dir_all(parent);
479 }
480 }
481
482 #[test]
483 fn load_from_path_returns_default_when_missing() {
484 let path = unique_temp_path("missing");
485 let _ = std::fs::remove_file(&path);
486 let loaded = SubdomainBaselines::load_from_path(&path);
487 assert!(loaded.domains.is_empty());
488 if let Some(parent) = path.parent() {
489 let _ = std::fs::remove_dir_all(parent);
490 }
491 }
492
493 #[test]
494 fn baseline_json_missing_optional_fields_still_parses() {
495 let json = r#"{"domains":{"example.com":{"recorded_at":"2026-06-01T00:00:00Z"}}}"#;
499 let store: SubdomainBaselines = serde_json::from_str(json).expect("parses");
500 let baseline = store.get("example.com").expect("baseline");
501 assert!(baseline.names.is_empty());
502 assert!(baseline.source.is_empty());
503 }
504}