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