Skip to main content

seer_core/status/
client.rs

1use std::collections::HashSet;
2use std::net::SocketAddr;
3use std::time::Duration;
4
5use chrono::Utc;
6use native_tls::TlsConnector;
7use once_cell::sync::Lazy;
8use regex::Regex;
9use reqwest::{Client, Url};
10use tokio::net::TcpStream;
11use tracing::{debug, instrument};
12
13use super::types::{CertificateInfo, DnsResolution, DomainExpiration, StatusResponse};
14use crate::caa::{self, CaaPolicy};
15use crate::dns::{DnsResolver, RecordData, RecordType};
16use crate::error::{Result, SeerError};
17use crate::lookup::SmartLookup;
18use crate::validation::{describe_reserved_ip, normalize_domain};
19
20/// Default timeout for HTTP and TLS operations (10 seconds).
21/// Balances responsiveness with allowing slow servers to respond.
22const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
23const MAX_REDIRECTS: usize = 5;
24
25/// Pre-compiled regex for extracting HTML title.
26static TITLE_REGEX: Lazy<Regex> = Lazy::new(|| {
27    Regex::new(r"(?i)<title[^>]*>([^<]+)</title>").expect("Invalid regex for HTML title extraction")
28});
29
30/// Client for checking domain status (HTTP, SSL, expiration)
31#[derive(Debug, Clone)]
32pub struct StatusClient {
33    timeout: Duration,
34    /// Cached DNS resolver reused across status checks.
35    dns_resolver: DnsResolver,
36    /// Reusable SmartLookup for domain expiration checks.
37    smart_lookup: SmartLookup,
38}
39
40impl Default for StatusClient {
41    fn default() -> Self {
42        Self::new()
43    }
44}
45
46impl StatusClient {
47    /// Creates a new StatusClient with default settings.
48    pub fn new() -> Self {
49        Self {
50            timeout: DEFAULT_TIMEOUT,
51            dns_resolver: DnsResolver::new(),
52            smart_lookup: SmartLookup::new(),
53        }
54    }
55
56    /// Sets the timeout for HTTP and TLS operations.
57    pub fn with_timeout(mut self, timeout: Duration) -> Self {
58        self.timeout = timeout;
59        self
60    }
61
62    /// Checks the status of a domain (HTTP, SSL, expiration, DNS).
63    #[instrument(skip(self), fields(domain = %domain))]
64    pub async fn check(&self, domain: &str) -> Result<StatusResponse> {
65        // Normalize domain format (doesn't require DNS resolution)
66        let domain = normalize_domain(domain)?;
67        debug!("Checking status for domain: {}", domain);
68
69        let mut response = StatusResponse::new(domain.clone());
70
71        // Fetch HTTP status, SSL cert info, domain expiration, DNS
72        // resolution, and CAA policy concurrently. HTTP and SSL checks
73        // include SSRF protection internally; CAA never fails the request
74        // (a resolver error yields an empty policy).
75        let (http_result, cert_result, expiry_result, dns_result, caa_policy) = tokio::join!(
76            self.fetch_http_info(&domain),
77            self.fetch_certificate_info(&domain),
78            self.fetch_domain_expiration(&domain),
79            self.fetch_dns_resolution(&domain),
80            caa::lookup_caa(&self.dns_resolver, &domain),
81        );
82
83        // Apply HTTP info
84        match http_result {
85            Ok((status, status_text, title)) => {
86                response.http_status = Some(status);
87                response.http_status_text = Some(status_text);
88                response.title = title;
89            }
90            Err(e) => response.errors.push(super::types::StatusError {
91                check: "http".to_string(),
92                message: e.to_string(),
93            }),
94        }
95
96        // Apply certificate info and tag the CAA policy with the issuer
97        // comparison if a cert was retrieved.
98        let mut caa_policy: CaaPolicy = caa_policy;
99        match cert_result {
100            Ok(cert_info) => {
101                caa_policy.issuer_match =
102                    Some(caa::classify_issuer(&cert_info.issuer, &caa_policy));
103                response.certificate = Some(cert_info);
104            }
105            Err(e) => response.errors.push(super::types::StatusError {
106                check: "ssl".to_string(),
107                message: e.to_string(),
108            }),
109        }
110        response.caa = Some(caa_policy);
111
112        // Apply domain expiration info
113        match expiry_result {
114            Ok(expiry_info) => response.domain_expiration = expiry_info,
115            Err(e) => response.errors.push(super::types::StatusError {
116                check: "expiration".to_string(),
117                message: e.to_string(),
118            }),
119        }
120
121        // Apply DNS resolution info
122        match dns_result {
123            Ok(dns_info) => response.dns_resolution = Some(dns_info),
124            Err(e) => response.errors.push(super::types::StatusError {
125                check: "dns".to_string(),
126                message: e.to_string(),
127            }),
128        }
129
130        Ok(response)
131    }
132
133    /// Fetches the HTTP status code and page title.
134    ///
135    /// Redirects are followed manually with IP validation at each hop.
136    /// Resolved IPs are pinned on the HTTP client via `resolve_to_addrs` to
137    /// prevent DNS rebinding attacks (TOCTOU between validation and connect).
138    ///
139    /// # Security Note
140    /// This path uses reqwest's default (validating) TLS configuration — a
141    /// bad certificate surfaces as a typed `SeerError::HttpError` and the
142    /// status check reports it as a failed "http" sub-check instead of
143    /// silently returning attacker-controlled body content as "successful".
144    /// The SSL inspection path in `ssl.rs` (and `fetch_certificate_info`
145    /// below) intentionally relaxes verification because inspecting an
146    /// invalid cert is the whole point of that code; this path MUST NOT.
147    ///
148    /// Redirect targets are validated for SSRF but the HTTP response body
149    /// (page title) comes from an unauthenticated connection and should be
150    /// treated as untrusted.
151    async fn fetch_http_info(&self, domain: &str) -> Result<(u16, String, Option<String>)> {
152        let mut url = Url::parse(&format!("https://{}", domain))
153            .map_err(|e| SeerError::HttpError(format!("invalid URL: {}", e)))?;
154        let mut visited = HashSet::new();
155
156        for _ in 0..=MAX_REDIRECTS {
157            let validated_addrs = validate_url_target(&url).await?;
158
159            if !visited.insert(url.clone()) {
160                return Err(SeerError::HttpError("redirect loop detected".to_string()));
161            }
162
163            // Build a per-hop client that pins the validated IPs so reqwest
164            // cannot re-resolve the hostname to a different (potentially
165            // private) address (DNS rebinding protection).
166            let host = url
167                .host_str()
168                .ok_or_else(|| SeerError::HttpError("missing URL host".to_string()))?;
169            let client = Client::builder()
170                .redirect(reqwest::redirect::Policy::none())
171                .user_agent(concat!("Seer/", env!("CARGO_PKG_VERSION")))
172                .resolve_to_addrs(host, &validated_addrs)
173                .build()
174                .map_err(|e| SeerError::HttpError(format!("failed to build HTTP client: {}", e)))?;
175
176            let response = client
177                .get(url.clone())
178                .timeout(self.timeout)
179                .send()
180                .await
181                .map_err(|e| SeerError::HttpError(e.to_string()))?;
182
183            if response.status().is_redirection() {
184                let location = response.headers().get(reqwest::header::LOCATION);
185                let location = location.and_then(|v| v.to_str().ok()).ok_or_else(|| {
186                    SeerError::HttpError("redirect missing location header".to_string())
187                })?;
188                let next_url = url
189                    .join(location)
190                    .or_else(|_| Url::parse(location))
191                    .map_err(|e| SeerError::HttpError(format!("invalid redirect URL: {}", e)))?;
192                url = next_url;
193                continue;
194            }
195
196            let status = response.status();
197            let status_code = status.as_u16();
198            let status_text = status.canonical_reason().unwrap_or("Unknown").to_string();
199
200            // Only try to get title for successful HTML responses
201            let title = if status.is_success() {
202                let content_type = response
203                    .headers()
204                    .get("content-type")
205                    .and_then(|v| v.to_str().ok())
206                    .unwrap_or("");
207
208                if content_type.contains("text/html") {
209                    // Stream at most 64 KB for title extraction. Streaming
210                    // (rather than `response.bytes().await`) prevents a
211                    // malicious server from forcing us to buffer a huge
212                    // body before the cap is applied.
213                    const MAX_TITLE_BODY: usize = 64 * 1024;
214                    use futures::StreamExt;
215                    let mut buf: Vec<u8> = Vec::with_capacity(8 * 1024);
216                    let mut stream = response.bytes_stream();
217                    while let Some(chunk) = stream.next().await {
218                        let chunk = chunk
219                            .map_err(|e| SeerError::HttpError(format!("body chunk: {}", e)))?;
220                        let remaining = MAX_TITLE_BODY.saturating_sub(buf.len());
221                        if remaining == 0 {
222                            break;
223                        }
224                        let take = remaining.min(chunk.len());
225                        buf.extend_from_slice(&chunk[..take]);
226                        if buf.len() >= MAX_TITLE_BODY {
227                            break;
228                        }
229                    }
230                    let body = String::from_utf8_lossy(&buf);
231                    extract_title(&body)
232                } else {
233                    None
234                }
235            } else {
236                None
237            };
238
239            return Ok((status_code, status_text, title));
240        }
241
242        Err(SeerError::HttpError("too many redirects".to_string()))
243    }
244
245    /// Fetches SSL certificate information using native-tls.
246    ///
247    /// # Security Note
248    /// This connection uses `danger_accept_invalid_certs(true)` to inspect certificates
249    /// even when invalid. Data retrieved (issuer, subject, dates) comes from an
250    /// unauthenticated TLS connection and may have been tampered with by a MITM.
251    async fn fetch_certificate_info(&self, domain: &str) -> Result<CertificateInfo> {
252        // SSRF protection: resolve and reject reserved IPs before connecting.
253        // Use crate::net::resolve_public_host so we get the Hickory fallback
254        // when the OS resolver is broken (corporate Macs, Tailscale split-DNS,
255        // etc.) — the same path every other outbound-connect uses.
256        let socket_addrs = crate::net::resolve_public_host(domain, 443)
257            .await
258            .map_err(|e| SeerError::CertificateError(e.to_string()))?;
259
260        let connector = TlsConnector::builder()
261            .danger_accept_invalid_certs(true) // We want to see the cert even if invalid
262            .build()
263            .map_err(|e| SeerError::CertificateError(e.to_string()))?;
264
265        let connector = tokio_native_tls::TlsConnector::from(connector);
266
267        // Connect directly to the validated socket address to prevent DNS
268        // rebinding (TOCTOU) between validation and connect.
269        let stream =
270            tokio::time::timeout(self.timeout, TcpStream::connect(socket_addrs.as_slice()))
271                .await
272                .map_err(|_| SeerError::Timeout(format!("connection to {} timed out", domain)))?
273                .map_err(|e| SeerError::CertificateError(e.to_string()))?;
274
275        // Use the domain as SNI hostname for the TLS handshake.
276        let tls_stream = tokio::time::timeout(self.timeout, connector.connect(domain, stream))
277            .await
278            .map_err(|_| SeerError::Timeout(format!("TLS handshake with {} timed out", domain)))?
279            .map_err(|e| SeerError::CertificateError(e.to_string()))?;
280
281        // Get the peer certificate
282        let cert = tls_stream
283            .get_ref()
284            .peer_certificate()
285            .map_err(|e| SeerError::CertificateError(e.to_string()))?
286            .ok_or_else(|| SeerError::CertificateError("no certificate found".to_string()))?;
287
288        // Parse certificate info
289        let der = cert
290            .to_der()
291            .map_err(|e| SeerError::CertificateError(e.to_string()))?;
292
293        parse_certificate_der(&der, domain)
294    }
295
296    /// Fetches domain expiration info using WHOIS/RDAP.
297    async fn fetch_domain_expiration(&self, domain: &str) -> Result<Option<DomainExpiration>> {
298        match self.smart_lookup.lookup(domain).await {
299            Ok(result) => {
300                let (expiration_date, registrar) = result.expiration_info();
301
302                if let Some(exp_date) = expiration_date {
303                    let days_until_expiry = (exp_date - Utc::now()).num_days();
304                    Ok(Some(DomainExpiration {
305                        expiration_date: exp_date,
306                        days_until_expiry,
307                        registrar,
308                    }))
309                } else {
310                    Ok(None)
311                }
312            }
313            Err(_) => Ok(None), // Don't fail the whole status check if WHOIS fails
314        }
315    }
316
317    /// Fetches DNS root record resolution (A, AAAA, CNAME, NS).
318    async fn fetch_dns_resolution(&self, domain: &str) -> Result<DnsResolution> {
319        let resolver = &self.dns_resolver;
320
321        // Query all record types concurrently
322        let (a_result, aaaa_result, cname_result, ns_result) = tokio::join!(
323            resolver.resolve(domain, RecordType::A, None),
324            resolver.resolve(domain, RecordType::AAAA, None),
325            resolver.resolve(domain, RecordType::CNAME, None),
326            resolver.resolve(domain, RecordType::NS, None)
327        );
328
329        // Extract A records
330        let a_records: Vec<String> = a_result
331            .unwrap_or_default()
332            .into_iter()
333            .filter_map(|r| {
334                if let RecordData::A { address } = r.data {
335                    Some(address)
336                } else {
337                    None
338                }
339            })
340            .collect();
341
342        // Extract AAAA records
343        let aaaa_records: Vec<String> = aaaa_result
344            .unwrap_or_default()
345            .into_iter()
346            .filter_map(|r| {
347                if let RecordData::AAAA { address } = r.data {
348                    Some(address)
349                } else {
350                    None
351                }
352            })
353            .collect();
354
355        // Extract CNAME target (trim trailing dot)
356        let cname_target: Option<String> =
357            cname_result.unwrap_or_default().into_iter().find_map(|r| {
358                if let RecordData::CNAME { target } = r.data {
359                    Some(target.trim_end_matches('.').to_string())
360                } else {
361                    None
362                }
363            });
364
365        // Extract NS records (trim trailing dots)
366        let nameservers: Vec<String> = ns_result
367            .unwrap_or_default()
368            .into_iter()
369            .filter_map(|r| {
370                if let RecordData::NS { nameserver } = r.data {
371                    Some(nameserver.trim_end_matches('.').to_string())
372                } else {
373                    None
374                }
375            })
376            .collect();
377
378        // Domain resolves if it has A/AAAA records or a CNAME
379        let resolves = !a_records.is_empty() || !aaaa_records.is_empty() || cname_target.is_some();
380
381        Ok(DnsResolution {
382            a_records,
383            aaaa_records,
384            cname_target,
385            nameservers,
386            resolves,
387        })
388    }
389}
390
391// Domain normalization and validation is now handled by the validation module
392
393/// Extracts the title from HTML content.
394///
395/// Strips ASCII control characters (NUL, ESC, etc.) at extraction time so
396/// the value is safe for every downstream sink — JSON (which would happily
397/// encode `` and pass it to an LLM via the MCP server), the human
398/// formatter (which sanitises again at render time), and the bulk-CSV
399/// writer. Without the strip, a crafted `<title>Foo\x00Bar</title>` reaches
400/// the LLM context window.
401fn extract_title(html: &str) -> Option<String> {
402    TITLE_REGEX
403        .captures(html)
404        .and_then(|caps| caps.get(1))
405        .map(|m| {
406            // Strip ALL control characters. A raw `\n` or `\t` inside a
407            // `<title>` element is meaningless HTML whitespace (browsers
408            // collapse it to a single space); preserving them would
409            // produce multi-line JSON field values and break CSV column
410            // alignment downstream.
411            m.as_str()
412                .chars()
413                .filter(|c| !c.is_control())
414                .collect::<String>()
415                .trim()
416                .to_string()
417        })
418        .filter(|s| !s.is_empty())
419}
420
421/// Validates that a URL target is safe (no private/reserved IPs, no credentials,
422/// supported scheme) and returns the resolved socket addresses.
423///
424/// The caller should pin these addresses on the HTTP client to prevent DNS
425/// rebinding between validation and the actual connection.
426async fn validate_url_target(url: &Url) -> Result<Vec<SocketAddr>> {
427    let scheme = url.scheme();
428    if scheme != "https" && scheme != "http" {
429        return Err(SeerError::HttpError(format!(
430            "unsupported URL scheme: {}",
431            scheme
432        )));
433    }
434
435    if !url.username().is_empty() || url.password().is_some() {
436        return Err(SeerError::HttpError(
437            "URL credentials are not allowed".to_string(),
438        ));
439    }
440
441    let host = url
442        .host_str()
443        .ok_or_else(|| SeerError::HttpError("missing URL host".to_string()))?;
444    let port = url.port_or_known_default().unwrap_or(443);
445
446    // Only allow standard HTTP/HTTPS ports to prevent port scanning via redirects
447    if port != 80 && port != 443 {
448        return Err(SeerError::HttpError(format!(
449            "non-standard port {} is not allowed in redirects",
450            port
451        )));
452    }
453
454    if let Ok(ip) = host.parse::<std::net::IpAddr>() {
455        if let Some(reason) = describe_reserved_ip(&ip) {
456            return Err(SeerError::HttpError(format!(
457                "cannot connect to {}: {} — {}",
458                host, ip, reason
459            )));
460        }
461        return Ok(vec![SocketAddr::new(ip, port)]);
462    }
463
464    let addr = format!("{}:{}", host, port);
465    let socket_addrs: Vec<_> = tokio::net::lookup_host(&addr)
466        .await
467        .map_err(|e| SeerError::HttpError(format!("DNS lookup failed: {}", e)))?
468        .collect();
469
470    if socket_addrs.is_empty() {
471        return Err(SeerError::HttpError(format!(
472            "DNS lookup returned no addresses for {}",
473            host
474        )));
475    }
476
477    for socket_addr in &socket_addrs {
478        if let Some(reason) = describe_reserved_ip(&socket_addr.ip()) {
479            return Err(SeerError::HttpError(format!(
480                "cannot connect to {}: {} — {}",
481                host,
482                socket_addr.ip(),
483                reason
484            )));
485        }
486    }
487
488    Ok(socket_addrs)
489}
490
491/// Parses certificate information from DER-encoded certificate using x509-parser.
492fn parse_certificate_der(der: &[u8], domain: &str) -> Result<CertificateInfo> {
493    use x509_parser::prelude::*;
494
495    let (_, cert) = X509Certificate::from_der(der)
496        .map_err(|e| SeerError::CertificateError(format!("failed to parse certificate: {}", e)))?;
497
498    // Extract issuer — combine CN and O when both exist. Intermediate CA
499    // certs commonly have a short CN like "E7" or "R3"; without the
500    // organization the human-readable name is unhelpful and the CAA
501    // comparison cannot match the CA's well-known name.
502    let issuer = format_issuer_name(cert.issuer()).unwrap_or_else(|| "Unknown Issuer".to_string());
503
504    // Extract subject - prefer CN, fall back to O (Organization)
505    let subject =
506        extract_name_from_x509(cert.subject()).unwrap_or_else(|| "Unknown Subject".to_string());
507
508    // Extract validity dates
509    let valid_from = asn1_time_to_chrono(cert.validity().not_before)?;
510    let valid_until = asn1_time_to_chrono(cert.validity().not_after)?;
511
512    let now = Utc::now();
513    let days_until_expiry = (valid_until - now).num_days();
514    let is_valid = now >= valid_from && now <= valid_until;
515
516    // Hostname verification is performed manually because the TLS connector
517    // was configured with danger_accept_invalid_certs(true) to allow cert
518    // inspection on mildly-broken sites. Without this check any cert — even
519    // one issued for an unrelated domain — would be accepted.
520    let hostname_verified = cert_matches_hostname(&cert, domain);
521
522    Ok(CertificateInfo {
523        issuer,
524        subject,
525        valid_from,
526        valid_until,
527        days_until_expiry,
528        is_valid,
529        hostname_verified,
530    })
531}
532
533/// Matches a hostname against a certificate name pattern.
534///
535/// Supports exact matches (case-insensitive) and single-label wildcards
536/// per RFC 6125 — `*.example.com` matches `a.example.com` but not
537/// `example.com` or `a.b.example.com`.
538fn hostname_matches_pattern(host: &str, pattern: &str) -> bool {
539    let host = host.to_ascii_lowercase();
540    let pattern = pattern.to_ascii_lowercase();
541    if let Some(rest) = pattern.strip_prefix("*.") {
542        // Wildcard: must match exactly one label, and must contain a dot
543        let Some(dot) = host.find('.') else {
544            return false;
545        };
546        let host_rest = &host[dot + 1..];
547        host_rest == rest
548    } else {
549        host == pattern
550    }
551}
552
553/// Checks whether a certificate's SAN dNSName entries (or CN as fallback)
554/// match the queried hostname.
555///
556/// Per RFC 6125, SAN dNSName is the authoritative source; CN is only checked
557/// as a legacy fallback.
558fn cert_matches_hostname(cert: &x509_parser::certificate::X509Certificate<'_>, host: &str) -> bool {
559    use x509_parser::prelude::*;
560
561    // SAN dNSName entries (preferred per RFC 6125)
562    if let Ok(Some(san_ext)) = cert.tbs_certificate.subject_alternative_name() {
563        for name in &san_ext.value.general_names {
564            if let GeneralName::DNSName(n) = name {
565                if hostname_matches_pattern(host, n) {
566                    return true;
567                }
568            }
569        }
570    }
571
572    // CN fallback (legacy)
573    for cn in cert.subject().iter_common_name() {
574        if let Ok(s) = cn.as_str() {
575            if hostname_matches_pattern(host, s) {
576                return true;
577            }
578        }
579    }
580
581    false
582}
583
584/// Builds a human-readable issuer label, combining Organization and Common
585/// Name when both exist. Used for the cert's issuer rather than the bare
586/// CN so users see "Let's Encrypt (E7)" rather than "E7".
587fn format_issuer_name(name: &x509_parser::prelude::X509Name) -> Option<String> {
588    use x509_parser::oid_registry;
589    let cn = extract_oid_value(name, &oid_registry::OID_X509_COMMON_NAME);
590    let org = extract_oid_value(name, &oid_registry::OID_X509_ORGANIZATION_NAME);
591    match (org, cn) {
592        (Some(o), Some(c)) if o != c => Some(format!("{} ({})", o, c)),
593        (Some(o), _) => Some(o),
594        (None, Some(c)) => Some(c),
595        (None, None) => None,
596    }
597}
598
599/// Pulls the first attribute matching `oid` out of an X.509 name.
600fn extract_oid_value(
601    name: &x509_parser::prelude::X509Name,
602    oid: &x509_parser::der_parser::oid::Oid<'static>,
603) -> Option<String> {
604    for rdn in name.iter() {
605        for attr in rdn.iter() {
606            if attr.attr_type() == oid {
607                if let Some(s) = extract_attr_string(attr.attr_value()) {
608                    return Some(s);
609                }
610            }
611        }
612    }
613    None
614}
615
616/// Extracts the Common Name or Organization from an X.509 name.
617fn extract_name_from_x509(name: &x509_parser::prelude::X509Name) -> Option<String> {
618    use x509_parser::prelude::*;
619
620    // Try Common Name first (OID 2.5.4.3)
621    for rdn in name.iter() {
622        for attr in rdn.iter() {
623            if attr.attr_type() == &oid_registry::OID_X509_COMMON_NAME {
624                if let Some(s) = extract_attr_string(attr.attr_value()) {
625                    return Some(s);
626                }
627            }
628        }
629    }
630
631    // Fall back to Organization (OID 2.5.4.10)
632    for rdn in name.iter() {
633        for attr in rdn.iter() {
634            if attr.attr_type() == &oid_registry::OID_X509_ORGANIZATION_NAME {
635                if let Some(s) = extract_attr_string(attr.attr_value()) {
636                    return Some(s);
637                }
638            }
639        }
640    }
641
642    None
643}
644
645/// Extracts a string from an ASN.1 attribute value, handling different encodings.
646fn extract_attr_string(value: &x509_parser::der_parser::asn1_rs::Any) -> Option<String> {
647    // Try as_str() first (handles PrintableString, IA5String, etc.)
648    if let Ok(s) = value.as_str() {
649        return Some(s.to_string());
650    }
651
652    // Try UTF8String explicitly
653    if let Ok(utf8) = value.as_utf8string() {
654        return Some(utf8.string().to_string());
655    }
656
657    // Try raw bytes as UTF-8
658    if let Ok(s) = std::str::from_utf8(value.data) {
659        return Some(s.to_string());
660    }
661
662    None
663}
664
665/// Converts an x509-parser ASN1Time to a chrono DateTime.
666fn asn1_time_to_chrono(time: x509_parser::time::ASN1Time) -> Result<chrono::DateTime<Utc>> {
667    let timestamp = time.timestamp();
668    chrono::DateTime::from_timestamp(timestamp, 0)
669        .ok_or_else(|| SeerError::CertificateError("invalid certificate timestamp".to_string()))
670}
671
672#[cfg(test)]
673mod tests {
674    use super::*;
675
676    #[test]
677    fn hostname_matches_pattern_exact() {
678        assert!(hostname_matches_pattern("example.com", "example.com"));
679        assert!(hostname_matches_pattern("EXAMPLE.COM", "example.com"));
680        assert!(hostname_matches_pattern("example.com", "EXAMPLE.COM"));
681        assert!(!hostname_matches_pattern("evil.com", "example.com"));
682        assert!(!hostname_matches_pattern("example.com", "evil.com"));
683    }
684
685    #[test]
686    fn hostname_matches_pattern_wildcard() {
687        assert!(hostname_matches_pattern("a.example.com", "*.example.com"));
688        assert!(hostname_matches_pattern("A.EXAMPLE.COM", "*.example.com"));
689        // Apex must not match wildcard (RFC 6125)
690        assert!(!hostname_matches_pattern("example.com", "*.example.com"));
691        // Wildcard only covers a single label
692        assert!(!hostname_matches_pattern(
693            "a.b.example.com",
694            "*.example.com"
695        ));
696        assert!(!hostname_matches_pattern("b.other.com", "*.example.com"));
697    }
698
699    #[test]
700    fn hostname_matches_pattern_wildcard_requires_dot() {
701        // A bare host with no dot cannot match a wildcard pattern
702        assert!(!hostname_matches_pattern("localhost", "*.example.com"));
703    }
704}