Skip to main content

seer_core/status/
client.rs

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