Skip to main content

basil_core/
doctor.rs

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