Skip to main content

basil_core/
doctor.rs

1// SPDX-FileCopyrightText: 2026 OpenBasil Contributors
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! `basil doctor`: preflight environment & deployment checks (basil-f0j).
6//!
7//! Doctor runs a set of **independent**, read-only diagnostics against a resolved
8//! daemon config and reports each as a structured
9//! [`CheckResult`] with actionable remediation. It is a pre-deploy / first-boot
10//! sanity gate: it answers "will `basil agent` even get off the ground here?"
11//! It has two tiers, split on whether the sealed bundle is unlocked:
12//!
13//! - **`basil doctor`** (OFFLINE, no unlock): catalog/policy load, backend
14//!   capability enforcement, invocation broker-identity/key bindings, feature
15//!   compatibility, backend binary on PATH, socket, bundle perms/freshness, and
16//!   backend health reachability. It never unlocks the bundle, binds the socket,
17//!   or mutates anything.
18//! - **`basil doctor --keys`** (UNLOCK): additionally unlocks the sealed bundle
19//!   and runs an authenticated, read-only per-key existence probe. Still never
20//!   reconciles, generates, writes a sidecar, or binds the socket.
21//!
22//! ## Invariants (load-bearing)
23//!
24//! - **No panic.** Every step handles its error into a check *failure*; one check
25//!   erroring never aborts the others. All results are collected, then the exit
26//!   code is decided.
27//! - **No secrets by default.** The bundle check reports **readability, permissions, and
28//!   freshness only**, never bundle contents, key material, passphrases, or
29//!   tokens. No secret byte reaches stdout, the JSON, or an error string. The
30//!   bundle is parsed for its public header (epoch) but never unlocked.
31//! - **No mutation.** Default doctor never unlocks; the explicit `--keys`
32//!   key-material mode unlocks only to read. Neither mode reconciles, generates a
33//!   key, writes a sidecar, or binds the socket. The epoch-sidecar check is
34//!   read-only (unlike [`crate::seal::verify_epoch_sidecar`], which advances the
35//!   sidecar).
36//!
37//! ## Severity & exit
38//!
39//! Each check is `ok` / `warn` / `fatal`. A **fatal** condition would stop the
40//! broker/service from starting (catalog won't load, backend unreachable, bundle
41//! won't unlock, a `missing=error` key reconcile cannot satisfy). Everything else
42//! (a `missing=generate` key, an optional key absent, `bao` not on PATH, loose
43//! bundle perms) is a **warning**: advisory, report-only.
44//!
45//! The caller maps `any fatal → nonzero exit`. Warnings alone exit `0` unless
46//! `--strict` is passed, which also makes warnings exit nonzero. The return code
47//! is derived from the worst severity among the checks that ran.
48
49use std::path::Path;
50use std::time::Duration;
51
52use serde::Serialize;
53
54use crate::catalog::BackendKind;
55
56/// Stable schema version of the `--json` document. Bump on a breaking shape
57/// change; operators script against this.
58pub const DOCTOR_SCHEMA_VERSION: u32 = 2;
59
60/// Bounded per-backend reachability timeout. A down backend must be FATAL for the
61/// check, never hang the whole run.
62const REACHABILITY_TIMEOUT: Duration = Duration::from_secs(3);
63
64/// The outcome of a single diagnostic check.
65///
66/// `Ok` passes; `Warn` is advisory (exits nonzero only under `--strict`); `Fatal`
67/// is blocking (the run exits nonzero, a condition that would stop the broker from
68/// starting). The string tokens (`ok` / `warn` / `fatal`) are the stable JSON
69/// values that operators match on.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
71#[serde(rename_all = "lowercase")]
72pub enum CheckStatus {
73    /// The check passed.
74    Ok,
75    /// Advisory: a non-ideal condition that does not stop startup.
76    Warn,
77    /// Blocking: this would prevent (or endanger) a clean `run`.
78    Fatal,
79}
80
81/// One diagnostic result: a named check, its status, a human-readable detail,
82/// and (for non-ok results) actionable remediation text.
83///
84/// `name` is a stable machine identifier (`snake_case`, scriptable); per-key
85/// probe rows carry a `key_material:<key>` name. `detail` and `remediation` are
86/// operator-facing prose and may change wording freely. **No field ever carries a
87/// secret** (the bundle arm reports perms/freshness only; key rows carry catalog
88/// key *names*, which are public config, never key material).
89#[derive(Debug, Clone, Serialize)]
90pub struct CheckResult {
91    /// Stable machine identifier for the check (e.g. `backend_reachability`).
92    pub name: String,
93    /// Pass / advisory / blocking.
94    pub status: CheckStatus,
95    /// Human-readable description of what was found.
96    pub detail: String,
97    /// Actionable fix for a non-ok result (empty for `ok`).
98    pub remediation: String,
99}
100
101impl CheckResult {
102    pub(crate) fn ok(name: impl Into<String>, detail: impl Into<String>) -> Self {
103        Self {
104            name: name.into(),
105            status: CheckStatus::Ok,
106            detail: detail.into(),
107            remediation: String::new(),
108        }
109    }
110
111    pub(crate) fn warn(
112        name: impl Into<String>,
113        detail: impl Into<String>,
114        remediation: impl Into<String>,
115    ) -> Self {
116        Self {
117            name: name.into(),
118            status: CheckStatus::Warn,
119            detail: detail.into(),
120            remediation: remediation.into(),
121        }
122    }
123
124    pub(crate) fn fatal(
125        name: impl Into<String>,
126        detail: impl Into<String>,
127        remediation: impl Into<String>,
128    ) -> Self {
129        Self {
130            name: name.into(),
131            status: CheckStatus::Fatal,
132            detail: detail.into(),
133            remediation: remediation.into(),
134        }
135    }
136}
137
138/// A non-secret summary of a doctor run: per-status counts and the fatal flag.
139#[derive(Debug, Clone, Copy, Serialize)]
140pub struct DoctorSummary {
141    /// Total number of checks run.
142    pub total: usize,
143    /// Count of `ok` checks.
144    pub ok: usize,
145    /// Count of `warn` (advisory) checks.
146    pub warn: usize,
147    /// Count of `fatal` (blocking) checks.
148    pub fatal: usize,
149    /// True iff at least one check is `fatal` (the run exits nonzero even without
150    /// `--strict`).
151    pub blocking: bool,
152}
153
154/// The full doctor document: a stable, versioned shape an operator scripts on.
155#[derive(Debug, Clone, Serialize)]
156pub struct DoctorReport {
157    /// Stable schema version of this document.
158    pub schema_version: u32,
159    /// Every check result, in a deterministic order.
160    pub checks: Vec<CheckResult>,
161    /// Roll-up counts + the fatal flag.
162    pub summary: DoctorSummary,
163}
164
165impl DoctorReport {
166    /// Build a report from a set of independent results, computing the summary.
167    #[must_use]
168    pub fn from_checks(checks: Vec<CheckResult>) -> Self {
169        let mut ok = 0;
170        let mut warn = 0;
171        let mut fatal = 0;
172        for c in &checks {
173            match c.status {
174                CheckStatus::Ok => ok += 1,
175                CheckStatus::Warn => warn += 1,
176                CheckStatus::Fatal => fatal += 1,
177            }
178        }
179        let summary = DoctorSummary {
180            total: checks.len(),
181            ok,
182            warn,
183            fatal,
184            blocking: fatal > 0,
185        };
186        Self {
187            schema_version: DOCTOR_SCHEMA_VERSION,
188            checks,
189            summary,
190        }
191    }
192
193    /// Whether the caller should exit nonzero. A fatal check always trips it; a
194    /// warning trips it only under `strict`. The return code is thus derived from
195    /// the worst severity among the checks that ran.
196    #[must_use]
197    pub const fn should_exit_nonzero(&self, strict: bool) -> bool {
198        self.summary.fatal > 0 || (strict && self.summary.warn > 0)
199    }
200}
201
202/// The resolved, non-secret inputs a doctor run needs.
203///
204/// Built by the binary from the same config/override resolution `run` uses, then
205/// handed here so the check logic is free of clap/config-file plumbing.
206#[derive(Debug, Clone)]
207pub struct DoctorInputs {
208    /// Path to the exported catalog JSON.
209    pub catalog: std::path::PathBuf,
210    /// Path to the exported policy JSON.
211    pub policy: std::path::PathBuf,
212    /// Path to the `0600` sealed bundle.
213    pub bundle: std::path::PathBuf,
214    /// The unix socket path the daemon would bind.
215    pub socket: String,
216    /// The configured socket mode (octal, e.g. `0o600`).
217    pub socket_mode: u32,
218    /// The configured socket group (numeric gid or name), if any.
219    pub socket_group: Option<String>,
220    /// The backend-capability enforcement policy (`strict` / `degraded` / `off`).
221    pub capability_policy: crate::CapabilityPolicy,
222    /// The resolved invocation-service runtime config, for offline broker-identity
223    /// and key-binding validation.
224    pub invocation: crate::service::broker::InvocationRuntimeConfig,
225    /// Whether a passphrase unlock file is configured.
226    pub unlock_passphrase_selected: bool,
227    /// Whether a bip39 break-glass phrase file is configured.
228    pub unlock_bip39_selected: bool,
229    /// Whether the age-yubikey unlock slot is enabled.
230    pub unlock_age_yubikey_selected: bool,
231}
232
233/// Which cargo features the running binary was compiled with, captured at the
234/// call site via `cfg!` so the doctor logic stays a pure function (testable for
235/// both presence and absence of a feature).
236///
237/// Each field maps 1:1 to a cargo feature flag; a sub-struct would not group them
238/// more meaningfully than this flat capture, hence the `struct_excessive_bools`
239/// allow.
240#[allow(clippy::struct_excessive_bools)]
241#[derive(Debug, Clone, Copy)]
242pub struct EnabledFeatures {
243    /// `keystore-backend` is compiled in.
244    pub keystore_backend: bool,
245    /// `aws-kms` is compiled in.
246    pub aws_kms: bool,
247    /// `gcp-kms` is compiled in.
248    pub gcp_kms: bool,
249    /// `unlock-bip39` is compiled in.
250    pub unlock_bip39: bool,
251    /// `unlock-age-yubikey` is compiled in.
252    pub unlock_age_yubikey: bool,
253}
254
255impl EnabledFeatures {
256    /// Capture the features of the **current** build.
257    #[must_use]
258    pub const fn current() -> Self {
259        Self {
260            keystore_backend: cfg!(feature = "keystore-backend"),
261            aws_kms: cfg!(feature = "aws-kms"),
262            gcp_kms: cfg!(feature = "gcp-kms"),
263            unlock_bip39: cfg!(feature = "unlock-bip39"),
264            unlock_age_yubikey: cfg!(feature = "unlock-age-yubikey"),
265        }
266    }
267}
268
269/// Run every OFFLINE diagnostic and assemble the report.
270///
271/// Checks are independent: a check that errors becomes a `fatal`/`warn` row and
272/// never aborts the rest. This never unlocks the bundle; the caller appends the
273/// `--keys` per-key probe rows (see [`key_material_rows`]).
274///
275/// The catalog is loaded once (its own check) and, if it parses, reused to derive
276/// the capability, invocation-binding, backend-binary and reachability checks. A
277/// catalog that fails to load yields a `fatal` row and the catalog-dependent
278/// checks degrade gracefully (they report "skipped: catalog did not load").
279pub async fn run_doctor(inputs: &DoctorInputs, features: EnabledFeatures) -> DoctorReport {
280    let mut checks = Vec::new();
281
282    // Load the catalog/policy once; the result drives downstream checks.
283    let loaded = load_catalog_policy(&inputs.catalog, &inputs.policy);
284    checks.push(catalog_policy_check(&loaded));
285
286    // Offline config lints that need the loaded catalog (no unlock, no backend I/O).
287    if let Ok(catalog) = loaded.as_ref() {
288        checks.push(capability_check(catalog, inputs.capability_policy));
289        checks.push(invocation_bindings_check(&inputs.invocation, catalog));
290    } else {
291        checks.push(catalog_dependent_skip("capability"));
292        checks.push(catalog_dependent_skip("invocation_bindings"));
293    }
294
295    let backends = loaded.as_ref().ok().map(|c| c.backends.clone());
296
297    checks.push(feature_compatibility_check(
298        inputs,
299        features,
300        backends.as_ref(),
301    ));
302    checks.push(backend_binary_check(backends.as_ref()));
303    checks.push(socket_check(
304        &inputs.socket,
305        inputs.socket_mode,
306        inputs.socket_group.as_deref(),
307    ));
308    checks.extend(bundle_checks(&inputs.bundle));
309    checks.push(backend_reachability_check(backends.as_ref()).await);
310
311    DoctorReport::from_checks(checks)
312}
313
314/// A stable "skipped: catalog did not load" advisory row for a check that needs
315/// the loaded catalog.
316fn catalog_dependent_skip(name: &'static str) -> CheckResult {
317    CheckResult::warn(
318        name,
319        "skipped: catalog did not load",
320        "Fix the catalog/policy load first (see the catalog_policy check).",
321    )
322}
323
324/// Build the authenticated per-key-material rows for `doctor --keys`.
325///
326/// This is intentionally not part of the default [`run_doctor`] boundary: its
327/// caller must have explicitly unlocked the sealed bundle and run the read-only
328/// [`crate::BackendManager::check`] probe. The rows are still non-secret: they
329/// report catalog key *names* and counts, never backend tokens or key material.
330///
331/// Returns an aggregate `key_material` summary row plus one `key_material:<key>`
332/// detail row per ABSENT key, classified by its `missing` policy: a `missing=error`
333/// key reconcile cannot satisfy is **fatal**; a `missing=warn`/`missing=generate`
334/// absence is a **warning**. A probe that could not run at all is a single fatal
335/// `key_material` row (secret-free reason).
336#[must_use]
337pub fn key_material_rows(probe: Result<&crate::CheckReport, String>) -> Vec<CheckResult> {
338    const NAME: &str = "key_material";
339    let report = match probe {
340        Ok(report) => report,
341        Err(reason) => {
342            drop(reason);
343            return vec![CheckResult::fatal(
344                NAME,
345                "authenticated key-material probe failed",
346                "Confirm the sealed bundle unlock settings and backend credentials, then rerun `basil doctor --keys` for the detailed probe.",
347            )];
348        }
349    };
350
351    let total = report.keys.len();
352    let present = report.present_count();
353    let required_missing = report.required_missing();
354
355    // Aggregate summary row (stable `key_material` name for scripting).
356    let summary = if required_missing.is_empty() {
357        let optional_missing = report.missing().count();
358        if optional_missing == 0 {
359            CheckResult::ok(
360                NAME,
361                format!("all {total} catalog key(s) are present in the backend"),
362            )
363        } else {
364            CheckResult::warn(
365                NAME,
366                format!(
367                    "{present}/{total} catalog key(s) present; {optional_missing} optional key(s) absent"
368                ),
369                "Provision optional keys if their operations should be available, or let startup reconcile generate keys declared missing=generate.",
370            )
371        }
372    } else {
373        CheckResult::fatal(
374            NAME,
375            format!(
376                "{present}/{total} catalog key(s) present; required key(s) absent: {}",
377                required_missing.join(", ")
378            ),
379            "Provision the missing required key material, then rerun `basil doctor --keys`.",
380        )
381    };
382
383    let mut rows = vec![summary];
384
385    // Per-key detail rows for every ABSENT key, classified by its missing policy.
386    for (name, policy) in report.missing() {
387        let row_name = format!("key_material:{name}");
388        let row = match policy {
389            crate::catalog::MissingPolicy::Error => CheckResult::fatal(
390                row_name,
391                format!(
392                    "required key `{name}` is absent (missing=error); reconcile cannot satisfy it"
393                ),
394                "Provision this key in its backend before starting the broker.",
395            ),
396            crate::catalog::MissingPolicy::Warn => CheckResult::warn(
397                row_name,
398                format!(
399                    "key `{name}` is absent (missing=warn); operations on it fail until provisioned"
400                ),
401                "Provision the key if its operations should be available.",
402            ),
403            crate::catalog::MissingPolicy::Generate => CheckResult::warn(
404                row_name,
405                format!(
406                    "key `{name}` is absent (missing=generate); startup reconcile will create it"
407                ),
408                "No action required unless you expected the key to already exist.",
409            ),
410        };
411        rows.push(row);
412    }
413
414    rows
415}
416
417/// Load + validate the catalog/policy via the SAME loader `check`/`run` use. A
418/// load/validation error is returned verbatim for the catalog/policy check row.
419fn load_catalog_policy(catalog_path: &Path, policy_path: &Path) -> Result<crate::Catalog, String> {
420    let catalog_json = std::fs::read_to_string(catalog_path)
421        .map_err(|e| format!("reading catalog {}: {e}", catalog_path.display()))?;
422    let policy_json = std::fs::read_to_string(policy_path)
423        .map_err(|e| format!("reading policy {}: {e}", policy_path.display()))?;
424    let (catalog, _policy, _config, _warnings) =
425        crate::catalog::load(&catalog_json, &policy_json).map_err(|e| e.to_string())?;
426    Ok(catalog)
427}
428
429fn catalog_policy_check(loaded: &Result<crate::Catalog, String>) -> CheckResult {
430    const NAME: &str = "catalog_policy";
431    match loaded {
432        Ok(c) => CheckResult::ok(
433            NAME,
434            format!(
435                "catalog + policy load and validate; {} backend(s)",
436                c.backends.len()
437            ),
438        ),
439        Err(reason) => CheckResult::fatal(
440            NAME,
441            format!("catalog/policy did not load: {reason}"),
442            "Fix the catalog/policy export so it validates; the loader reason above names the \
443             offending field.",
444        ),
445    }
446}
447
448/// Offline backend-capability enforcement: does each backend PROVIDE what the
449/// catalog's keys (and explicit `requires`) need? Honors `capability-policy`;
450/// under a policy that makes a gap fatal, an unmet requirement would fail broker
451/// startup, so it is FATAL here too. Pure and offline (no backend I/O).
452fn capability_check(catalog: &crate::Catalog, policy: crate::CapabilityPolicy) -> CheckResult {
453    const NAME: &str = "capability";
454    match crate::enforce_capabilities(catalog, policy) {
455        Ok(s) => CheckResult::ok(
456            NAME,
457            format!(
458                "backend capabilities cover the catalog: {} enforced, {} undeclared (skipped), {} warning(s)",
459                s.enforced, s.skipped_undeclared, s.warnings
460            ),
461        ),
462        Err(e) => CheckResult::fatal(
463            NAME,
464            format!("backend capability gap: {e}"),
465            "Grant the backend the missing capabilities, or relax `capability-policy`; under the \
466             configured policy this gap would fail broker startup.",
467        ),
468    }
469}
470
471/// Offline invocation validation: when invocation is enabled, its broker-identity
472/// and request/response key bindings must resolve to catalog keys of the right
473/// class and use. A bad binding would fail broker startup, so it is FATAL. Pure
474/// and offline (no bundle, no backend).
475fn invocation_bindings_check(
476    invocation: &crate::service::broker::InvocationRuntimeConfig,
477    catalog: &crate::Catalog,
478) -> CheckResult {
479    const NAME: &str = "invocation_bindings";
480    if !invocation.enabled {
481        return CheckResult::ok(
482            NAME,
483            "invocation is disabled; no broker-identity/key bindings to validate",
484        );
485    }
486    match crate::agent_cli::validate_invocation_catalog_bindings(invocation, catalog) {
487        Ok(()) => CheckResult::ok(
488            NAME,
489            "invocation broker-identity and request/response key bindings are valid",
490        ),
491        Err(e) => CheckResult::fatal(
492            NAME,
493            format!("invocation binding invalid: {e}"),
494            "Fix the invocation broker-identity/key configuration; invocation is enabled but its \
495             catalog bindings would fail broker startup.",
496        ),
497    }
498}
499
500/// Does the running binary's feature set support what the config asks for?
501///
502/// - bip39 phrase configured but `unlock-bip39` absent → FATAL.
503/// - age-yubikey requested but `unlock-age-yubikey` absent → FATAL.
504/// - A `keystore`-kind backend in the catalog but `keystore-backend` absent → FATAL.
505fn feature_compatibility_check(
506    inputs: &DoctorInputs,
507    features: EnabledFeatures,
508    backends: Option<&std::collections::BTreeMap<String, crate::catalog::BackendRef>>,
509) -> CheckResult {
510    const NAME: &str = "feature_compatibility";
511    let mut gaps: Vec<String> = Vec::new();
512
513    if inputs.unlock_bip39_selected && !features.unlock_bip39 {
514        gaps.push(
515            "a bip39 break-glass phrase is configured but the binary lacks `unlock-bip39`"
516                .to_string(),
517        );
518    }
519    if inputs.unlock_age_yubikey_selected && !features.unlock_age_yubikey {
520        gaps.push(
521            "age-yubikey unlock is enabled but the binary lacks `unlock-age-yubikey`".to_string(),
522        );
523    }
524    if let Some(backends) = backends {
525        let needs_keystore = backends.values().any(|b| b.kind == BackendKind::Keystore);
526        if needs_keystore && !features.keystore_backend {
527            gaps.push(
528                "the catalog declares a `keystore` backend but the binary lacks `keystore-backend`"
529                    .to_string(),
530            );
531        }
532        let needs_aws_kms = backends.values().any(|b| b.kind == BackendKind::AwsKms);
533        if needs_aws_kms && !features.aws_kms {
534            gaps.push(
535                "the catalog declares an `aws-kms` backend but the binary lacks `aws-kms`"
536                    .to_string(),
537            );
538        }
539        let needs_gcp_kms = backends.values().any(|b| b.kind == BackendKind::GcpKms);
540        if needs_gcp_kms && !features.gcp_kms {
541            gaps.push(
542                "the catalog declares a `gcp-kms` backend but the binary lacks `gcp-kms`"
543                    .to_string(),
544            );
545        }
546    }
547
548    if gaps.is_empty() {
549        CheckResult::ok(
550            NAME,
551            "the binary's enabled features cover the configured backend + unlock methods",
552        )
553    } else {
554        CheckResult::fatal(
555            NAME,
556            format!("feature gap(s): {}", gaps.join("; ")),
557            "Rebuild `basil` with the missing cargo feature(s) enabled \
558             (e.g. `--features unlock-bip39,keystore-backend`), or remove the \
559             corresponding config option.",
560        )
561    }
562}
563
564/// Is the configured backend CLI on `PATH`? For `vault`-kind backends the CLI is
565/// `bao` (`OpenBao`) or `vault` (`HashiCorp`); either satisfies the check. A
566/// `keystore`-kind backend needs no external CLI.
567fn backend_binary_check(
568    backends: Option<&std::collections::BTreeMap<String, crate::catalog::BackendRef>>,
569) -> CheckResult {
570    const NAME: &str = "backend_binary";
571    let Some(backends) = backends else {
572        return catalog_dependent_skip(NAME);
573    };
574
575    let has_vault = backends.values().any(|b| b.kind == BackendKind::Vault);
576    if !has_vault {
577        return CheckResult::ok(
578            NAME,
579            "no vault-kind backend in the catalog; no external CLI required",
580        );
581    }
582
583    let bao = on_path("bao");
584    let vault = on_path("vault");
585    match (bao, vault) {
586        (true, true) => CheckResult::ok(NAME, "both `bao` and `vault` are on PATH"),
587        (true, false) => CheckResult::ok(NAME, "`bao` (OpenBao) is on PATH"),
588        (false, true) => CheckResult::ok(NAME, "`vault` (HashiCorp) is on PATH"),
589        (false, false) => CheckResult::warn(
590            NAME,
591            "neither `bao` nor `vault` is on PATH",
592            "Install the OpenBao (`bao`) or Vault (`vault`) CLI on PATH for \
593             out-of-band provisioning; the daemon talks HTTP and does not strictly \
594             require the CLI, hence advisory.",
595        ),
596    }
597}
598
599/// Is `bin` an executable file on `PATH`? (Mirrors the test harness's `on_path`.)
600fn on_path(bin: &str) -> bool {
601    std::env::var_os("PATH")
602        .is_some_and(|paths| std::env::split_paths(&paths).any(|dir| dir.join(bin).is_file()))
603}
604
605/// Socket sanity: the parent dir exists and is writable, the mode is not
606/// world-writable, and a configured group resolves. The socket is NOT bound. A
607/// parent dir the daemon cannot bind under (missing / unwritable / unresolvable
608/// group) is FATAL; a world-writable mode is an advisory posture warning.
609fn socket_check(socket: &str, mode: u32, group: Option<&str>) -> CheckResult {
610    const NAME: &str = "socket";
611    let path = Path::new(socket);
612    let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) else {
613        return CheckResult::fatal(
614            NAME,
615            format!("socket path `{socket}` has no parent directory"),
616            "Use an absolute socket path under an existing run dir, e.g. /run/basil/basil.sock.",
617        );
618    };
619
620    if !parent.exists() {
621        return CheckResult::fatal(
622            NAME,
623            format!("socket parent dir {} does not exist", parent.display()),
624            format!(
625                "Create the run directory (e.g. `install -d -m 0750 {}`) before starting.",
626                parent.display()
627            ),
628        );
629    }
630    if !dir_is_writable(parent) {
631        return CheckResult::fatal(
632            NAME,
633            format!("socket parent dir {} is not writable", parent.display()),
634            "Make the run directory writable by the user that runs basil.",
635        );
636    }
637
638    // World-writable socket mode (other-write bit set) is a posture problem.
639    if mode & 0o002 != 0 {
640        return CheckResult::warn(
641            NAME,
642            format!("configured socket mode {mode:04o} is world-writable"),
643            "Tighten `socket-mode` to 0600 (owner-only) or 0660 with a `socket-group`; \
644             a world-writable socket lets any local user connect.",
645        );
646    }
647
648    if let Some(group) = group
649        && !group_resolves(group)
650    {
651        return CheckResult::fatal(
652            NAME,
653            format!("configured socket-group `{group}` does not resolve to a gid"),
654            "Create the group, or set `socket-group` to an existing group name or numeric gid.",
655        );
656    }
657
658    CheckResult::ok(
659        NAME,
660        format!(
661            "parent dir {} exists + writable; mode {mode:04o} not world-writable",
662            parent.display()
663        ),
664    )
665}
666
667/// Can we create an entry under `dir`? Probe with a temp file we immediately
668/// remove (never binds a socket). On non-unix or any error, fall back to a
669/// permissions-bit heuristic.
670fn dir_is_writable(dir: &Path) -> bool {
671    let probe = dir.join(format!(".basil-doctor-{}.tmp", std::process::id()));
672    match std::fs::File::create(&probe) {
673        Ok(_) => {
674            let _ = std::fs::remove_file(&probe);
675            true
676        }
677        Err(_) => false,
678    }
679}
680
681/// Does `group` (a name or numeric gid) resolve? A numeric gid always resolves;
682/// a name is looked up in `/etc/group`. Read-only; failure to read the group
683/// file is treated as "cannot confirm" → does not resolve.
684fn group_resolves(group: &str) -> bool {
685    if group.parse::<u32>().is_ok() {
686        return true;
687    }
688    let Ok(body) = std::fs::read_to_string("/etc/group") else {
689        return false;
690    };
691    body.lines()
692        .filter_map(|line| line.split(':').next())
693        .any(|name| name == group)
694}
695
696/// Bundle diagnostics: existence + readability, `0600` perms, and epoch freshness
697/// (sidecar present + not behind the bundle). Each is its own row so an operator
698/// sees exactly which property failed. **Never reads/decrypts contents.**
699fn bundle_checks(bundle: &Path) -> Vec<CheckResult> {
700    const READ_NAME: &str = "bundle_readable";
701    const PERMS_NAME: &str = "bundle_perms";
702    const FRESH_NAME: &str = "bundle_freshness";
703
704    // 1. Existence + readability (read the bytes only to confirm access; the bytes
705    //    are an opaque sealed blob; we never expose them).
706    let bytes = match std::fs::read(bundle) {
707        Ok(b) => b,
708        Err(e) => {
709            let detail = format!("sealed bundle {} is not readable: {e}", bundle.display());
710            // A missing/unreadable bundle blocks the perms + freshness checks too;
711            // emit them as skipped so the row set is stable.
712            return vec![
713                CheckResult::fatal(
714                    READ_NAME,
715                    detail,
716                    "Place the 0600 sealed bundle at the configured path and ensure the \
717                     daemon user can read it.",
718                ),
719                CheckResult::warn(
720                    PERMS_NAME,
721                    "skipped: bundle not readable",
722                    "Resolve bundle_readable first.",
723                ),
724                CheckResult::warn(
725                    FRESH_NAME,
726                    "skipped: bundle not readable",
727                    "Resolve bundle_readable first.",
728                ),
729            ];
730        }
731    };
732
733    let read_row = CheckResult::ok(
734        READ_NAME,
735        format!("sealed bundle {} exists and is readable", bundle.display()),
736    );
737    let perms_row = bundle_perms_check(PERMS_NAME, bundle);
738    let fresh_row = bundle_freshness_check(FRESH_NAME, bundle, &bytes);
739    vec![read_row, perms_row, fresh_row]
740}
741
742/// `0600` permission check on the bundle file (owner-only). Loose perms are an
743/// advisory WARNING: startup only refuses them under `strict-bundle-perms`, so on
744/// their own they do not stop the broker from starting.
745fn bundle_perms_check(name: &'static str, bundle: &Path) -> CheckResult {
746    #[cfg(unix)]
747    {
748        use std::os::unix::fs::PermissionsExt;
749        match std::fs::metadata(bundle) {
750            Ok(meta) => {
751                let mode = meta.permissions().mode() & 0o777;
752                if mode == 0o600 {
753                    CheckResult::ok(name, "sealed bundle is 0600 (owner-only)")
754                } else {
755                    CheckResult::warn(
756                        name,
757                        format!("sealed bundle has mode {mode:04o}, expected 0600"),
758                        format!(
759                            "Run `chmod 600 {}`; broader perms leak the sealed creds. Startup \
760                             refuses this only under `strict-bundle-perms`, hence advisory.",
761                            bundle.display()
762                        ),
763                    )
764                }
765            }
766            Err(e) => CheckResult::fatal(
767                name,
768                format!("cannot stat sealed bundle: {e}"),
769                "Ensure the bundle path is correct and accessible.",
770            ),
771        }
772    }
773    #[cfg(not(unix))]
774    {
775        let _ = bundle;
776        CheckResult::warn(
777            name,
778            "permission check is unix-only; skipped on this platform",
779            "Ensure the bundle is owner-only by your platform's equivalent.",
780        )
781    }
782}
783
784/// Epoch freshness: parse the bundle's PUBLIC header for its epoch, read the
785/// `.epoch` sidecar (if present) and compare. Read-only: unlike
786/// [`crate::seal::verify_epoch_sidecar`], doctor never writes the sidecar. The
787/// only field touched is the non-secret monotonic epoch counter.
788fn bundle_freshness_check(name: &'static str, bundle: &Path, bytes: &[u8]) -> CheckResult {
789    let parsed = match crate::seal::format::decode(bytes) {
790        Ok(p) => p,
791        Err(e) => {
792            return CheckResult::fatal(
793                name,
794                format!("sealed bundle does not parse (corrupt/wrong format): {e}"),
795                "Re-export the sealed bundle; the on-disk file is not a valid basil bundle.",
796            );
797        }
798    };
799    let current = parsed.body.header.epoch;
800    let sidecar = epoch_sidecar_path(bundle);
801
802    match std::fs::read_to_string(&sidecar) {
803        Ok(raw) => match raw.trim().parse::<u64>() {
804            Ok(seen) if current < seen => CheckResult::fatal(
805                name,
806                format!(
807                    "STALE bundle: epoch {current} is behind the last-seen sidecar epoch {seen}"
808                ),
809                "An older bundle was swapped in. Restore the current bundle, or if the \
810                 rollback is intentional, remove the stale .epoch sidecar.",
811            ),
812            Ok(seen) => CheckResult::ok(
813                name,
814                format!("bundle epoch {current} is current (sidecar last-seen {seen})"),
815            ),
816            Err(e) => CheckResult::fatal(
817                name,
818                format!(
819                    "epoch sidecar {} is not a valid integer: {e}",
820                    sidecar.display()
821                ),
822                "Remove the corrupt .epoch sidecar; the daemon re-initializes it at startup.",
823            ),
824        },
825        Err(e) if e.kind() == std::io::ErrorKind::NotFound => CheckResult::warn(
826            name,
827            format!("epoch sidecar {} is absent (first boot)", sidecar.display()),
828            "Expected on first boot; the daemon initializes it at startup. After the first \
829             successful start it should be present.",
830        ),
831        Err(e) => CheckResult::fatal(
832            name,
833            format!("epoch sidecar {} is not readable: {e}", sidecar.display()),
834            "Ensure the daemon user can read the .epoch sidecar next to the bundle.",
835        ),
836    }
837}
838
839/// The `.epoch` sidecar path next to a bundle (mirrors `unlock::epoch_sidecar_path`).
840fn epoch_sidecar_path(bundle: &Path) -> std::path::PathBuf {
841    let mut p = std::ffi::OsString::from(bundle.as_os_str());
842    p.push(".epoch");
843    std::path::PathBuf::from(p)
844}
845
846/// Backend reachability: a bounded, unauthenticated probe of each distinct
847/// vault-kind backend address. Hits the Vault/OpenBao `/v1/sys/health` endpoint,
848/// which needs no token. Unreachable within [`REACHABILITY_TIMEOUT`] → FATAL
849/// (never a hang). A keystore-only catalog has nothing to probe.
850async fn backend_reachability_check(
851    backends: Option<&std::collections::BTreeMap<String, crate::catalog::BackendRef>>,
852) -> CheckResult {
853    const NAME: &str = "backend_reachability";
854    let Some(backends) = backends else {
855        return catalog_dependent_skip(NAME);
856    };
857
858    // Distinct vault addresses (a keystore addr is a local path, not probed here).
859    let mut addrs: Vec<String> = backends
860        .values()
861        .filter(|b| b.kind == BackendKind::Vault)
862        .map(|b| b.addr.clone())
863        .collect();
864    addrs.sort();
865    addrs.dedup();
866
867    if addrs.is_empty() {
868        return CheckResult::ok(
869            NAME,
870            "no vault-kind backend to probe (keystore-only or no backends)",
871        );
872    }
873
874    crate::ensure_crypto_provider();
875    let client = match reqwest::Client::builder()
876        .timeout(REACHABILITY_TIMEOUT)
877        .build()
878    {
879        Ok(c) => c,
880        Err(e) => {
881            return CheckResult::fatal(
882                NAME,
883                format!("could not build HTTP client for the reachability probe: {e}"),
884                "This is unexpected; check the host's TLS/HTTP stack.",
885            );
886        }
887    };
888
889    let mut unreachable: Vec<String> = Vec::new();
890    let mut reached = 0usize;
891    for addr in &addrs {
892        if probe_vault_health(&client, addr).await {
893            reached += 1;
894        } else {
895            unreachable.push(addr.clone());
896        }
897    }
898
899    if unreachable.is_empty() {
900        CheckResult::ok(
901            NAME,
902            format!("all {reached} vault backend address(es) responded to /v1/sys/health"),
903        )
904    } else {
905        CheckResult::fatal(
906            NAME,
907            format!(
908                "{} of {} vault backend address(es) unreachable: {}",
909                unreachable.len(),
910                addrs.len(),
911                unreachable.join(", ")
912            ),
913            "Start/reach the backend (OpenBao/Vault) at the configured address, or fix \
914             `addr` in the catalog. The probe is unauthenticated (/v1/sys/health) with a \
915             bounded timeout.",
916        )
917    }
918}
919
920/// Probe a single vault address's unauthenticated health endpoint. Any HTTP
921/// response (even sealed/standby 5xx) proves reachability; only a transport
922/// failure (connect refused / timeout / DNS) is "unreachable".
923async fn probe_vault_health(client: &reqwest::Client, addr: &str) -> bool {
924    let url = format!("{}/v1/sys/health", addr.trim_end_matches('/'));
925    client.get(&url).send().await.is_ok()
926}
927
928/// Render the report as grouped, human-readable text to stdout.
929#[must_use]
930pub fn render_human(report: &DoctorReport) -> String {
931    use std::fmt::Write as _;
932    let mut out = String::new();
933    let _ = writeln!(out, "basil doctor - {} check(s)\n", report.checks.len());
934    for c in &report.checks {
935        let marker = match c.status {
936            CheckStatus::Ok => "OK   ",
937            CheckStatus::Warn => "WARN ",
938            CheckStatus::Fatal => "FATAL",
939        };
940        let _ = writeln!(out, "[{marker}] {}: {}", c.name, c.detail);
941        if c.status != CheckStatus::Ok && !c.remediation.is_empty() {
942            let _ = writeln!(out, "       → {}", c.remediation);
943        }
944    }
945    let s = &report.summary;
946    let _ = writeln!(
947        out,
948        "\nsummary: {} ok, {} warn, {} fatal ({} total)",
949        s.ok, s.warn, s.fatal, s.total
950    );
951    if s.blocking {
952        let _ = writeln!(out, "result: BLOCKING, at least one check is fatal");
953    } else if s.warn > 0 {
954        let _ = writeln!(
955            out,
956            "result: OK with advisories (nonzero exit only under --strict)"
957        );
958    } else {
959        let _ = writeln!(out, "result: OK");
960    }
961    out
962}
963
964/// Render the report as the stable `--json` document.
965///
966/// # Errors
967/// Returns a serialization error only on an internal invariant failure (the shape
968/// is plain data, so this is effectively infallible).
969pub fn render_json(report: &DoctorReport) -> Result<String, serde_json::Error> {
970    serde_json::to_string_pretty(report)
971}
972
973#[cfg(test)]
974mod tests {
975    // Indexing into fixed test fixtures (serde_json values, result vecs) is fine;
976    // the no-panic gate targets the runtime path, not assertions.
977    #![allow(clippy::unwrap_used, clippy::indexing_slicing)]
978    use super::*;
979    use crate::catalog::MissingPolicy;
980    use crate::{CheckReport, KeyCheck, KeyStatus};
981    use std::io::Write as _;
982
983    fn unique_dir() -> std::path::PathBuf {
984        let dir = std::env::temp_dir().join(format!(
985            "basil-doctor-test-{}-{}",
986            std::process::id(),
987            uuid::Uuid::new_v4()
988        ));
989        std::fs::create_dir_all(&dir).unwrap();
990        dir
991    }
992
993    fn write_mode(path: &Path, contents: &[u8], mode: u32) {
994        let mut f = std::fs::File::create(path).unwrap();
995        f.write_all(contents).unwrap();
996        #[cfg(unix)]
997        {
998            use std::os::unix::fs::PermissionsExt;
999            std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
1000        }
1001        #[cfg(not(unix))]
1002        let _ = mode;
1003    }
1004
1005    fn all_features_off() -> EnabledFeatures {
1006        EnabledFeatures {
1007            keystore_backend: false,
1008            aws_kms: false,
1009            gcp_kms: false,
1010            unlock_bip39: false,
1011            unlock_age_yubikey: false,
1012        }
1013    }
1014
1015    fn base_inputs(dir: &Path) -> DoctorInputs {
1016        DoctorInputs {
1017            catalog: dir.join("catalog.json"),
1018            policy: dir.join("policy.json"),
1019            bundle: dir.join("bundle.sealed"),
1020            socket: dir.join("basil.sock").to_string_lossy().into_owned(),
1021            socket_mode: 0o600,
1022            socket_group: None,
1023            capability_policy: crate::CapabilityPolicy::Off,
1024            invocation: crate::service::broker::InvocationRuntimeConfig::default(),
1025            unlock_passphrase_selected: false,
1026            unlock_bip39_selected: false,
1027            unlock_age_yubikey_selected: false,
1028        }
1029    }
1030
1031    fn key_check(name: &str, status: KeyStatus) -> KeyCheck {
1032        KeyCheck {
1033            name: name.to_string(),
1034            status,
1035        }
1036    }
1037
1038    // --- feature compatibility ---
1039
1040    #[test]
1041    fn feature_keystore_backend_in_catalog_without_feature_is_fatal() {
1042        let dir = unique_dir();
1043        let inputs = base_inputs(&dir);
1044        let mut backends = std::collections::BTreeMap::new();
1045        backends.insert(
1046            "ks".to_string(),
1047            serde_json::from_value::<crate::catalog::BackendRef>(serde_json::json!({
1048                "kind": "keystore",
1049                "addr": "/var/lib/basil/keystore.db"
1050            }))
1051            .unwrap(),
1052        );
1053        let res = feature_compatibility_check(&inputs, all_features_off(), Some(&backends));
1054        assert_eq!(res.status, CheckStatus::Fatal);
1055        assert!(res.detail.contains("keystore-backend"));
1056    }
1057
1058    #[test]
1059    fn feature_all_satisfied_is_ok() {
1060        let dir = unique_dir();
1061        let inputs = base_inputs(&dir);
1062        let features = EnabledFeatures {
1063            keystore_backend: true,
1064            aws_kms: true,
1065            gcp_kms: true,
1066            unlock_bip39: true,
1067            unlock_age_yubikey: true,
1068        };
1069        let res = feature_compatibility_check(&inputs, features, None);
1070        assert_eq!(res.status, CheckStatus::Ok);
1071    }
1072
1073    // --- socket ---
1074
1075    #[test]
1076    fn socket_missing_parent_dir_is_fatal() {
1077        let res = socket_check("/nonexistent-basil-dir-xyz/basil.sock", 0o600, None);
1078        assert_eq!(res.status, CheckStatus::Fatal);
1079    }
1080
1081    #[test]
1082    fn socket_writable_parent_owner_only_is_ok() {
1083        let dir = unique_dir();
1084        let sock = dir.join("basil.sock");
1085        let res = socket_check(&sock.to_string_lossy(), 0o600, None);
1086        assert_eq!(res.status, CheckStatus::Ok);
1087    }
1088
1089    #[test]
1090    fn socket_world_writable_mode_warns() {
1091        let dir = unique_dir();
1092        let sock = dir.join("basil.sock");
1093        let res = socket_check(&sock.to_string_lossy(), 0o666, None);
1094        assert_eq!(res.status, CheckStatus::Warn);
1095    }
1096
1097    #[test]
1098    fn socket_unresolvable_group_is_fatal() {
1099        let dir = unique_dir();
1100        let sock = dir.join("basil.sock");
1101        let res = socket_check(
1102            &sock.to_string_lossy(),
1103            0o660,
1104            Some("no-such-group-basil-zzz"),
1105        );
1106        assert_eq!(res.status, CheckStatus::Fatal);
1107    }
1108
1109    #[test]
1110    fn socket_numeric_group_resolves() {
1111        let dir = unique_dir();
1112        let sock = dir.join("basil.sock");
1113        let res = socket_check(&sock.to_string_lossy(), 0o660, Some("0"));
1114        assert_eq!(res.status, CheckStatus::Ok);
1115    }
1116
1117    // --- bundle ---
1118
1119    #[test]
1120    fn bundle_missing_is_fatal_and_skips_dependents() {
1121        let dir = unique_dir();
1122        let bundle = dir.join("absent.sealed");
1123        let rows = bundle_checks(&bundle);
1124        assert_eq!(rows.len(), 3);
1125        assert_eq!(rows[0].status, CheckStatus::Fatal); // readable
1126        assert_eq!(rows[1].status, CheckStatus::Warn); // perms skipped
1127        assert_eq!(rows[2].status, CheckStatus::Warn); // freshness skipped
1128    }
1129
1130    #[test]
1131    fn bundle_bad_perms_warns_perms_row() {
1132        let dir = unique_dir();
1133        let bundle = dir.join("bundle.sealed");
1134        write_mode(&bundle, b"not-a-real-bundle", 0o644);
1135        let row = bundle_perms_check("bundle_perms", &bundle);
1136        // Loose perms are advisory: startup refuses them only under
1137        // `strict-bundle-perms`, so on their own they do not stop startup.
1138        #[cfg(unix)]
1139        assert_eq!(row.status, CheckStatus::Warn);
1140        #[cfg(not(unix))]
1141        assert_eq!(row.status, CheckStatus::Warn);
1142    }
1143
1144    #[test]
1145    fn bundle_good_perms_pass() {
1146        let dir = unique_dir();
1147        let bundle = dir.join("bundle.sealed");
1148        write_mode(&bundle, b"blob", 0o600);
1149        let row = bundle_perms_check("bundle_perms", &bundle);
1150        #[cfg(unix)]
1151        assert_eq!(row.status, CheckStatus::Ok);
1152    }
1153
1154    #[test]
1155    fn bundle_freshness_unparsable_blob_is_fatal() {
1156        let dir = unique_dir();
1157        let bundle = dir.join("bundle.sealed");
1158        let row = bundle_freshness_check("bundle_freshness", &bundle, b"garbage-not-a-bundle");
1159        assert_eq!(row.status, CheckStatus::Fatal);
1160        assert!(row.detail.contains("does not parse"));
1161    }
1162
1163    // --- catalog/policy ---
1164
1165    #[test]
1166    fn catalog_unloadable_is_fatal() {
1167        let dir = unique_dir();
1168        std::fs::write(dir.join("catalog.json"), "not json").unwrap();
1169        std::fs::write(dir.join("policy.json"), "not json").unwrap();
1170        let loaded = load_catalog_policy(&dir.join("catalog.json"), &dir.join("policy.json"));
1171        let row = catalog_policy_check(&loaded);
1172        assert_eq!(row.status, CheckStatus::Fatal);
1173    }
1174
1175    // --- summary / exit logic ---
1176
1177    #[test]
1178    fn summary_counts_and_blocking_iff_any_fatal() {
1179        let checks = vec![
1180            CheckResult::ok("a", "fine"),
1181            CheckResult::warn("b", "meh", "do x"),
1182        ];
1183        let report = DoctorReport::from_checks(checks);
1184        assert!(!report.summary.blocking);
1185        assert_eq!(report.summary.warn, 1);
1186
1187        let checks = vec![
1188            CheckResult::ok("a", "fine"),
1189            CheckResult::fatal("c", "broken", "fix it"),
1190        ];
1191        let report = DoctorReport::from_checks(checks);
1192        assert!(report.summary.blocking);
1193        assert_eq!(report.summary.fatal, 1);
1194    }
1195
1196    #[test]
1197    fn exit_code_model_fatal_vs_warn_vs_strict() {
1198        // All-ok: never exits nonzero.
1199        let ok = DoctorReport::from_checks(vec![CheckResult::ok("a", "fine")]);
1200        assert!(!ok.should_exit_nonzero(false));
1201        assert!(!ok.should_exit_nonzero(true));
1202
1203        // Warn-only: exits nonzero only under --strict.
1204        let warn = DoctorReport::from_checks(vec![CheckResult::warn("b", "meh", "do x")]);
1205        assert!(!warn.should_exit_nonzero(false));
1206        assert!(warn.should_exit_nonzero(true));
1207
1208        // Any fatal: always exits nonzero, strict or not.
1209        let fatal = DoctorReport::from_checks(vec![
1210            CheckResult::warn("b", "meh", "do x"),
1211            CheckResult::fatal("c", "broken", "fix it"),
1212        ]);
1213        assert!(fatal.should_exit_nonzero(false));
1214        assert!(fatal.should_exit_nonzero(true));
1215    }
1216
1217    #[test]
1218    fn json_shape_is_stable() {
1219        let report = DoctorReport::from_checks(vec![CheckResult::fatal("x", "d", "r")]);
1220        let json = render_json(&report).unwrap();
1221        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
1222        assert_eq!(v["schema_version"], DOCTOR_SCHEMA_VERSION);
1223        assert_eq!(v["checks"][0]["name"], "x");
1224        assert_eq!(v["checks"][0]["status"], "fatal");
1225        assert_eq!(v["checks"][0]["detail"], "d");
1226        assert_eq!(v["checks"][0]["remediation"], "r");
1227        assert_eq!(v["summary"]["fatal"], 1);
1228        assert_eq!(v["summary"]["blocking"], true);
1229    }
1230
1231    // --- key material (--keys) per-key detail ---
1232
1233    #[test]
1234    fn key_material_probe_failure_omits_secret_bearing_reason() {
1235        let rows = key_material_rows(Err(
1236            "reading passphrase from /run/credentials/basil/passphrase failed; \
1237             sealed bundle /var/lib/basil/bootstrap.bundle; \
1238             Authorization: Bearer vault-token-s.123; upstream body has credential"
1239                .to_string(),
1240        ));
1241        assert_eq!(rows.len(), 1);
1242        assert_eq!(rows[0].status, CheckStatus::Fatal);
1243        let visible = format!("{} {}", rows[0].detail, rows[0].remediation);
1244        for canary in [
1245            "/run/credentials/basil/passphrase",
1246            "/var/lib/basil/bootstrap.bundle",
1247            "vault-token-s.123",
1248            "upstream body has credential",
1249        ] {
1250            assert!(
1251                !visible.contains(canary),
1252                "doctor row leaked secret canary `{canary}` in `{visible}`"
1253            );
1254        }
1255    }
1256
1257    #[test]
1258    fn key_material_all_present_is_single_ok_row() {
1259        let report = CheckReport {
1260            keys: vec![
1261                key_check("web-tls", KeyStatus::Present),
1262                key_check("app-sign", KeyStatus::Present),
1263            ],
1264        };
1265        let rows = key_material_rows(Ok(&report));
1266        assert_eq!(rows.len(), 1);
1267        assert_eq!(rows[0].name, "key_material");
1268        assert_eq!(rows[0].status, CheckStatus::Ok);
1269    }
1270
1271    #[test]
1272    fn key_material_required_missing_is_fatal_with_per_key_detail() {
1273        let report = CheckReport {
1274            keys: vec![
1275                key_check("present-key", KeyStatus::Present),
1276                key_check("req-key", KeyStatus::Missing(MissingPolicy::Error)),
1277                key_check("gen-key", KeyStatus::Missing(MissingPolicy::Generate)),
1278            ],
1279        };
1280        let rows = key_material_rows(Ok(&report));
1281        // aggregate + 2 per-missing-key rows.
1282        assert_eq!(rows.len(), 3);
1283        // aggregate: required missing → fatal.
1284        assert_eq!(rows[0].name, "key_material");
1285        assert_eq!(rows[0].status, CheckStatus::Fatal);
1286        // per-key rows, one fatal (error), one warn (generate).
1287        let req = rows
1288            .iter()
1289            .find(|r| r.name == "key_material:req-key")
1290            .expect("req-key row present");
1291        assert_eq!(req.status, CheckStatus::Fatal);
1292        let generate_row = rows
1293            .iter()
1294            .find(|r| r.name == "key_material:gen-key")
1295            .expect("gen-key row present");
1296        assert_eq!(generate_row.status, CheckStatus::Warn);
1297    }
1298
1299    #[test]
1300    fn key_material_only_generate_missing_is_warn_not_fatal() {
1301        let report = CheckReport {
1302            keys: vec![
1303                key_check("present-key", KeyStatus::Present),
1304                key_check("gen-key", KeyStatus::Missing(MissingPolicy::Generate)),
1305            ],
1306        };
1307        let rows = key_material_rows(Ok(&report));
1308        assert_eq!(rows[0].status, CheckStatus::Warn); // aggregate
1309        let report = DoctorReport::from_checks(rows);
1310        assert!(!report.summary.blocking);
1311        assert!(!report.should_exit_nonzero(false));
1312        assert!(report.should_exit_nonzero(true));
1313    }
1314
1315    #[tokio::test]
1316    async fn unreachable_backend_is_fatal_within_timeout() {
1317        let mut backends = std::collections::BTreeMap::new();
1318        // 127.0.0.1:1 is reserved/closed; connect is refused quickly.
1319        backends.insert(
1320            "v".to_string(),
1321            serde_json::from_value::<crate::catalog::BackendRef>(serde_json::json!({
1322                "kind": "vault",
1323                "addr": "http://127.0.0.1:1"
1324            }))
1325            .unwrap(),
1326        );
1327        let started = std::time::Instant::now();
1328        let res = backend_reachability_check(Some(&backends)).await;
1329        assert!(started.elapsed() < REACHABILITY_TIMEOUT * 3);
1330        assert_eq!(res.status, CheckStatus::Fatal);
1331    }
1332
1333    #[tokio::test]
1334    async fn keystore_only_catalog_skips_reachability() {
1335        let mut backends = std::collections::BTreeMap::new();
1336        backends.insert(
1337            "ks".to_string(),
1338            serde_json::from_value::<crate::catalog::BackendRef>(serde_json::json!({
1339                "kind": "keystore",
1340                "addr": "/var/lib/basil/keystore.db"
1341            }))
1342            .unwrap(),
1343        );
1344        let res = backend_reachability_check(Some(&backends)).await;
1345        assert_eq!(res.status, CheckStatus::Ok);
1346    }
1347}