Skip to main content

seer_core/
doctor.rs

1//! Environment self-diagnosis for `seer doctor`: config file health, DNS
2//! resolution, WHOIS port-43 reachability, and RDAP bootstrap (outbound
3//! HTTPS) reachability.
4//!
5//! [`Doctor::run`] executes all four checks concurrently (`tokio::join!`);
6//! each probe is individually timeout-bounded, and one failing probe never
7//! aborts the others. Results fold into a [`DoctorReport`] whose `overall`
8//! status is the worst individual result. A malformed config file is a
9//! [`CheckStatus::Warn`] — seer still works on built-in defaults — while an
10//! unreachable network dependency is a [`CheckStatus::Fail`].
11//!
12//! Probe endpoints are injectable through `#[cfg(test)]`-only seams
13//! (mirroring the `allowing_private_hosts`/`with_port` pattern on the
14//! protocol clients) so the hermetic tests run entirely against loopback
15//! fixtures. The seams do not exist in release builds: production probes
16//! always target the real, hardcoded endpoints below.
17
18use std::net::SocketAddr;
19use std::path::PathBuf;
20use std::time::{Duration, Instant};
21
22use serde::{Deserialize, Serialize};
23use tokio::io::{AsyncReadExt, AsyncWriteExt};
24use tokio::net::TcpStream;
25use tokio::time::timeout;
26
27use crate::config::SeerConfig;
28use crate::dns::{DnsResolver, RecordType};
29use crate::http::{read_body_capped, BodyReadError, Overflow};
30use crate::rdap::MAX_BOOTSTRAP_SIZE;
31
32/// IANA RDAP bootstrap registry for DNS. Mirrors the private
33/// `IANA_BOOTSTRAP_DNS` const in `rdap/client.rs` — keep the two in sync.
34const IANA_BOOTSTRAP_DNS: &str = "https://data.iana.org/rdap/dns.json";
35
36/// Default WHOIS probe target: IANA's root WHOIS server (reachable for every
37/// TLD lookup seer performs, so it is the canonical port-43 reachability
38/// signal).
39const DEFAULT_WHOIS_ADDR: &str = "whois.iana.org:43";
40
41/// Well-known name resolved by the DNS probe and sent as the WHOIS query
42/// line. `example.com` is IANA-reserved and permanently registered.
43const DEFAULT_PROBE_DOMAIN: &str = "example.com";
44
45/// Per-probe deadline. Deliberately independent of the user-tunable protocol
46/// timeouts: a diagnosis should answer quickly even when the user has
47/// configured generous lookup timeouts.
48const DEFAULT_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
49
50/// Check names as they appear in [`DoctorCheck::name`].
51const CHECK_CONFIG: &str = "config";
52const CHECK_DNS: &str = "dns";
53const CHECK_WHOIS: &str = "whois";
54const CHECK_RDAP_BOOTSTRAP: &str = "rdap-bootstrap";
55
56/// Outcome of a single diagnostic check.
57///
58/// Variant order is the severity order [`DoctorReport::from_checks`]
59/// aggregates by (`Ord`: `Pass < Warn < Fail`) — keep it that way.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
61#[serde(rename_all = "lowercase")]
62pub enum CheckStatus {
63    /// The check succeeded.
64    Pass,
65    /// Degraded but non-blocking (e.g. malformed config: seer runs on defaults).
66    Warn,
67    /// The checked dependency is unusable (timeout, refusal, bad response).
68    Fail,
69}
70
71impl std::fmt::Display for CheckStatus {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        f.write_str(match self {
74            CheckStatus::Pass => "PASS",
75            CheckStatus::Warn => "WARN",
76            CheckStatus::Fail => "FAIL",
77        })
78    }
79}
80
81/// A single named diagnostic result.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct DoctorCheck {
84    /// Stable check identifier: `"config"`, `"dns"`, `"whois"`, or
85    /// `"rdap-bootstrap"`.
86    pub name: String,
87    /// Outcome severity.
88    pub status: CheckStatus,
89    /// Human-readable explanation of the outcome.
90    pub detail: String,
91    /// Wall-clock duration of the probe. `None` for the local config check.
92    pub latency_ms: Option<u64>,
93}
94
95impl DoctorCheck {
96    fn new(
97        name: &str,
98        status: CheckStatus,
99        detail: impl Into<String>,
100        latency_ms: Option<u64>,
101    ) -> Self {
102        Self {
103            name: name.to_string(),
104            status,
105            detail: detail.into(),
106            latency_ms,
107        }
108    }
109}
110
111/// Aggregated result of a [`Doctor::run`] diagnosis.
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113pub struct DoctorReport {
114    /// Individual check results, in the fixed order config, dns, whois,
115    /// rdap-bootstrap.
116    pub checks: Vec<DoctorCheck>,
117    /// Worst status across `checks` ([`CheckStatus::Pass`] when empty).
118    pub overall: CheckStatus,
119}
120
121impl DoctorReport {
122    /// Builds a report from individual checks, computing `overall` as the
123    /// worst (maximum-severity) status present.
124    pub fn from_checks(checks: Vec<DoctorCheck>) -> Self {
125        let overall = checks
126            .iter()
127            .map(|check| check.status)
128            .max()
129            .unwrap_or(CheckStatus::Pass);
130        Self { checks, overall }
131    }
132}
133
134/// Environment/connectivity self-diagnosis runner.
135///
136/// Built from a [`SeerConfig`] so the DNS probe honors the configured
137/// nameserver and `timeouts.dns_secs`. All probe endpoints default to the
138/// real production targets; they are only injectable in `#[cfg(test)]`
139/// builds.
140#[derive(Debug, Clone)]
141pub struct Doctor {
142    config: SeerConfig,
143    /// Path checked by the config diagnostic (`None` when no home directory
144    /// could be determined).
145    config_path: Option<PathBuf>,
146    /// Resolver used by the DNS probe (built via [`DnsResolver::from_config`]).
147    resolver: DnsResolver,
148    /// Name resolved by the DNS probe and queried over WHOIS.
149    probe_domain: String,
150    /// `host:port` target of the WHOIS TCP probe.
151    whois_addr: String,
152    /// URL fetched by the RDAP bootstrap probe.
153    bootstrap_url: String,
154    /// Per-probe deadline.
155    probe_timeout: Duration,
156    /// Test-only: resolve probe targets with the plain OS resolver instead
157    /// of [`crate::net::resolve_public_host`], so loopback fixtures are
158    /// reachable (mirrors `WhoisClient::allowing_private_hosts`).
159    #[cfg(test)]
160    allow_reserved_probe_hosts: bool,
161}
162
163impl Doctor {
164    /// Creates a doctor honoring `config` (nameserver + DNS timeout), with
165    /// all probes pointed at the real production endpoints.
166    pub fn from_config(config: &SeerConfig) -> Self {
167        Self {
168            config: config.clone(),
169            config_path: SeerConfig::config_path(),
170            resolver: DnsResolver::from_config(config),
171            probe_domain: DEFAULT_PROBE_DOMAIN.to_string(),
172            whois_addr: DEFAULT_WHOIS_ADDR.to_string(),
173            bootstrap_url: IANA_BOOTSTRAP_DNS.to_string(),
174            probe_timeout: DEFAULT_PROBE_TIMEOUT,
175            #[cfg(test)]
176            allow_reserved_probe_hosts: false,
177        }
178    }
179
180    // --- #[cfg(test)]-only endpoint seams --------------------------------
181    // Mirror the repo's mock-server pattern (`allowing_private_hosts` /
182    // `with_port`): thread test fixtures in, never weaken production paths.
183
184    /// Test-only: point the config check at an arbitrary file path.
185    #[cfg(test)]
186    pub(crate) fn with_config_path(mut self, path: PathBuf) -> Self {
187        self.config_path = Some(path);
188        self
189    }
190
191    /// Test-only: substitute the DNS probe's resolver (e.g. one wired to the
192    /// loopback fixture via `dns::test_support::mock_dns_resolver`).
193    #[cfg(test)]
194    pub(crate) fn with_resolver(mut self, resolver: DnsResolver) -> Self {
195        self.resolver = resolver;
196        self
197    }
198
199    /// Test-only: change the probe domain (the mock DNS zone serves `seer.test`).
200    #[cfg(test)]
201    pub(crate) fn with_probe_domain(mut self, domain: &str) -> Self {
202        self.probe_domain = domain.to_string();
203        self
204    }
205
206    /// Test-only: point the WHOIS probe at a loopback listener.
207    #[cfg(test)]
208    pub(crate) fn with_whois_addr(mut self, addr: String) -> Self {
209        self.whois_addr = addr;
210        self
211    }
212
213    /// Test-only: point the RDAP bootstrap probe at a wiremock server.
214    #[cfg(test)]
215    pub(crate) fn with_bootstrap_url(mut self, url: String) -> Self {
216        self.bootstrap_url = url;
217        self
218    }
219
220    /// Test-only: shorten the per-probe deadline to keep failing tests fast.
221    #[cfg(test)]
222    pub(crate) fn with_probe_timeout(mut self, timeout: Duration) -> Self {
223        self.probe_timeout = timeout;
224        self
225    }
226
227    /// Test-only: let the WHOIS probe reach loopback fixtures by resolving
228    /// with the plain OS resolver instead of the reserved-range-refusing
229    /// [`crate::net::resolve_public_host`].
230    #[cfg(test)]
231    pub(crate) fn allowing_reserved_for_tests(mut self) -> Self {
232        self.allow_reserved_probe_hosts = true;
233        self
234    }
235
236    /// Runs all checks concurrently and aggregates them.
237    ///
238    /// Never fails: probe errors surface as [`CheckStatus::Fail`] checks in
239    /// the report, not as an `Err`.
240    ///
241    /// # Example
242    /// ```no_run
243    /// # async fn demo() {
244    /// use seer_core::config::SeerConfig;
245    /// use seer_core::doctor::Doctor;
246    ///
247    /// let report = Doctor::from_config(&SeerConfig::load()).run().await;
248    /// for check in &report.checks {
249    ///     println!("{}: {} — {}", check.name, check.status, check.detail);
250    /// }
251    /// # }
252    /// ```
253    pub async fn run(&self) -> DoctorReport {
254        let (config, dns, whois, rdap) = tokio::join!(
255            self.check_config(),
256            self.check_dns(),
257            self.check_whois(),
258            self.check_rdap_bootstrap(),
259        );
260        DoctorReport::from_checks(vec![config, dns, whois, rdap])
261    }
262
263    /// Reports whether `~/.seer/config.toml` is absent (defaults apply),
264    /// parses cleanly, or is malformed (seer falls back to defaults — Warn).
265    ///
266    /// Re-parses the file directly rather than trusting `self.config`: the
267    /// in-memory config is already defaults-on-failure, so it cannot tell us
268    /// *why* it holds defaults.
269    async fn check_config(&self) -> DoctorCheck {
270        let Some(path) = self.config_path.as_ref() else {
271            return DoctorCheck::new(
272                CHECK_CONFIG,
273                CheckStatus::Pass,
274                "no home directory found; using built-in defaults",
275                None,
276            );
277        };
278        if !path.exists() {
279            return DoctorCheck::new(
280                CHECK_CONFIG,
281                CheckStatus::Pass,
282                format!("{} not present; using built-in defaults", path.display()),
283                None,
284            );
285        }
286        // Sync read of a tiny local file; matches config.rs, and tokio's `fs`
287        // feature isn't enabled in this workspace.
288        match std::fs::read_to_string(path) {
289            Ok(content) => match toml::from_str::<SeerConfig>(&content) {
290                Ok(_) => DoctorCheck::new(
291                    CHECK_CONFIG,
292                    CheckStatus::Pass,
293                    format!("{} parsed OK", path.display()),
294                    None,
295                ),
296                Err(e) => DoctorCheck::new(
297                    CHECK_CONFIG,
298                    CheckStatus::Warn,
299                    format!(
300                        "{} is malformed TOML (seer runs on defaults): {}",
301                        path.display(),
302                        one_line(&e.to_string())
303                    ),
304                    None,
305                ),
306            },
307            Err(e) => DoctorCheck::new(
308                CHECK_CONFIG,
309                CheckStatus::Warn,
310                format!(
311                    "could not read {} (seer runs on defaults): {}",
312                    path.display(),
313                    e
314                ),
315                None,
316            ),
317        }
318    }
319
320    /// Resolves the well-known probe domain's A records through the
321    /// configured nameserver (or the default Google DNS resolver).
322    async fn check_dns(&self) -> DoctorCheck {
323        let nameserver = self.config.nameserver.as_deref();
324        let ns_label = nameserver.unwrap_or("8.8.8.8 (default)");
325        let start = Instant::now();
326        let outcome = timeout(
327            self.probe_timeout,
328            self.resolver
329                .resolve(&self.probe_domain, RecordType::A, nameserver),
330        )
331        .await;
332        let latency = Some(latency_since(start));
333        match outcome {
334            Ok(Ok(records)) if !records.is_empty() => DoctorCheck::new(
335                CHECK_DNS,
336                CheckStatus::Pass,
337                format!(
338                    "resolved {} A via {} ({} record{})",
339                    self.probe_domain,
340                    ns_label,
341                    records.len(),
342                    plural(records.len())
343                ),
344                latency,
345            ),
346            // NXDOMAIN/NODATA fold to Ok(vec![]) in the resolver. The
347            // transport works (a server answered), but a permanently
348            // registered name coming back empty means the resolver is
349            // filtering or intercepted — degraded, not dead.
350            Ok(Ok(_)) => DoctorCheck::new(
351                CHECK_DNS,
352                CheckStatus::Warn,
353                format!(
354                    "{} returned no A records via {} — resolver reachable, but a well-known name came back empty (possible filtering or captive portal)",
355                    self.probe_domain, ns_label
356                ),
357                latency,
358            ),
359            Ok(Err(e)) => DoctorCheck::new(
360                CHECK_DNS,
361                CheckStatus::Fail,
362                format!("resolving {} via {} failed: {}", self.probe_domain, ns_label, e),
363                latency,
364            ),
365            Err(_) => DoctorCheck::new(
366                CHECK_DNS,
367                CheckStatus::Fail,
368                format!(
369                    "resolving {} via {} timed out after {:?}",
370                    self.probe_domain, ns_label, self.probe_timeout
371                ),
372                latency,
373            ),
374        }
375    }
376
377    /// Resolves the WHOIS server with the production connection strategy,
378    /// then TCP-connects, sends one query line, and requires at least one
379    /// response byte.
380    async fn check_whois(&self) -> DoctorCheck {
381        let start = Instant::now();
382        // Stage 1: resolution. Deliberately outside `probe_timeout` — it is
383        // already bounded inside resolve_public_host (getaddrinfo capped,
384        // hickory fallback bounded), and sharing the exchange deadline would
385        // let a hung OS resolver eat the whole budget before the fallback
386        // runs: a false FAIL in exactly the broken-resolver environments the
387        // fallback exists for. Splitting the stages also keeps the timeout
388        // message below honest — it fires only once resolution succeeded.
389        let addrs = match self.resolve_whois_target().await {
390            Ok(addrs) => addrs,
391            Err(detail) => {
392                return DoctorCheck::new(
393                    CHECK_WHOIS,
394                    CheckStatus::Fail,
395                    detail,
396                    Some(latency_since(start)),
397                )
398            }
399        };
400        // Stage 2: the TCP exchange proper — a timeout here genuinely
401        // points at port-43 trouble.
402        let outcome = timeout(self.probe_timeout, self.whois_exchange(&addrs)).await;
403        let latency = Some(latency_since(start));
404        match outcome {
405            Ok(Ok(detail)) => DoctorCheck::new(CHECK_WHOIS, CheckStatus::Pass, detail, latency),
406            Ok(Err(detail)) => DoctorCheck::new(CHECK_WHOIS, CheckStatus::Fail, detail, latency),
407            Err(_) => DoctorCheck::new(
408                CHECK_WHOIS,
409                CheckStatus::Fail,
410                format!(
411                    "WHOIS probe to {} timed out after {:?} (port 43 may be blocked)",
412                    self.whois_addr, self.probe_timeout
413                ),
414                latency,
415            ),
416        }
417    }
418
419    /// Resolves and vets the probe target through the exact path the WHOIS
420    /// client connects with ([`crate::net::resolve_public_host`]: OS
421    /// resolver capped, hickory fallback, reserved-range refusal) so the
422    /// doctor's verdict tracks what `seer whois` will actually do. `Err`
423    /// carries the resolution-stage failure detail.
424    async fn resolve_whois_target(&self) -> Result<Vec<SocketAddr>, String> {
425        let (host, port) = self
426            .whois_addr
427            .rsplit_once(':')
428            .and_then(|(host, port)| port.parse::<u16>().ok().map(|port| (host, port)))
429            .ok_or_else(|| format!("invalid WHOIS probe address {}", self.whois_addr))?;
430        #[cfg(test)]
431        if self.allow_reserved_probe_hosts {
432            return tokio::net::lookup_host((host, port))
433                .await
434                .map(|iter| iter.collect())
435                .map_err(|e| format!("resolving {} failed: {}", host, e));
436        }
437        crate::net::resolve_public_host(host, port)
438            .await
439            .map_err(|e| format!("resolving {} failed: {}", host, e))
440    }
441
442    /// The raw WHOIS exchange against pre-vetted addresses; `Err` carries
443    /// the failure detail. The probe target is the hardcoded IANA server in
444    /// production (only injectable under `#[cfg(test)]`).
445    async fn whois_exchange(&self, addrs: &[SocketAddr]) -> Result<String, String> {
446        let mut stream = TcpStream::connect(addrs)
447            .await
448            .map_err(|e| format!("connect to {} failed: {}", self.whois_addr, e))?;
449        stream
450            .write_all(format!("{}\r\n", self.probe_domain).as_bytes())
451            .await
452            .map_err(|e| format!("write to {} failed: {}", self.whois_addr, e))?;
453        let mut buf = [0u8; 256];
454        let n = stream
455            .read(&mut buf)
456            .await
457            .map_err(|e| format!("read from {} failed: {}", self.whois_addr, e))?;
458        if n == 0 {
459            return Err(format!(
460                "{} closed the connection without sending data",
461                self.whois_addr
462            ));
463        }
464        Ok(format!(
465            "queried {} via {} ({} byte{} received)",
466            self.probe_domain,
467            self.whois_addr,
468            n,
469            plural(n)
470        ))
471    }
472
473    /// HTTPS-fetches the IANA RDAP DNS bootstrap registry, requiring an
474    /// HTTP 200 with a non-empty body within the bootstrap's size cap.
475    async fn check_rdap_bootstrap(&self) -> DoctorCheck {
476        let start = Instant::now();
477        let outcome = timeout(self.probe_timeout, self.rdap_bootstrap_probe()).await;
478        let latency = Some(latency_since(start));
479        match outcome {
480            Ok(Ok(detail)) => {
481                DoctorCheck::new(CHECK_RDAP_BOOTSTRAP, CheckStatus::Pass, detail, latency)
482            }
483            Ok(Err(detail)) => {
484                DoctorCheck::new(CHECK_RDAP_BOOTSTRAP, CheckStatus::Fail, detail, latency)
485            }
486            Err(_) => DoctorCheck::new(
487                CHECK_RDAP_BOOTSTRAP,
488                CheckStatus::Fail,
489                format!(
490                    "GET {} timed out after {:?}",
491                    self.bootstrap_url, self.probe_timeout
492                ),
493                latency,
494            ),
495        }
496    }
497
498    /// The raw bootstrap fetch; `Ok` carries the success detail, `Err` the
499    /// failure detail. The URL is the hardcoded IANA HTTPS endpoint in
500    /// production (only injectable under `#[cfg(test)]`).
501    async fn rdap_bootstrap_probe(&self) -> Result<String, String> {
502        // Built like the real bootstrap fetch: redirects off (a 3xx fails here
503        // as it would there) and the body read under its cap. The client
504        // timeout and the capped read's deadline both hold the probe to
505        // `probe_timeout`; the caller's outer `timeout()` is defense in depth.
506        let client = crate::net::client_builder(self.probe_timeout)
507            .build()
508            .map_err(|e| format!("could not build HTTP client: {}", e))?;
509        let response = client
510            .get(&self.bootstrap_url)
511            .send()
512            .await
513            .map_err(|e| format!("GET {} failed: {}", self.bootstrap_url, e))?;
514        let status = response.status();
515        if status != reqwest::StatusCode::OK {
516            return Err(format!(
517                "GET {} returned HTTP {}",
518                self.bootstrap_url, status
519            ));
520        }
521        let body = read_body_capped(
522            response,
523            MAX_BOOTSTRAP_SIZE,
524            self.probe_timeout,
525            Overflow::Reject,
526        )
527        .await
528        .map_err(|e| match e {
529            BodyReadError::Chunk(e) => format!("reading {} body failed: {}", self.bootstrap_url, e),
530            BodyReadError::TooLarge => format!(
531                "GET {} returned a body over the {} byte cap",
532                self.bootstrap_url, MAX_BOOTSTRAP_SIZE
533            ),
534            BodyReadError::TimedOut => format!(
535                "reading {} body timed out after {:?}",
536                self.bootstrap_url, self.probe_timeout
537            ),
538        })?;
539        if body.is_empty() {
540            return Err(format!("GET {} returned an empty body", self.bootstrap_url));
541        }
542        Ok(format!(
543            "fetched {} ({} bytes)",
544            self.bootstrap_url,
545            body.len()
546        ))
547    }
548}
549
550/// Milliseconds elapsed since `start`, saturating at `u64::MAX`.
551fn latency_since(start: Instant) -> u64 {
552    u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX)
553}
554
555/// `"s"` when `n != 1`, for probe detail strings.
556fn plural(n: usize) -> &'static str {
557    if n == 1 {
558        ""
559    } else {
560        "s"
561    }
562}
563
564/// Collapses a multi-line error (toml parse errors span lines) into a single
565/// whitespace-normalized line for the check detail.
566fn one_line(s: &str) -> String {
567    s.split_whitespace().collect::<Vec<_>>().join(" ")
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573
574    use std::sync::atomic::{AtomicU32, Ordering};
575
576    use wiremock::matchers::{method, path};
577    use wiremock::{Mock, MockServer, ResponseTemplate};
578
579    use crate::dns::test_support::{mock_dns_resolver, spawn_mock_dns, MockMode};
580
581    // --- fixtures ------------------------------------------------------
582
583    /// A unique temp-file path; the file (if created) is removed on drop.
584    struct TmpFile(PathBuf);
585
586    impl TmpFile {
587        fn unique(tag: &str) -> Self {
588            static COUNTER: AtomicU32 = AtomicU32::new(0);
589            let n = COUNTER.fetch_add(1, Ordering::Relaxed);
590            Self(std::env::temp_dir().join(format!(
591                "seer-doctor-{}-{}-{}.toml",
592                tag,
593                std::process::id(),
594                n
595            )))
596        }
597
598        fn with_content(tag: &str, content: &str) -> Self {
599            let file = Self::unique(tag);
600            std::fs::write(&file.0, content).expect("write temp config");
601            file
602        }
603    }
604
605    impl Drop for TmpFile {
606        fn drop(&mut self) {
607            let _ = std::fs::remove_file(&self.0);
608        }
609    }
610
611    /// Loopback WHOIS listener that answers every connection with `response`.
612    async fn spawn_whois_responder(response: &'static [u8]) -> u16 {
613        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
614            .await
615            .expect("bind mock whois");
616        let port = listener.local_addr().expect("local addr").port();
617        tokio::spawn(async move {
618            loop {
619                let Ok((mut sock, _)) = listener.accept().await else {
620                    return;
621                };
622                let mut buf = [0u8; 128];
623                let _ = sock.read(&mut buf).await; // consume "domain\r\n"
624                let _ = sock.write_all(response).await;
625            }
626        });
627        port
628    }
629
630    /// A loopback port with nothing listening (bind then drop → refused).
631    async fn refused_port() -> u16 {
632        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
633            .await
634            .expect("bind for refused port");
635        let port = listener.local_addr().expect("local addr").port();
636        drop(listener);
637        port
638    }
639
640    fn base_doctor(config: &SeerConfig) -> Doctor {
641        // Point the config check away from the developer's real
642        // ~/.seer/config.toml — every test overrides what it exercises.
643        // The reserved-range seam lets the WHOIS probe reach the loopback
644        // fixtures; the vetting path itself is covered by
645        // `whois_check_vets_probe_target_through_resolve_public_host`.
646        Doctor::from_config(config)
647            .with_config_path(TmpFile::unique("unused").0.clone())
648            .with_probe_timeout(Duration::from_secs(2))
649            .allowing_reserved_for_tests()
650    }
651
652    /// Doctor whose DNS probe is wired to the loopback mock fixture.
653    async fn dns_doctor(mode: MockMode) -> Doctor {
654        let port = spawn_mock_dns(mode).await;
655        let config = SeerConfig {
656            nameserver: Some("127.0.0.1".to_string()),
657            ..SeerConfig::default()
658        };
659        base_doctor(&config)
660            .with_resolver(mock_dns_resolver(port))
661            .with_probe_domain("seer.test")
662    }
663
664    // --- aggregation -----------------------------------------------------
665
666    fn check(status: CheckStatus) -> DoctorCheck {
667        DoctorCheck::new("x", status, "detail", None)
668    }
669
670    #[test]
671    fn overall_is_worst_of_checks() {
672        use CheckStatus::{Fail, Pass, Warn};
673        let cases = [
674            (vec![], Pass),
675            (vec![check(Pass), check(Pass)], Pass),
676            (vec![check(Pass), check(Warn)], Warn),
677            (vec![check(Warn), check(Pass), check(Fail)], Fail),
678            (vec![check(Fail), check(Warn)], Fail),
679        ];
680        for (checks, expected) in cases {
681            assert_eq!(DoctorReport::from_checks(checks).overall, expected);
682        }
683    }
684
685    #[test]
686    fn report_serde_roundtrips_with_lowercase_statuses() {
687        let report = DoctorReport::from_checks(vec![DoctorCheck::new(
688            "dns",
689            CheckStatus::Fail,
690            "boom",
691            Some(12),
692        )]);
693        let json = serde_json::to_string(&report).expect("serialize report");
694        assert!(json.contains("\"fail\""), "got: {json}");
695        let back: DoctorReport = serde_json::from_str(&json).expect("deserialize report");
696        assert_eq!(back, report);
697    }
698
699    #[test]
700    fn doctor_defaults_target_real_endpoints() {
701        // The wiring agent's CLI contract depends on these production
702        // defaults; the test seams must be the only way to change them.
703        let doctor = Doctor::from_config(&SeerConfig::default());
704        assert_eq!(doctor.whois_addr, "whois.iana.org:43");
705        assert_eq!(doctor.bootstrap_url, "https://data.iana.org/rdap/dns.json");
706        assert_eq!(doctor.probe_domain, "example.com");
707        assert_eq!(doctor.probe_timeout, Duration::from_secs(5));
708    }
709
710    // --- config check ----------------------------------------------------
711
712    #[tokio::test]
713    async fn config_check_absent_file_passes() {
714        let missing = TmpFile::unique("absent");
715        let doctor =
716            Doctor::from_config(&SeerConfig::default()).with_config_path(missing.0.clone());
717        let check = doctor.check_config().await;
718        assert_eq!(check.status, CheckStatus::Pass);
719        assert!(check.detail.contains("defaults"), "got: {}", check.detail);
720        assert_eq!(check.latency_ms, None);
721    }
722
723    #[tokio::test]
724    async fn config_check_valid_file_passes() {
725        let file = TmpFile::with_content("valid", "output_format = \"json\"\n");
726        let doctor = Doctor::from_config(&SeerConfig::default()).with_config_path(file.0.clone());
727        let check = doctor.check_config().await;
728        assert_eq!(check.status, CheckStatus::Pass);
729        assert!(check.detail.contains("parsed OK"), "got: {}", check.detail);
730    }
731
732    #[tokio::test]
733    async fn config_check_malformed_file_warns() {
734        // Both a syntax error and a type error must Warn, mirroring the two
735        // fallback paths in SeerConfig::parse_or_default.
736        for content in ["output_format = [not toml", "output_format = 42"] {
737            let file = TmpFile::with_content("malformed", content);
738            let doctor =
739                Doctor::from_config(&SeerConfig::default()).with_config_path(file.0.clone());
740            let check = doctor.check_config().await;
741            assert_eq!(check.status, CheckStatus::Warn, "content: {content}");
742            assert!(check.detail.contains("defaults"), "got: {}", check.detail);
743            // Detail must stay a single line for terminal/JSON output.
744            assert!(!check.detail.contains('\n'), "got: {}", check.detail);
745        }
746    }
747
748    // --- whois check -------------------------------------------------------
749
750    #[tokio::test]
751    async fn whois_check_passes_against_responding_listener() {
752        let port = spawn_whois_responder(b"% IANA WHOIS server\r\n").await;
753        let doctor =
754            base_doctor(&SeerConfig::default()).with_whois_addr(format!("127.0.0.1:{port}"));
755        let check = doctor.check_whois().await;
756        assert_eq!(check.status, CheckStatus::Pass, "got: {}", check.detail);
757        assert!(check.latency_ms.is_some());
758    }
759
760    #[tokio::test]
761    async fn whois_check_fails_on_connection_refused() {
762        let port = refused_port().await;
763        let doctor =
764            base_doctor(&SeerConfig::default()).with_whois_addr(format!("127.0.0.1:{port}"));
765        let check = doctor.check_whois().await;
766        assert_eq!(check.status, CheckStatus::Fail);
767        // The failure reason is transport-specific: unix reports the refused
768        // connect immediately, while Windows retries SYNs against a closed
769        // port long enough that the probe deadline fires first. The contract
770        // is the Fail status plus a detail naming the probed address.
771        assert!(
772            check.detail.contains(&format!("127.0.0.1:{port}")),
773            "got: {}",
774            check.detail
775        );
776    }
777
778    #[tokio::test]
779    async fn whois_check_fails_on_eof_without_data() {
780        // Listener accepts, reads the query, then closes without writing —
781        // "read at least 1 byte" must not count a bare FIN as success.
782        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
783            .await
784            .expect("bind mock whois");
785        let port = listener.local_addr().expect("local addr").port();
786        tokio::spawn(async move {
787            let Ok((mut sock, _)) = listener.accept().await else {
788                return;
789            };
790            let mut buf = [0u8; 128];
791            let _ = sock.read(&mut buf).await;
792            // drop(sock) → FIN with zero response bytes
793        });
794        let doctor =
795            base_doctor(&SeerConfig::default()).with_whois_addr(format!("127.0.0.1:{port}"));
796        let check = doctor.check_whois().await;
797        assert_eq!(check.status, CheckStatus::Fail, "got: {}", check.detail);
798    }
799
800    #[tokio::test]
801    async fn whois_check_vets_probe_target_through_resolve_public_host() {
802        // The production probe must share the WHOIS client's connection
803        // strategy (net::resolve_public_host): without the test seam, a
804        // reserved-range target is refused during the resolution stage —
805        // it must never reach a connect attempt that ends in a misleading
806        // "(port 43 may be blocked)" timeout.
807        let unused = TmpFile::unique("reserved");
808        let doctor = Doctor::from_config(&SeerConfig::default())
809            .with_config_path(unused.0.clone())
810            .with_whois_addr("10.255.255.1:43".to_string())
811            .with_probe_timeout(Duration::from_millis(300));
812        let check = doctor.check_whois().await;
813        assert_eq!(check.status, CheckStatus::Fail, "got: {}", check.detail);
814        assert!(check.detail.contains("reserved"), "got: {}", check.detail);
815        assert!(
816            !check.detail.contains("port 43 may be blocked"),
817            "got: {}",
818            check.detail
819        );
820    }
821
822    #[tokio::test]
823    async fn whois_check_fails_on_timeout() {
824        // Listener accepts but never responds: the probe deadline must fire.
825        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
826            .await
827            .expect("bind mock whois");
828        let port = listener.local_addr().expect("local addr").port();
829        tokio::spawn(async move {
830            let Ok((_sock, _)) = listener.accept().await else {
831                return;
832            };
833            tokio::time::sleep(Duration::from_secs(30)).await;
834        });
835        let doctor = base_doctor(&SeerConfig::default())
836            .with_whois_addr(format!("127.0.0.1:{port}"))
837            .with_probe_timeout(Duration::from_millis(200));
838        let check = doctor.check_whois().await;
839        assert_eq!(check.status, CheckStatus::Fail);
840        assert!(check.detail.contains("timed out"), "got: {}", check.detail);
841    }
842
843    // --- rdap bootstrap check ---------------------------------------------
844
845    #[tokio::test]
846    async fn rdap_check_passes_on_200_with_body() {
847        let server = MockServer::start().await;
848        Mock::given(method("GET"))
849            .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"services":[]}"#))
850            .mount(&server)
851            .await;
852        let doctor = base_doctor(&SeerConfig::default())
853            .with_bootstrap_url(format!("{}/rdap/dns.json", server.uri()));
854        let check = doctor.check_rdap_bootstrap().await;
855        assert_eq!(check.status, CheckStatus::Pass, "got: {}", check.detail);
856        assert!(check.latency_ms.is_some());
857    }
858
859    #[tokio::test]
860    async fn rdap_check_fails_on_500() {
861        let server = MockServer::start().await;
862        Mock::given(method("GET"))
863            .respond_with(ResponseTemplate::new(500))
864            .mount(&server)
865            .await;
866        let doctor = base_doctor(&SeerConfig::default())
867            .with_bootstrap_url(format!("{}/rdap/dns.json", server.uri()));
868        let check = doctor.check_rdap_bootstrap().await;
869        assert_eq!(check.status, CheckStatus::Fail);
870        assert!(check.detail.contains("500"), "got: {}", check.detail);
871    }
872
873    #[tokio::test]
874    async fn rdap_check_fails_on_empty_body() {
875        let server = MockServer::start().await;
876        Mock::given(method("GET"))
877            .respond_with(ResponseTemplate::new(200))
878            .mount(&server)
879            .await;
880        let doctor = base_doctor(&SeerConfig::default())
881            .with_bootstrap_url(format!("{}/rdap/dns.json", server.uri()));
882        let check = doctor.check_rdap_bootstrap().await;
883        assert_eq!(check.status, CheckStatus::Fail);
884        assert!(check.detail.contains("empty body"), "got: {}", check.detail);
885    }
886
887    /// Regression: the probe followed redirects that the real bootstrap
888    /// fetch (redirects off) never does, so it could pass while RDAP failed.
889    #[tokio::test]
890    async fn rdap_check_does_not_follow_redirects() {
891        let server = MockServer::start().await;
892        Mock::given(path("/rdap/dns.json"))
893            .respond_with(ResponseTemplate::new(302).insert_header("location", "/moved.json"))
894            .mount(&server)
895            .await;
896        Mock::given(path("/moved.json"))
897            .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"services":[]}"#))
898            .mount(&server)
899            .await;
900        let doctor = base_doctor(&SeerConfig::default())
901            .with_bootstrap_url(format!("{}/rdap/dns.json", server.uri()));
902        let check = doctor.check_rdap_bootstrap().await;
903        assert_eq!(check.status, CheckStatus::Fail, "got: {}", check.detail);
904        assert!(check.detail.contains("302"), "got: {}", check.detail);
905    }
906
907    /// Regression: the body was read with an unbounded `bytes()`.
908    #[tokio::test]
909    async fn rdap_check_fails_on_body_over_the_bootstrap_cap() {
910        let server = MockServer::start().await;
911        Mock::given(method("GET"))
912            .respond_with(
913                ResponseTemplate::new(200).set_body_bytes(vec![b' '; MAX_BOOTSTRAP_SIZE + 1]),
914            )
915            .mount(&server)
916            .await;
917        let doctor = base_doctor(&SeerConfig::default())
918            .with_bootstrap_url(format!("{}/rdap/dns.json", server.uri()));
919        let check = doctor.check_rdap_bootstrap().await;
920        assert_eq!(check.status, CheckStatus::Fail, "got: {}", check.detail);
921        assert!(check.detail.contains("byte cap"), "got: {}", check.detail);
922    }
923
924    #[tokio::test]
925    async fn rdap_check_fails_on_timeout() {
926        let server = MockServer::start().await;
927        Mock::given(method("GET"))
928            .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(30)))
929            .mount(&server)
930            .await;
931        let doctor = base_doctor(&SeerConfig::default())
932            .with_bootstrap_url(format!("{}/rdap/dns.json", server.uri()))
933            .with_probe_timeout(Duration::from_millis(200));
934        let check = doctor.check_rdap_bootstrap().await;
935        assert_eq!(check.status, CheckStatus::Fail, "got: {}", check.detail);
936    }
937
938    // --- dns check ---------------------------------------------------------
939
940    #[tokio::test]
941    async fn dns_check_passes_with_mock_zone() {
942        let doctor = dns_doctor(MockMode::Zone).await;
943        let check = doctor.check_dns().await;
944        assert_eq!(check.status, CheckStatus::Pass, "got: {}", check.detail);
945        // Detail must show the config nameserver was honored.
946        assert!(check.detail.contains("127.0.0.1"), "got: {}", check.detail);
947        assert!(check.latency_ms.is_some());
948    }
949
950    #[tokio::test]
951    async fn dns_check_warns_on_nxdomain_for_probe_name() {
952        let doctor = dns_doctor(MockMode::Nxdomain).await;
953        let check = doctor.check_dns().await;
954        assert_eq!(check.status, CheckStatus::Warn, "got: {}", check.detail);
955    }
956
957    #[tokio::test]
958    async fn dns_check_fails_when_server_unresponsive() {
959        // MockMode::Ignore never answers: either the resolver's own timeout
960        // or the probe deadline fires — both must map to Fail.
961        let doctor = dns_doctor(MockMode::Ignore)
962            .await
963            .with_probe_timeout(Duration::from_secs(1));
964        let check = doctor.check_dns().await;
965        assert_eq!(check.status, CheckStatus::Fail, "got: {}", check.detail);
966    }
967
968    // --- full run ------------------------------------------------------------
969
970    #[tokio::test]
971    async fn run_reports_all_four_checks_in_order_and_passes() {
972        let config_file = TmpFile::with_content("run-valid", "output_format = \"yaml\"\n");
973        let whois_port = spawn_whois_responder(b"% ok\r\n").await;
974        let rdap = MockServer::start().await;
975        Mock::given(method("GET"))
976            .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"services":[]}"#))
977            .mount(&rdap)
978            .await;
979
980        let doctor = dns_doctor(MockMode::Zone)
981            .await
982            .with_config_path(config_file.0.clone())
983            .with_whois_addr(format!("127.0.0.1:{whois_port}"))
984            .with_bootstrap_url(format!("{}/rdap/dns.json", rdap.uri()));
985
986        let report = doctor.run().await;
987        let names: Vec<&str> = report.checks.iter().map(|c| c.name.as_str()).collect();
988        assert_eq!(names, ["config", "dns", "whois", "rdap-bootstrap"]);
989        for check in &report.checks {
990            assert_eq!(
991                check.status,
992                CheckStatus::Pass,
993                "{}: {}",
994                check.name,
995                check.detail
996            );
997        }
998        assert_eq!(report.overall, CheckStatus::Pass);
999    }
1000
1001    #[tokio::test]
1002    async fn run_isolates_probe_failures() {
1003        // WHOIS refused + RDAP 500 while config and DNS are healthy: the
1004        // failures must not abort the healthy probes, and overall is Fail.
1005        let missing_config = TmpFile::unique("run-absent");
1006        let whois_port = refused_port().await;
1007        let rdap = MockServer::start().await;
1008        Mock::given(method("GET"))
1009            .respond_with(ResponseTemplate::new(500))
1010            .mount(&rdap)
1011            .await;
1012
1013        let doctor = dns_doctor(MockMode::Zone)
1014            .await
1015            .with_config_path(missing_config.0.clone())
1016            .with_whois_addr(format!("127.0.0.1:{whois_port}"))
1017            .with_bootstrap_url(format!("{}/rdap/dns.json", rdap.uri()));
1018
1019        let report = doctor.run().await;
1020        let by_name = |name: &str| {
1021            report
1022                .checks
1023                .iter()
1024                .find(|c| c.name == name)
1025                .unwrap_or_else(|| panic!("missing check {name}"))
1026        };
1027        assert_eq!(by_name("config").status, CheckStatus::Pass);
1028        assert_eq!(by_name("dns").status, CheckStatus::Pass);
1029        assert_eq!(by_name("whois").status, CheckStatus::Fail);
1030        assert_eq!(by_name("rdap-bootstrap").status, CheckStatus::Fail);
1031        assert_eq!(report.overall, CheckStatus::Fail);
1032    }
1033
1034    #[tokio::test]
1035    async fn run_with_malformed_config_only_warns_overall() {
1036        // Warn-vs-Fail end to end: a broken config file must degrade the
1037        // report to Warn, never Fail, when every network probe passes.
1038        let config_file = TmpFile::with_content("run-malformed", "output_format = [broken");
1039        let whois_port = spawn_whois_responder(b"% ok\r\n").await;
1040        let rdap = MockServer::start().await;
1041        Mock::given(method("GET"))
1042            .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"services":[]}"#))
1043            .mount(&rdap)
1044            .await;
1045
1046        let doctor = dns_doctor(MockMode::Zone)
1047            .await
1048            .with_config_path(config_file.0.clone())
1049            .with_whois_addr(format!("127.0.0.1:{whois_port}"))
1050            .with_bootstrap_url(format!("{}/rdap/dns.json", rdap.uri()));
1051
1052        let report = doctor.run().await;
1053        assert_eq!(report.overall, CheckStatus::Warn);
1054    }
1055}