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