use std::collections::{BTreeMap, BTreeSet};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use super::SubdomainResult;
const MAX_DOMAINS: usize = 1000;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubdomainBaseline {
pub recorded_at: DateTime<Utc>,
#[serde(default)]
pub source: String,
#[serde(default)]
pub names: BTreeSet<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub truncated: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SubdomainBaselines {
#[serde(default)]
pub domains: BTreeMap<String, SubdomainBaseline>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubdomainBaselineDiff {
pub domain: String,
pub baseline_recorded_at: Option<DateTime<Utc>>,
pub added: Vec<String>,
pub removed: Vec<String>,
pub unchanged_count: usize,
pub baseline_missing: bool,
#[serde(default)]
pub baseline_truncated: bool,
}
impl SubdomainBaselineDiff {
pub fn has_new_names(&self) -> bool {
!self.added.is_empty() && !self.baseline_truncated
}
pub fn between(domain: &str, baseline: Option<&SubdomainBaseline>, fresh: &[String]) -> Self {
let Some(baseline) = baseline else {
return Self {
domain: domain.to_string(),
baseline_recorded_at: None,
added: Vec::new(),
removed: Vec::new(),
unchanged_count: 0,
baseline_missing: true,
baseline_truncated: false,
};
};
let fresh_set: BTreeSet<String> = fresh.iter().map(|n| normalize_name(n)).collect();
let added: Vec<String> = fresh_set.difference(&baseline.names).cloned().collect();
let removed: Vec<String> = baseline.names.difference(&fresh_set).cloned().collect();
let unchanged_count = fresh_set.intersection(&baseline.names).count();
Self {
domain: domain.to_string(),
baseline_recorded_at: Some(baseline.recorded_at),
added,
removed,
unchanged_count,
baseline_missing: false,
baseline_truncated: baseline.truncated,
}
}
}
fn normalize_name(name: &str) -> String {
name.trim().to_lowercase()
}
crate::fsutil::persisted_store!(
SubdomainBaselines,
"subdomain_baselines.json",
json,
"subdomain baselines"
);
impl SubdomainBaselines {
pub fn record(&mut self, result: &SubdomainResult) {
let key = result.domain.to_lowercase();
let previous = self.domains.remove(&key);
let truncated = result.truncated && previous.as_ref().is_none_or(|p| p.truncated);
let mut merged: BTreeSet<String> = previous.map(|p| p.names).unwrap_or_default();
merged.extend(result.subdomains.iter().map(|n| normalize_name(n)));
self.domains.insert(
key,
SubdomainBaseline {
recorded_at: Utc::now(),
source: result.source.clone(),
names: merged,
truncated,
},
);
while self.domains.len() > MAX_DOMAINS {
let Some(victim) = self
.domains
.iter()
.min_by_key(|(_, b)| b.recorded_at)
.map(|(k, _)| k.clone())
else {
break;
};
self.domains.remove(&victim);
}
}
pub fn get(&self, domain: &str) -> Option<&SubdomainBaseline> {
self.domains.get(&domain.to_lowercase())
}
pub fn diff(&self, domain: &str, fresh: &[String]) -> SubdomainBaselineDiff {
SubdomainBaselineDiff::between(domain, self.get(domain), fresh)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn names(items: &[&str]) -> Vec<String> {
items.iter().map(|s| s.to_string()).collect()
}
fn run(domain: &str, names: &[String], source: &str, truncated: bool) -> SubdomainResult {
SubdomainResult {
domain: domain.to_string(),
subdomains: names.to_vec(),
source: source.to_string(),
count: names.len(),
truncated,
}
}
fn record(store: &mut SubdomainBaselines, domain: &str, names: &[String], source: &str) {
store.record(&run(domain, names, source, false));
}
#[test]
fn names_added_to_a_truncated_baseline_are_not_material() {
let mut store = SubdomainBaselines::default();
store.record(&run(
"example.com",
&names(&["a.example.com"]),
"certspotter",
true,
));
assert!(store.get("example.com").expect("baseline").truncated);
let full = names(&["a.example.com", "b.example.com", "c.example.com"]);
let report = store.diff("example.com", &full);
assert_eq!(report.added, vec!["b.example.com", "c.example.com"]);
assert!(report.baseline_truncated);
assert!(!report.has_new_names(), "{report:?}");
record(&mut store, "example.com", &full, "crt.sh");
assert!(!store.get("example.com").expect("baseline").truncated);
let report = store.diff(
"example.com",
&names(&[
"a.example.com",
"b.example.com",
"c.example.com",
"d.example.com",
]),
);
assert!(!report.baseline_truncated);
assert!(report.has_new_names());
store.record(&run(
"example.com",
&names(&["a.example.com"]),
"certspotter",
true,
));
assert!(!store.get("example.com").expect("baseline").truncated);
}
#[test]
fn baselines_without_the_truncated_field_load_as_complete() {
let json = r#"{"domains":{"example.com":{"recorded_at":"2026-06-01T00:00:00Z","source":"crt.sh","names":["a.example.com"]}}}"#;
let store: SubdomainBaselines = serde_json::from_str(json).expect("parses");
assert!(!store.get("example.com").expect("baseline").truncated);
let report = store.diff("example.com", &names(&["a.example.com", "b.example.com"]));
assert!(report.has_new_names());
}
#[test]
fn diff_reports_added_removed_and_unchanged() {
let mut store = SubdomainBaselines::default();
record(
&mut store,
"example.com",
&names(&["api.example.com", "mail.example.com", "old.example.com"]),
"crt.sh",
);
let report = store.diff(
"example.com",
&names(&["api.example.com", "mail.example.com", "new.example.com"]),
);
assert!(!report.baseline_missing);
assert!(report.baseline_recorded_at.is_some());
assert_eq!(report.added, vec!["new.example.com"]);
assert_eq!(report.removed, vec!["old.example.com"]);
assert_eq!(report.unchanged_count, 2);
assert!(report.has_new_names());
}
#[test]
fn removals_alone_are_not_material() {
let mut store = SubdomainBaselines::default();
record(
&mut store,
"example.com",
&names(&["a.example.com", "b.example.com"]),
"crt.sh",
);
let report = store.diff("example.com", &names(&["a.example.com"]));
assert_eq!(report.removed, vec!["b.example.com"]);
assert!(!report.has_new_names(), "removals must be non-fatal");
}
#[test]
fn identical_sets_show_no_changes() {
let mut store = SubdomainBaselines::default();
record(
&mut store,
"example.com",
&names(&["a.example.com"]),
"crt.sh",
);
let report = store.diff("example.com", &names(&["a.example.com"]));
assert!(report.added.is_empty());
assert!(report.removed.is_empty());
assert_eq!(report.unchanged_count, 1);
assert!(!report.has_new_names());
}
#[test]
fn missing_baseline_yields_empty_non_material_diff() {
let store = SubdomainBaselines::default();
let report = store.diff("example.com", &names(&["a.example.com"]));
assert!(report.baseline_missing);
assert!(report.baseline_recorded_at.is_none());
assert!(report.added.is_empty());
assert!(report.removed.is_empty());
assert_eq!(report.unchanged_count, 0);
assert!(!report.has_new_names());
}
#[test]
fn empty_fresh_set_reports_all_baseline_names_removed() {
let mut store = SubdomainBaselines::default();
record(
&mut store,
"example.com",
&names(&["a.example.com", "b.example.com"]),
"crt.sh",
);
let report = store.diff("example.com", &[]);
assert!(report.added.is_empty());
assert_eq!(report.removed, vec!["a.example.com", "b.example.com"]);
assert_eq!(report.unchanged_count, 0);
assert!(!report.has_new_names(), "a wiped result set must not alert");
}
#[test]
fn case_and_whitespace_churn_is_not_a_change() {
let mut store = SubdomainBaselines::default();
record(
&mut store,
"Example.COM",
&names(&["API.example.com "]),
"crt.sh",
);
let report = store.diff("example.com", &names(&["api.example.com"]));
assert!(report.added.is_empty(), "case churn: {:?}", report.added);
assert!(report.removed.is_empty());
assert_eq!(report.unchanged_count, 1);
}
#[test]
fn record_merges_into_previous_baseline() {
let mut store = SubdomainBaselines::default();
record(
&mut store,
"example.com",
&names(&["a.example.com", "b.example.com"]),
"crt.sh",
);
record(
&mut store,
"example.com",
&names(&["b.example.com"]),
"certspotter",
);
let baseline = store.get("example.com").expect("baseline");
assert_eq!(baseline.source, "certspotter");
assert!(baseline.names.contains("a.example.com"));
assert!(baseline.names.contains("b.example.com"));
let diff = store.diff("example.com", &names(&["a.example.com", "b.example.com"]));
assert!(!diff.has_new_names(), "{diff:?}");
}
#[test]
fn domain_cap_evicts_oldest_baseline() {
let mut store = SubdomainBaselines::default();
for i in 0..(MAX_DOMAINS + 5) {
record(
&mut store,
&format!("d{i}.example"),
&names(&["x.d.example"]),
"t",
);
if let Some(b) = store.domains.get_mut(&format!("d{i}.example")) {
b.recorded_at = Utc::now() + chrono::Duration::seconds(i as i64);
}
}
assert!(
store.domains.len() <= MAX_DOMAINS,
"distinct domains must be capped at {MAX_DOMAINS}, got {}",
store.domains.len()
);
assert!(
store.get("d0.example").is_none(),
"oldest baseline should be evicted first"
);
}
fn unique_temp_path(tag: &str) -> PathBuf {
let mut dir = std::env::temp_dir();
dir.push(format!(
"seer-subdomain-baseline-test-{}-{}",
tag,
std::process::id()
));
let _ = std::fs::create_dir_all(&dir);
dir.push("subdomain_baselines.json");
dir
}
#[test]
fn record_save_load_round_trip() {
let path = unique_temp_path("roundtrip");
let _ = std::fs::remove_file(&path);
let mut store = SubdomainBaselines::default();
record(
&mut store,
"example.com",
&names(&["api.example.com", "mail.example.com"]),
"crt.sh",
);
store.save_to_path(&path).expect("save");
let loaded = SubdomainBaselines::load_from_path(&path).expect("load");
let baseline = loaded.get("example.com").expect("baseline survives");
assert_eq!(baseline.source, "crt.sh");
assert_eq!(baseline.names.len(), 2);
assert!(baseline.names.contains("api.example.com"));
if let Some(parent) = path.parent() {
let _ = std::fs::remove_dir_all(parent);
}
}
#[cfg(unix)]
#[test]
fn save_applies_owner_only_permissions() {
use std::os::unix::fs::PermissionsExt;
let path = unique_temp_path("perms");
let _ = std::fs::remove_file(&path);
let mut store = SubdomainBaselines::default();
record(
&mut store,
"example.com",
&names(&["a.example.com"]),
"crt.sh",
);
store.save_to_path(&path).expect("save");
let mode = std::fs::metadata(&path).expect("metadata").permissions();
assert_eq!(
mode.mode() & 0o777,
0o600,
"baseline file must be owner-only"
);
if let Some(parent) = path.parent() {
let _ = std::fs::remove_dir_all(parent);
}
}
#[test]
fn load_from_path_backs_up_corrupt_file_and_returns_default() {
let path = unique_temp_path("corrupt");
let backup = path.with_extension("corrupt");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&backup);
std::fs::write(&path, b"{ not valid json ").expect("seed file");
let loaded = SubdomainBaselines::load_from_path(&path).expect("load");
assert!(
loaded.domains.is_empty(),
"corrupt load must return default"
);
assert!(!path.exists(), "corrupt file should be renamed away");
assert!(
backup.exists(),
"backup .corrupt file should exist at {}",
backup.display()
);
let _ = std::fs::remove_file(&backup);
if let Some(parent) = path.parent() {
let _ = std::fs::remove_dir_all(parent);
}
}
#[test]
fn load_from_path_returns_default_when_missing() {
let path = unique_temp_path("missing");
let _ = std::fs::remove_file(&path);
let loaded = SubdomainBaselines::load_from_path(&path).expect("load");
assert!(loaded.domains.is_empty());
if let Some(parent) = path.parent() {
let _ = std::fs::remove_dir_all(parent);
}
}
#[test]
fn baseline_json_missing_optional_fields_still_parses() {
let json = r#"{"domains":{"example.com":{"recorded_at":"2026-06-01T00:00:00Z"}}}"#;
let store: SubdomainBaselines = serde_json::from_str(json).expect("parses");
let baseline = store.get("example.com").expect("baseline");
assert!(baseline.names.is_empty());
assert!(baseline.source.is_empty());
}
}