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