Skip to main content

pq_map/
model.rs

1use std::collections::BTreeMap;
2use std::net::IpAddr;
3
4use chrono::{DateTime, Local, Utc};
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
8pub struct Origins(u8);
9
10impl Origins {
11    pub const NONE: Self = Self(0);
12    pub const CT: Self = Self(1);
13    pub const DNS: Self = Self(2);
14    pub const APEX: Self = Self(4);
15
16    pub fn insert(&mut self, other: Self) {
17        self.0 |= other.0;
18    }
19
20    pub fn contains(self, other: Self) -> bool {
21        self.0 & other.0 != 0
22    }
23
24    fn labels(self) -> Vec<&'static str> {
25        let mut out = Vec::new();
26        if self.contains(Self::CT) {
27            out.push("ct");
28        }
29        if self.contains(Self::DNS) {
30            out.push("dns");
31        }
32        if self.contains(Self::APEX) {
33            out.push("apex");
34        }
35        out
36    }
37}
38
39impl Serialize for Origins {
40    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
41        serializer.collect_seq(self.labels())
42    }
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
46#[serde(rename_all = "snake_case")]
47pub enum MatchKind {
48    Exact,
49    Wildcard,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case", tag = "source")]
54pub enum ProbeStatus {
55    Ok,
56    DnsFailure,
57    TcpRefused,
58    TcpUnreachable,
59    Timeout { secs: u64 },
60    TlsHandshakeFailed,
61    ConnectedNoCert,
62    Unknown { reason: String },
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct ChainEntry {
67    pub depth: u32,
68    pub certificate_id: u64,
69    pub common_name: Option<String>,
70    pub ski: Option<String>,
71    pub aki: Option<String>,
72    pub not_before: Option<DateTime<Utc>>,
73    pub not_after: Option<DateTime<Utc>>,
74    pub key_algorithm: Option<String>,
75    pub key_size: Option<i64>,
76    pub signature_key_algorithm: Option<String>,
77    pub signature_hash_algorithm: Option<String>,
78    pub issuer_ca_name: Option<String>,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct Certificate {
83    pub serial: String,
84    pub in_ct: bool,
85    pub served_live: bool,
86    pub currently_valid: bool,
87    pub ct_ids: Vec<u64>,
88    pub common_name: Option<String>,
89    pub identities: Vec<String>,
90    pub not_before: Option<DateTime<Utc>>,
91    pub not_after: Option<DateTime<Utc>>,
92    pub pubkey_alg: Option<String>,
93    pub sig_alg: Option<String>,
94    pub issuer: Option<String>,
95    pub key_algorithm: Option<String>,
96    pub key_size: Option<i64>,
97    pub sig_key_algorithm: Option<String>,
98    pub sig_hash_algorithm: Option<String>,
99    pub cert_signature: Option<String>,
100    #[serde(default)]
101    pub chain: Vec<ChainEntry>,
102}
103
104#[derive(Debug, Clone, Serialize)]
105pub struct CertRef {
106    pub serial: String,
107    pub kind: MatchKind,
108}
109
110#[derive(Debug, Clone, Serialize)]
111#[serde(rename_all = "snake_case", tag = "source")]
112pub enum DnsRef {
113    Srv { service: String },
114    Https,
115}
116
117/// DNS response status for the A/AAAA lookup of a host (RFC 1035 rcodes).
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
119#[serde(rename_all = "snake_case")]
120pub enum DnsStatus {
121    /// NOERROR with address records.
122    Ok,
123    /// NOERROR, name exists, but no A/AAAA records (NODATA).
124    NoData,
125    /// NXDOMAIN — the name does not exist.
126    NxDomain,
127    ServFail,
128    Refused,
129    /// Resolver gave up within its timeout budget.
130    Timeout,
131    /// Any other response code or transport failure.
132    Other,
133}
134
135impl DnsStatus {
136    pub fn label(&self) -> &'static str {
137        match self {
138            Self::Ok => "resolves",
139            Self::NoData => "no data",
140            Self::NxDomain => "nxdomain",
141            Self::ServFail => "servfail",
142            Self::Refused => "refused",
143            Self::Timeout => "timeout",
144            Self::Other => "unknown",
145        }
146    }
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct ServedCert {
151    pub serial: String,
152    pub common_name: Option<String>,
153    pub identities: Vec<String>,
154    pub pubkey_alg: Option<String>,
155    pub sig_alg: Option<String>,
156    pub issuer: Option<String>,
157    pub not_before: Option<DateTime<Utc>>,
158    pub not_after: Option<DateTime<Utc>>,
159    /// Signature algorithms of the served chain, leaf first.
160    pub chain_sigs: Vec<String>,
161    pub pubkey_curve: Option<String>,
162    pub cert_signature: Option<String>,
163}
164
165/// Crypto negotiated during the live handshake.
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct Negotiated {
168    pub tls_version: Option<String>,
169    pub kx_group: Option<String>,
170    pub cipher: Option<String>,
171}
172
173impl ServedCert {
174    pub fn into_certificate(self) -> Certificate {
175        let currently_valid =
176            crate::discover::ct::currently_valid(self.not_before, self.not_after, Utc::now());
177        Certificate {
178            serial: self.serial,
179            in_ct: false,
180            served_live: true,
181            currently_valid,
182            ct_ids: Vec::new(),
183            common_name: self.common_name,
184            identities: self.identities,
185            not_before: self.not_before,
186            not_after: self.not_after,
187            pubkey_alg: self.pubkey_alg,
188            sig_alg: self.sig_alg,
189            issuer: self.issuer,
190            key_algorithm: None,
191            key_size: None,
192            sig_key_algorithm: None,
193            sig_hash_algorithm: None,
194            cert_signature: self.cert_signature,
195            chain: Vec::new(),
196        }
197    }
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct Endpoint {
202    pub status: ProbeStatus,
203    pub ip: Option<IpAddr>,
204    pub served: Option<ServedCert>,
205    pub served_not_in_ct: bool,
206    pub negotiated: Option<Negotiated>,
207    pub observed_at: Option<DateTime<Utc>>,
208}
209
210impl Endpoint {
211    pub fn failed(status: ProbeStatus) -> Self {
212        Self {
213            status,
214            ip: None,
215            served: None,
216            served_not_in_ct: false,
217            negotiated: None,
218            observed_at: None,
219        }
220    }
221}
222
223#[derive(Debug, Clone, Default, Serialize)]
224pub struct Host {
225    pub name: String,
226    pub origins: Origins,
227    pub resolves: Option<bool>,
228    pub dns_status: Option<DnsStatus>,
229    pub ct_refs: Vec<CertRef>,
230    pub dns_refs: Vec<DnsRef>,
231    pub endpoint: Option<Endpoint>,
232    pub ports: Vec<PortScan>,
233}
234
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
236#[serde(rename_all = "snake_case")]
237pub enum ScanState {
238    Open,
239    Closed,
240    Filtered,
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct PortScan {
245    pub port: u16,
246    pub state: ScanState,
247    pub served: Option<ServedCert>,
248    pub negotiated: Option<Negotiated>,
249    pub observed_at: Option<DateTime<Utc>>,
250}
251
252#[derive(Debug, Clone, Serialize)]
253pub struct SrvRecord {
254    pub service: String,
255    pub fqdn: String,
256    pub priority: u32,
257    pub weight: u32,
258    pub port: u16,
259    pub target: String,
260}
261#[derive(Debug, Clone, Serialize)]
262pub struct MxRecord {
263    pub priority: u32,
264    pub target: String,
265}
266
267#[derive(Debug, Clone, Serialize)]
268pub struct HttpsRecord {
269    pub rr_type: String,
270    pub priority: u16,
271    pub target: String,
272    pub params: String,
273}
274
275#[derive(Debug, Clone, Serialize)]
276#[serde(rename_all = "snake_case")]
277pub enum Provenance {
278    Fresh,
279    Cached { age_secs: u64 },
280    Stale { age_secs: Option<u64> },
281}
282
283fn hash_norm(raw: &str) -> String {
284    raw.trim().to_ascii_uppercase().replace('-', "")
285}
286
287pub fn alg_class_of(raw: &str) -> String {
288    let lower = raw.trim().to_ascii_lowercase();
289    if lower.contains("ed25519") {
290        "Ed25519".to_string()
291    } else if lower.contains("ed448") {
292        "Ed448".to_string()
293    } else if lower.starts_with("ml-dsa") || lower.contains("mldsa") {
294        raw.trim().to_string()
295    } else if lower.contains("ecdsa") || lower.contains("ecpublickey") || lower == "ec" {
296        "ECDSA".to_string()
297    } else if lower.contains("rsa") {
298        "RSA".to_string()
299    } else {
300        raw.trim().to_string()
301    }
302}
303
304pub fn curve_from_key_size(size: i64) -> Option<&'static str> {
305    match size {
306        256 => Some("P256"),
307        384 => Some("P384"),
308        521 => Some("P521"),
309        _ => None,
310    }
311}
312
313pub fn cert_key_name(
314    alg: Option<&str>,
315    key_size: Option<i64>,
316    curve: Option<&str>,
317) -> Option<String> {
318    let class = alg_class_of(alg?);
319    if class == "ECDSA" {
320        let bits = key_size.or_else(|| {
321            curve.and_then(|c| c.trim().trim_start_matches(['P', 'p', '-']).parse().ok())
322        });
323        return Some(match bits {
324            Some(b) => format!("EC-{b}"),
325            None => "EC".to_string(),
326        });
327    }
328    Some(match key_size {
329        Some(b) => format!("{class}-{b}"),
330        None => class,
331    })
332}
333
334pub fn cert_signature_name(class: &str, hash: Option<&str>, curve: Option<&str>) -> Option<String> {
335    let class = alg_class_of(class);
336    match class.as_str() {
337        "RSA" => Some(match hash.map(hash_norm) {
338            Some(h) => format!("RSA-{h}"),
339            None => "RSA".to_string(),
340        }),
341        "ECDSA" => Some(match curve {
342            Some(c) => format!("ECDSA-{c}"),
343            None => "ECDSA".to_string(),
344        }),
345        other => Some(other.to_string()),
346    }
347}
348
349pub fn cert_signature_from_db(
350    key_algorithm: Option<&str>,
351    key_size: Option<i64>,
352    sig_key_algorithm: Option<&str>,
353    sig_hash_algorithm: Option<&str>,
354) -> Option<String> {
355    let class = sig_key_algorithm.or(key_algorithm)?;
356    let curve = if alg_class_of(class) == "ECDSA" {
357        key_size.and_then(curve_from_key_size)
358    } else {
359        None
360    };
361    cert_signature_name(class, sig_hash_algorithm, curve)
362}
363
364pub fn port_service(port: u16) -> &'static str {
365    match port {
366        25 | 587 => "SMTP",
367        465 => "SMTPS",
368        993 => "IMAPS",
369        995 => "POP3S",
370        636 => "LDAPS",
371        5061 => "SIPS",
372        8883 => "MQTTS",
373        5222 => "XMPP",
374        5223 => "XMPPS",
375        _ => "TLS",
376    }
377}
378
379pub fn symmetric_name(cipher: &str) -> Option<String> {
380    let upper = cipher.to_ascii_uppercase();
381    if upper.contains("CHACHA20") {
382        return Some("CHACHA20-POLY1305".to_string());
383    }
384    let bits = if upper.contains("AES_128") {
385        "AES128"
386    } else if upper.contains("AES_256") {
387        "AES256"
388    } else {
389        return None;
390    };
391    let mode = if upper.contains("GCM") {
392        "GCM"
393    } else if upper.contains("CBC") {
394        "CBC"
395    } else {
396        return None;
397    };
398    Some(format!("{bits}-{mode}"))
399}
400
401#[derive(Debug, Clone, Copy, Default, Serialize)]
402pub struct CtStats {
403    /// Number of raw crt.sh log entries in the snapshot.
404    pub rows: usize,
405    /// Log entries currently valid (not_before <= now <= not_after).
406    pub valid: usize,
407    /// Log entries that are not valid yet.
408    pub future: usize,
409}
410
411#[derive(Debug, Serialize)]
412pub struct DomainState {
413    pub domain: String,
414    pub checked_at: DateTime<Local>,
415    pub provenance: Provenance,
416    pub ct_stats: CtStats,
417    pub certs: BTreeMap<String, Certificate>,
418    pub hosts: BTreeMap<String, Host>,
419    pub srv: Vec<SrvRecord>,
420    pub https: Vec<HttpsRecord>,
421    pub mx: Vec<MxRecord>,
422}
423
424impl DomainState {
425    pub fn host_names(&self) -> Vec<String> {
426        self.hosts.keys().cloned().collect()
427    }
428}
429
430#[cfg(test)]
431pub fn fixture() -> DomainState {
432    use chrono::TimeZone;
433
434    let now = Utc::now();
435    let nb = now - chrono::Duration::days(30);
436    let na = now + chrono::Duration::days(30);
437    let observed = Utc.with_ymd_and_hms(2026, 9, 21, 12, 0, 0).unwrap();
438
439    let mut certs = BTreeMap::new();
440    certs.insert(
441        "aa11".to_string(),
442        Certificate {
443            serial: "aa11".to_string(),
444            in_ct: true,
445            served_live: true,
446            currently_valid: true,
447            ct_ids: vec![9612296601],
448            common_name: Some("www.example.com".to_string()),
449            identities: vec!["example.com".to_string(), "www.example.com".to_string()],
450            not_before: Some(nb),
451            not_after: Some(na),
452            pubkey_alg: Some("RSA".to_string()),
453            sig_alg: Some("SHA256/RSA".to_string()),
454            issuer: Some("CN=DigiCert TLS RSA SHA256 2020 CA-1".to_string()),
455            key_algorithm: Some("RSA".to_string()),
456            key_size: Some(2048),
457            sig_key_algorithm: Some("RSA".to_string()),
458            sig_hash_algorithm: Some("SHA-256".to_string()),
459            cert_signature: Some("RSA-SHA256".to_string()),
460            chain: vec![
461                ChainEntry {
462                    depth: 0,
463                    certificate_id: 9612296601,
464                    common_name: Some("www.example.com".to_string()),
465                    ski: Some("aabb".to_string()),
466                    aki: None,
467                    not_before: Some(nb),
468                    not_after: Some(na),
469                    key_algorithm: Some("RSA".to_string()),
470                    key_size: Some(2048),
471                    signature_key_algorithm: Some("RSA".to_string()),
472                    signature_hash_algorithm: Some("SHA-256".to_string()),
473                    issuer_ca_name: Some("CN=DigiCert TLS RSA SHA256 2020 CA-1".to_string()),
474                },
475                ChainEntry {
476                    depth: 1,
477                    certificate_id: 90000000001,
478                    common_name: Some("DigiCert TLS RSA SHA256 2020 CA-1".to_string()),
479                    ski: Some("bbcc".to_string()),
480                    aki: Some("aabb".to_string()),
481                    not_before: Some(nb),
482                    not_after: Some(na),
483                    key_algorithm: Some("RSA".to_string()),
484                    key_size: Some(2048),
485                    signature_key_algorithm: Some("RSA".to_string()),
486                    signature_hash_algorithm: Some("SHA-256".to_string()),
487                    issuer_ca_name: Some("CN=DigiCert Global Root CA".to_string()),
488                },
489                ChainEntry {
490                    depth: 2,
491                    certificate_id: 90000000002,
492                    common_name: Some("DigiCert Global Root CA".to_string()),
493                    ski: Some("ccdd".to_string()),
494                    aki: Some("bbcc".to_string()),
495                    not_before: Some(nb),
496                    not_after: Some(na),
497                    key_algorithm: Some("RSA".to_string()),
498                    key_size: Some(2048),
499                    signature_key_algorithm: Some("RSA".to_string()),
500                    signature_hash_algorithm: Some("SHA-256".to_string()),
501                    issuer_ca_name: Some("CN=DigiCert Global Root CA".to_string()),
502                },
503            ],
504        },
505    );
506    certs.insert(
507        "cc33".to_string(),
508        Certificate {
509            serial: "cc33".to_string(),
510            in_ct: true,
511            served_live: false,
512            currently_valid: true,
513            ct_ids: vec![9612300001],
514            common_name: Some("eas.example.com".to_string()),
515            identities: vec!["eas.example.com".to_string()],
516            not_before: Some(nb),
517            not_after: Some(na - chrono::Duration::days(10)),
518            pubkey_alg: None,
519            sig_alg: None,
520            issuer: Some("CN=Certum OV TLS G2 R39 CA".to_string()),
521            key_algorithm: Some("RSA".to_string()),
522            key_size: Some(2048),
523            sig_key_algorithm: Some("RSA".to_string()),
524            sig_hash_algorithm: Some("SHA-256".to_string()),
525            cert_signature: Some("RSA-SHA256".to_string()),
526            chain: Vec::new(),
527        },
528    );
529    certs.insert(
530        "bb22".to_string(),
531        Certificate {
532            serial: "bb22".to_string(),
533            in_ct: true,
534            served_live: false,
535            currently_valid: true,
536            ct_ids: vec![9612297416, 9612300000],
537            common_name: Some("*.example.com".to_string()),
538            identities: vec!["*.example.com".to_string()],
539            not_before: Some(nb),
540            not_after: Some(na),
541            pubkey_alg: None,
542            sig_alg: None,
543            issuer: None,
544            key_algorithm: Some("EC".to_string()),
545            key_size: Some(256),
546            sig_key_algorithm: Some("ECDSA".to_string()),
547            sig_hash_algorithm: Some("SHA-256".to_string()),
548            cert_signature: Some("ECDSA-P256".to_string()),
549            chain: Vec::new(),
550        },
551    );
552
553    let mut hosts = BTreeMap::new();
554    let mut www = Host {
555        name: "www.example.com".to_string(),
556        origins: Origins::CT,
557        resolves: Some(true),
558        dns_status: Some(DnsStatus::Ok),
559        ct_refs: vec![CertRef {
560            serial: "aa11".to_string(),
561            kind: MatchKind::Exact,
562        }],
563        dns_refs: Vec::new(),
564        ports: Vec::new(),
565        endpoint: Some(Endpoint {
566            status: ProbeStatus::Ok,
567            negotiated: Some(Negotiated {
568                tls_version: Some("TLS1.3".to_string()),
569                kx_group: Some("X25519MLKEM768".to_string()),
570                cipher: Some("TLS13_AES_256_GCM_SHA384".to_string()),
571            }),
572            ip: Some("104.94.222.171".parse().unwrap()),
573            served: Some(ServedCert {
574                serial: "aa11".to_string(),
575                common_name: Some("www.example.com".to_string()),
576                identities: vec!["example.com".to_string(), "www.example.com".to_string()],
577                pubkey_alg: Some("RSA".to_string()),
578                sig_alg: Some("SHA256/RSA".to_string()),
579                issuer: Some("CN=DigiCert TLS RSA SHA256 2020 CA-1".to_string()),
580                not_before: Some(nb),
581                not_after: Some(na),
582                chain_sigs: vec!["SHA256/RSA".to_string()],
583                pubkey_curve: None,
584                cert_signature: Some("RSA-SHA256".to_string()),
585            }),
586            served_not_in_ct: false,
587            observed_at: Some(observed),
588        }),
589    };
590    www.origins.insert(Origins::DNS);
591    hosts.insert("www.example.com".to_string(), www);
592    hosts.insert(
593        "example.com".to_string(),
594        Host {
595            name: "example.com".to_string(),
596            origins: Origins::APEX,
597            resolves: Some(true),
598            dns_status: Some(DnsStatus::Ok),
599            ct_refs: vec![CertRef {
600                serial: "aa11".to_string(),
601                kind: MatchKind::Exact,
602            }],
603            dns_refs: Vec::new(),
604            ports: Vec::new(),
605            endpoint: Some(Endpoint {
606                status: ProbeStatus::Ok,
607                ip: Some("104.94.222.1".parse().unwrap()),
608                served: Some(ServedCert {
609                    serial: "aa11".to_string(),
610                    common_name: Some("www.example.com".to_string()),
611                    identities: vec!["example.com".to_string(), "www.example.com".to_string()],
612                    pubkey_alg: Some("RSA".to_string()),
613                    sig_alg: Some("SHA256/RSA".to_string()),
614                    issuer: Some("CN=DigiCert TLS RSA SHA256 2020 CA-1".to_string()),
615                    not_before: Some(nb),
616                    not_after: Some(na),
617                    chain_sigs: vec!["SHA256/RSA".to_string()],
618                    pubkey_curve: None,
619                    cert_signature: Some("RSA-SHA256".to_string()),
620                }),
621                served_not_in_ct: false,
622                observed_at: Some(observed),
623                negotiated: Some(Negotiated {
624                    tls_version: Some("TLS1.3".to_string()),
625                    kx_group: Some("X25519MLKEM768".to_string()),
626                    cipher: Some("TLS13_AES_256_GCM_SHA384".to_string()),
627                }),
628            }),
629        },
630    );
631    hosts.insert(
632        "old.example.com".to_string(),
633        Host {
634            name: "old.example.com".to_string(),
635            origins: Origins::CT,
636            resolves: Some(false),
637            dns_status: Some(DnsStatus::NxDomain),
638            ct_refs: vec![CertRef {
639                serial: "bb22".to_string(),
640                kind: MatchKind::Wildcard,
641            }],
642            dns_refs: Vec::new(),
643            ports: Vec::new(),
644            endpoint: Some(Endpoint::failed(ProbeStatus::DnsFailure)),
645        },
646    );
647    hosts.insert(
648        "vpn.example.com".to_string(),
649        Host {
650            name: "vpn.example.com".to_string(),
651            origins: Origins::CT,
652            resolves: Some(true),
653            dns_status: Some(DnsStatus::Ok),
654            ct_refs: vec![CertRef {
655                serial: "bb22".to_string(),
656                kind: MatchKind::Wildcard,
657            }],
658            dns_refs: Vec::new(),
659            ports: Vec::new(),
660            endpoint: Some(Endpoint::failed(ProbeStatus::TcpRefused)),
661        },
662    );
663    hosts.insert(
664        "eas.example.com".to_string(),
665        Host {
666            name: "eas.example.com".to_string(),
667            origins: Origins::CT,
668            resolves: Some(true),
669            dns_status: Some(DnsStatus::Ok),
670            ct_refs: vec![
671                CertRef {
672                    serial: "bb22".to_string(),
673                    kind: MatchKind::Wildcard,
674                },
675                CertRef {
676                    serial: "cc33".to_string(),
677                    kind: MatchKind::Exact,
678                },
679            ],
680            dns_refs: Vec::new(),
681            ports: vec![
682                PortScan {
683                    port: 465,
684                    state: ScanState::Open,
685                    served: Some(ServedCert {
686                        serial: "cc33".to_string(),
687                        common_name: Some("eas.example.com".to_string()),
688                        identities: vec!["eas.example.com".to_string()],
689                        pubkey_alg: Some("RSA".to_string()),
690                        sig_alg: Some("SHA256/RSA".to_string()),
691                        issuer: Some("CN=Certum OV TLS G2 R39 CA".to_string()),
692                        not_before: Some(nb),
693                        not_after: Some(na - chrono::Duration::days(10)),
694                        chain_sigs: vec!["SHA256/RSA".to_string()],
695                        pubkey_curve: None,
696                        cert_signature: Some("RSA-SHA256".to_string()),
697                    }),
698                    negotiated: Some(Negotiated {
699                        tls_version: Some("TLS1.3".to_string()),
700                        kx_group: Some("X25519".to_string()),
701                        cipher: Some("TLS_AES_256_GCM_SHA384".to_string()),
702                    }),
703                    observed_at: Some(observed),
704                },
705                PortScan {
706                    port: 25,
707                    state: ScanState::Open,
708                    served: Some(ServedCert {
709                        serial: "cc33".to_string(),
710                        common_name: Some("eas.example.com".to_string()),
711                        identities: vec!["eas.example.com".to_string()],
712                        pubkey_alg: Some("RSA".to_string()),
713                        sig_alg: Some("SHA256/RSA".to_string()),
714                        issuer: Some("CN=Certum OV TLS G2 R39 CA".to_string()),
715                        not_before: Some(nb),
716                        not_after: Some(na - chrono::Duration::days(10)),
717                        chain_sigs: vec!["SHA256/RSA".to_string()],
718                        pubkey_curve: None,
719                        cert_signature: Some("RSA-SHA256".to_string()),
720                    }),
721                    negotiated: Some(Negotiated {
722                        tls_version: Some("TLS1.3".to_string()),
723                        kx_group: Some("X25519".to_string()),
724                        cipher: Some("TLS_AES_256_GCM_SHA384".to_string()),
725                    }),
726                    observed_at: Some(observed),
727                },
728            ],
729            endpoint: Some(Endpoint::failed(ProbeStatus::TcpRefused)),
730        },
731    );
732    let mut apex = hosts.get("example.com").cloned().unwrap();
733    apex.origins.insert(Origins::CT);
734    hosts.insert("example.com".to_string(), apex);
735
736    DomainState {
737        domain: "example.com".to_string(),
738        checked_at: Local.with_ymd_and_hms(2026, 9, 21, 12, 0, 0).unwrap(),
739        provenance: Provenance::Cached { age_secs: 2520 },
740        certs,
741        hosts,
742        mx: vec![MxRecord {
743            priority: 10,
744            target: "eas.example.com".to_string(),
745        }],
746        srv: vec![SrvRecord {
747            service: "_sip._tls".to_string(),
748            fqdn: "_sip._tls.example.com".to_string(),
749            priority: 1,
750            weight: 100,
751            port: 443,
752            target: "sipdir.online.lync.com".to_string(),
753        }],
754        https: vec![HttpsRecord {
755            rr_type: "HTTPS".to_string(),
756            priority: 1,
757            target: ".".to_string(),
758            params: "alpn=h2,h3".to_string(),
759        }],
760        ct_stats: CtStats {
761            rows: 2,
762            valid: 1,
763            future: 1,
764        },
765    }
766}
767
768#[cfg(test)]
769mod tests {
770    use super::*;
771
772    #[test]
773    fn cert_signature_names_are_canonical() {
774        assert_eq!(
775            cert_signature_from_db(Some("RSA"), Some(2048), Some("RSA"), Some("SHA-256")),
776            Some("RSA-SHA256".to_string())
777        );
778        assert_eq!(
779            cert_signature_from_db(Some("EC"), Some(256), Some("ECDSA"), Some("SHA-256")),
780            Some("ECDSA-P256".to_string())
781        );
782        assert_eq!(
783            cert_signature_from_db(Some("EC"), Some(384), Some("ECDSA"), Some("SHA-384")),
784            Some("ECDSA-P384".to_string())
785        );
786        assert_eq!(
787            cert_signature_name("rsaEncryption", Some("sha256"), None),
788            Some("RSA-SHA256".to_string())
789        );
790        assert_eq!(
791            cert_signature_name("id-ecPublicKey", Some("ecdsa-with-SHA384"), Some("P384")),
792            Some("ECDSA-P384".to_string())
793        );
794        assert_eq!(
795            cert_signature_name("Ed25519", None, None),
796            Some("Ed25519".to_string())
797        );
798        assert_eq!(
799            cert_signature_name("ML-DSA-65", None, None),
800            Some("ML-DSA-65".to_string())
801        );
802        assert_eq!(
803            cert_signature_name("1.2.3.4.5", None, None),
804            Some("1.2.3.4.5".to_string())
805        );
806        assert_eq!(cert_signature_from_db(None, None, None, None), None);
807    }
808
809    #[test]
810    fn curve_from_key_size_maps_nist_curves() {
811        assert_eq!(curve_from_key_size(256), Some("P256"));
812        assert_eq!(curve_from_key_size(384), Some("P384"));
813        assert_eq!(curve_from_key_size(521), Some("P521"));
814        assert_eq!(curve_from_key_size(2048), None);
815        assert_eq!(curve_from_key_size(512), None);
816    }
817
818    #[test]
819    fn cert_key_names_are_canonical() {
820        assert_eq!(
821            cert_key_name(Some("RSA"), Some(2048), None),
822            Some("RSA-2048".to_string())
823        );
824        assert_eq!(
825            cert_key_name(Some("EC"), Some(256), None),
826            Some("EC-256".to_string())
827        );
828        assert_eq!(
829            cert_key_name(Some("EC"), Some(384), None),
830            Some("EC-384".to_string())
831        );
832        assert_eq!(
833            cert_key_name(Some("ECDSA"), None, Some("P256")),
834            Some("EC-256".to_string())
835        );
836        assert_eq!(
837            cert_key_name(Some("id-ecPublicKey"), None, Some("P521")),
838            Some("EC-521".to_string())
839        );
840        assert_eq!(
841            cert_key_name(Some("RSA"), None, None),
842            Some("RSA".to_string())
843        );
844        assert_eq!(
845            cert_key_name(Some("Ed25519"), None, None),
846            Some("Ed25519".to_string())
847        );
848        assert_eq!(cert_key_name(None, None, None), None);
849    }
850
851    #[test]
852    fn symmetric_names_from_suite_names() {
853        assert_eq!(
854            symmetric_name("TLS13_AES_256_GCM_SHA384"),
855            Some("AES256-GCM".to_string())
856        );
857        assert_eq!(
858            symmetric_name("TLS_AES_128_GCM_SHA256"),
859            Some("AES128-GCM".to_string())
860        );
861        assert_eq!(
862            symmetric_name("TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"),
863            Some("AES128-GCM".to_string())
864        );
865        assert_eq!(
866            symmetric_name("TLS13_CHACHA20_POLY1305_SHA256"),
867            Some("CHACHA20-POLY1305".to_string())
868        );
869        assert_eq!(
870            symmetric_name("TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384"),
871            Some("AES256-CBC".to_string())
872        );
873        assert_eq!(symmetric_name("TLS_UNKNOWN"), None);
874    }
875
876    #[test]
877    fn fixture_facts_are_consistent() {
878        let state = fixture();
879        let aa = &state.certs["aa11"];
880        assert_eq!(aa.cert_signature.as_deref(), Some("RSA-SHA256"));
881        assert_eq!(aa.key_size, Some(2048));
882        let bb = &state.certs["bb22"];
883        assert_eq!(bb.cert_signature.as_deref(), Some("ECDSA-P256"));
884    }
885}