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