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