1use std::collections::{BTreeMap, BTreeSet};
30
31use chrono::{DateTime, Utc};
32use serde::{Deserialize, Serialize};
33
34use super::SubdomainResult;
35
36const MAX_DOMAINS: usize = 1000;
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct SubdomainBaseline {
46 pub recorded_at: DateTime<Utc>,
48 #[serde(default)]
50 pub source: String,
51 #[serde(default)]
53 pub names: BTreeSet<String>,
54 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
59 pub truncated: bool,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, Default)]
64pub struct SubdomainBaselines {
65 #[serde(default)]
67 pub domains: BTreeMap<String, SubdomainBaseline>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct SubdomainBaselineDiff {
76 pub domain: String,
78 pub baseline_recorded_at: Option<DateTime<Utc>>,
80 pub added: Vec<String>,
82 pub removed: Vec<String>,
86 pub unchanged_count: usize,
88 pub baseline_missing: bool,
92 #[serde(default)]
97 pub baseline_truncated: bool,
98}
99
100impl SubdomainBaselineDiff {
101 pub fn has_new_names(&self) -> bool {
108 !self.added.is_empty() && !self.baseline_truncated
109 }
110
111 pub fn between(domain: &str, baseline: Option<&SubdomainBaseline>, fresh: &[String]) -> Self {
116 let Some(baseline) = baseline else {
117 return Self {
118 domain: domain.to_string(),
119 baseline_recorded_at: None,
120 added: Vec::new(),
121 removed: Vec::new(),
122 unchanged_count: 0,
123 baseline_missing: true,
124 baseline_truncated: false,
125 };
126 };
127
128 let fresh_set: BTreeSet<String> = fresh.iter().map(|n| normalize_name(n)).collect();
129
130 let added: Vec<String> = fresh_set.difference(&baseline.names).cloned().collect();
131 let removed: Vec<String> = baseline.names.difference(&fresh_set).cloned().collect();
132 let unchanged_count = fresh_set.intersection(&baseline.names).count();
133
134 Self {
135 domain: domain.to_string(),
136 baseline_recorded_at: Some(baseline.recorded_at),
137 added,
138 removed,
139 unchanged_count,
140 baseline_missing: false,
141 baseline_truncated: baseline.truncated,
142 }
143 }
144}
145
146fn normalize_name(name: &str) -> String {
150 name.trim().to_lowercase()
151}
152
153crate::fsutil::persisted_store!(
154 SubdomainBaselines,
155 "subdomain_baselines.json",
156 json,
157 "subdomain baselines"
158);
159
160impl SubdomainBaselines {
161 pub fn record(&mut self, result: &SubdomainResult) {
173 let key = result.domain.to_lowercase();
174 let previous = self.domains.remove(&key);
175 let truncated = result.truncated && previous.as_ref().is_none_or(|p| p.truncated);
176 let mut merged: BTreeSet<String> = previous.map(|p| p.names).unwrap_or_default();
177 merged.extend(result.subdomains.iter().map(|n| normalize_name(n)));
178 self.domains.insert(
179 key,
180 SubdomainBaseline {
181 recorded_at: Utc::now(),
182 source: result.source.clone(),
183 names: merged,
184 truncated,
185 },
186 );
187 while self.domains.len() > MAX_DOMAINS {
188 let Some(victim) = self
189 .domains
190 .iter()
191 .min_by_key(|(_, b)| b.recorded_at)
192 .map(|(k, _)| k.clone())
193 else {
194 break;
195 };
196 self.domains.remove(&victim);
197 }
198 }
199
200 pub fn get(&self, domain: &str) -> Option<&SubdomainBaseline> {
202 self.domains.get(&domain.to_lowercase())
203 }
204
205 pub fn diff(&self, domain: &str, fresh: &[String]) -> SubdomainBaselineDiff {
208 SubdomainBaselineDiff::between(domain, self.get(domain), fresh)
209 }
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215 use std::path::PathBuf;
216
217 fn names(items: &[&str]) -> Vec<String> {
218 items.iter().map(|s| s.to_string()).collect()
219 }
220
221 fn run(domain: &str, names: &[String], source: &str, truncated: bool) -> SubdomainResult {
222 SubdomainResult {
223 domain: domain.to_string(),
224 subdomains: names.to_vec(),
225 source: source.to_string(),
226 count: names.len(),
227 truncated,
228 }
229 }
230
231 fn record(store: &mut SubdomainBaselines, domain: &str, names: &[String], source: &str) {
233 store.record(&run(domain, names, source, false));
234 }
235
236 #[test]
240 fn names_added_to_a_truncated_baseline_are_not_material() {
241 let mut store = SubdomainBaselines::default();
242 store.record(&run(
243 "example.com",
244 &names(&["a.example.com"]),
245 "certspotter",
246 true,
247 ));
248 assert!(store.get("example.com").expect("baseline").truncated);
249
250 let full = names(&["a.example.com", "b.example.com", "c.example.com"]);
251 let report = store.diff("example.com", &full);
252 assert_eq!(report.added, vec!["b.example.com", "c.example.com"]);
253 assert!(report.baseline_truncated);
254 assert!(!report.has_new_names(), "{report:?}");
255
256 record(&mut store, "example.com", &full, "crt.sh");
258 assert!(!store.get("example.com").expect("baseline").truncated);
259 let report = store.diff(
260 "example.com",
261 &names(&[
262 "a.example.com",
263 "b.example.com",
264 "c.example.com",
265 "d.example.com",
266 ]),
267 );
268 assert!(!report.baseline_truncated);
269 assert!(report.has_new_names());
270
271 store.record(&run(
273 "example.com",
274 &names(&["a.example.com"]),
275 "certspotter",
276 true,
277 ));
278 assert!(!store.get("example.com").expect("baseline").truncated);
279 }
280
281 #[test]
282 fn baselines_without_the_truncated_field_load_as_complete() {
283 let json = r#"{"domains":{"example.com":{"recorded_at":"2026-06-01T00:00:00Z","source":"crt.sh","names":["a.example.com"]}}}"#;
284 let store: SubdomainBaselines = serde_json::from_str(json).expect("parses");
285 assert!(!store.get("example.com").expect("baseline").truncated);
286 let report = store.diff("example.com", &names(&["a.example.com", "b.example.com"]));
287 assert!(report.has_new_names());
288 }
289
290 #[test]
291 fn diff_reports_added_removed_and_unchanged() {
292 let mut store = SubdomainBaselines::default();
293 record(
294 &mut store,
295 "example.com",
296 &names(&["api.example.com", "mail.example.com", "old.example.com"]),
297 "crt.sh",
298 );
299 let report = store.diff(
300 "example.com",
301 &names(&["api.example.com", "mail.example.com", "new.example.com"]),
302 );
303
304 assert!(!report.baseline_missing);
305 assert!(report.baseline_recorded_at.is_some());
306 assert_eq!(report.added, vec!["new.example.com"]);
307 assert_eq!(report.removed, vec!["old.example.com"]);
308 assert_eq!(report.unchanged_count, 2);
309 assert!(report.has_new_names());
310 }
311
312 #[test]
313 fn removals_alone_are_not_material() {
314 let mut store = SubdomainBaselines::default();
317 record(
318 &mut store,
319 "example.com",
320 &names(&["a.example.com", "b.example.com"]),
321 "crt.sh",
322 );
323 let report = store.diff("example.com", &names(&["a.example.com"]));
324 assert_eq!(report.removed, vec!["b.example.com"]);
325 assert!(!report.has_new_names(), "removals must be non-fatal");
326 }
327
328 #[test]
329 fn identical_sets_show_no_changes() {
330 let mut store = SubdomainBaselines::default();
331 record(
332 &mut store,
333 "example.com",
334 &names(&["a.example.com"]),
335 "crt.sh",
336 );
337 let report = store.diff("example.com", &names(&["a.example.com"]));
338 assert!(report.added.is_empty());
339 assert!(report.removed.is_empty());
340 assert_eq!(report.unchanged_count, 1);
341 assert!(!report.has_new_names());
342 }
343
344 #[test]
345 fn missing_baseline_yields_empty_non_material_diff() {
346 let store = SubdomainBaselines::default();
348 let report = store.diff("example.com", &names(&["a.example.com"]));
349 assert!(report.baseline_missing);
350 assert!(report.baseline_recorded_at.is_none());
351 assert!(report.added.is_empty());
352 assert!(report.removed.is_empty());
353 assert_eq!(report.unchanged_count, 0);
354 assert!(!report.has_new_names());
355 }
356
357 #[test]
358 fn empty_fresh_set_reports_all_baseline_names_removed() {
359 let mut store = SubdomainBaselines::default();
360 record(
361 &mut store,
362 "example.com",
363 &names(&["a.example.com", "b.example.com"]),
364 "crt.sh",
365 );
366 let report = store.diff("example.com", &[]);
367 assert!(report.added.is_empty());
368 assert_eq!(report.removed, vec!["a.example.com", "b.example.com"]);
369 assert_eq!(report.unchanged_count, 0);
370 assert!(!report.has_new_names(), "a wiped result set must not alert");
371 }
372
373 #[test]
374 fn case_and_whitespace_churn_is_not_a_change() {
375 let mut store = SubdomainBaselines::default();
376 record(
377 &mut store,
378 "Example.COM",
379 &names(&["API.example.com "]),
380 "crt.sh",
381 );
382 let report = store.diff("example.com", &names(&["api.example.com"]));
383 assert!(report.added.is_empty(), "case churn: {:?}", report.added);
384 assert!(report.removed.is_empty());
385 assert_eq!(report.unchanged_count, 1);
386 }
387
388 #[test]
389 fn record_merges_into_previous_baseline() {
390 let mut store = SubdomainBaselines::default();
391 record(
392 &mut store,
393 "example.com",
394 &names(&["a.example.com", "b.example.com"]),
395 "crt.sh",
396 );
397 record(
399 &mut store,
400 "example.com",
401 &names(&["b.example.com"]),
402 "certspotter",
403 );
404 let baseline = store.get("example.com").expect("baseline");
405 assert_eq!(baseline.source, "certspotter");
406 assert!(baseline.names.contains("a.example.com"));
407 assert!(baseline.names.contains("b.example.com"));
408 let diff = store.diff("example.com", &names(&["a.example.com", "b.example.com"]));
410 assert!(!diff.has_new_names(), "{diff:?}");
411 }
412
413 #[test]
414 fn domain_cap_evicts_oldest_baseline() {
415 let mut store = SubdomainBaselines::default();
416 for i in 0..(MAX_DOMAINS + 5) {
417 record(
418 &mut store,
419 &format!("d{i}.example"),
420 &names(&["x.d.example"]),
421 "t",
422 );
423 if let Some(b) = store.domains.get_mut(&format!("d{i}.example")) {
425 b.recorded_at = Utc::now() + chrono::Duration::seconds(i as i64);
426 }
427 }
428 assert!(
429 store.domains.len() <= MAX_DOMAINS,
430 "distinct domains must be capped at {MAX_DOMAINS}, got {}",
431 store.domains.len()
432 );
433 assert!(
434 store.get("d0.example").is_none(),
435 "oldest baseline should be evicted first"
436 );
437 }
438
439 fn unique_temp_path(tag: &str) -> PathBuf {
442 let mut dir = std::env::temp_dir();
443 dir.push(format!(
444 "seer-subdomain-baseline-test-{}-{}",
445 tag,
446 std::process::id()
447 ));
448 let _ = std::fs::create_dir_all(&dir);
449 dir.push("subdomain_baselines.json");
450 dir
451 }
452
453 #[test]
454 fn record_save_load_round_trip() {
455 let path = unique_temp_path("roundtrip");
456 let _ = std::fs::remove_file(&path);
457
458 let mut store = SubdomainBaselines::default();
459 record(
460 &mut store,
461 "example.com",
462 &names(&["api.example.com", "mail.example.com"]),
463 "crt.sh",
464 );
465 store.save_to_path(&path).expect("save");
466
467 let loaded = SubdomainBaselines::load_from_path(&path).expect("load");
468 let baseline = loaded.get("example.com").expect("baseline survives");
469 assert_eq!(baseline.source, "crt.sh");
470 assert_eq!(baseline.names.len(), 2);
471 assert!(baseline.names.contains("api.example.com"));
472
473 if let Some(parent) = path.parent() {
474 let _ = std::fs::remove_dir_all(parent);
475 }
476 }
477
478 #[cfg(unix)]
479 #[test]
480 fn save_applies_owner_only_permissions() {
481 use std::os::unix::fs::PermissionsExt;
482 let path = unique_temp_path("perms");
483 let _ = std::fs::remove_file(&path);
484
485 let mut store = SubdomainBaselines::default();
486 record(
487 &mut store,
488 "example.com",
489 &names(&["a.example.com"]),
490 "crt.sh",
491 );
492 store.save_to_path(&path).expect("save");
493
494 let mode = std::fs::metadata(&path).expect("metadata").permissions();
495 assert_eq!(
496 mode.mode() & 0o777,
497 0o600,
498 "baseline file must be owner-only"
499 );
500
501 if let Some(parent) = path.parent() {
502 let _ = std::fs::remove_dir_all(parent);
503 }
504 }
505
506 #[test]
507 fn load_from_path_backs_up_corrupt_file_and_returns_default() {
508 let path = unique_temp_path("corrupt");
509 let backup = path.with_extension("corrupt");
510 let _ = std::fs::remove_file(&path);
511 let _ = std::fs::remove_file(&backup);
512
513 std::fs::write(&path, b"{ not valid json ").expect("seed file");
514
515 let loaded = SubdomainBaselines::load_from_path(&path).expect("load");
516 assert!(
517 loaded.domains.is_empty(),
518 "corrupt load must return default"
519 );
520 assert!(!path.exists(), "corrupt file should be renamed away");
521 assert!(
522 backup.exists(),
523 "backup .corrupt file should exist at {}",
524 backup.display()
525 );
526
527 let _ = std::fs::remove_file(&backup);
528 if let Some(parent) = path.parent() {
529 let _ = std::fs::remove_dir_all(parent);
530 }
531 }
532
533 #[test]
534 fn load_from_path_returns_default_when_missing() {
535 let path = unique_temp_path("missing");
536 let _ = std::fs::remove_file(&path);
537 let loaded = SubdomainBaselines::load_from_path(&path).expect("load");
538 assert!(loaded.domains.is_empty());
539 if let Some(parent) = path.parent() {
540 let _ = std::fs::remove_dir_all(parent);
541 }
542 }
543
544 #[test]
545 fn baseline_json_missing_optional_fields_still_parses() {
546 let json = r#"{"domains":{"example.com":{"recorded_at":"2026-06-01T00:00:00Z"}}}"#;
550 let store: SubdomainBaselines = serde_json::from_str(json).expect("parses");
551 let baseline = store.get("example.com").expect("baseline");
552 assert!(baseline.names.is_empty());
553 assert!(baseline.source.is_empty());
554 }
555}