1use std::path::Path;
50use std::time::Duration;
51
52use serde::Serialize;
53
54use crate::catalog::BackendKind;
55
56pub const DOCTOR_SCHEMA_VERSION: u32 = 2;
59
60const REACHABILITY_TIMEOUT: Duration = Duration::from_secs(3);
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
71#[serde(rename_all = "lowercase")]
72pub enum CheckStatus {
73 Ok,
75 Warn,
77 Fatal,
79}
80
81#[derive(Debug, Clone, Serialize)]
90pub struct CheckResult {
91 pub name: String,
93 pub status: CheckStatus,
95 pub detail: String,
97 pub remediation: String,
99}
100
101impl CheckResult {
102 pub(crate) fn ok(name: impl Into<String>, detail: impl Into<String>) -> Self {
103 Self {
104 name: name.into(),
105 status: CheckStatus::Ok,
106 detail: detail.into(),
107 remediation: String::new(),
108 }
109 }
110
111 pub(crate) fn warn(
112 name: impl Into<String>,
113 detail: impl Into<String>,
114 remediation: impl Into<String>,
115 ) -> Self {
116 Self {
117 name: name.into(),
118 status: CheckStatus::Warn,
119 detail: detail.into(),
120 remediation: remediation.into(),
121 }
122 }
123
124 pub(crate) fn fatal(
125 name: impl Into<String>,
126 detail: impl Into<String>,
127 remediation: impl Into<String>,
128 ) -> Self {
129 Self {
130 name: name.into(),
131 status: CheckStatus::Fatal,
132 detail: detail.into(),
133 remediation: remediation.into(),
134 }
135 }
136}
137
138#[derive(Debug, Clone, Copy, Serialize)]
140pub struct DoctorSummary {
141 pub total: usize,
143 pub ok: usize,
145 pub warn: usize,
147 pub fatal: usize,
149 pub blocking: bool,
152}
153
154#[derive(Debug, Clone, Serialize)]
156pub struct DoctorReport {
157 pub schema_version: u32,
159 pub checks: Vec<CheckResult>,
161 pub summary: DoctorSummary,
163}
164
165impl DoctorReport {
166 #[must_use]
168 pub fn from_checks(checks: Vec<CheckResult>) -> Self {
169 let mut ok = 0;
170 let mut warn = 0;
171 let mut fatal = 0;
172 for c in &checks {
173 match c.status {
174 CheckStatus::Ok => ok += 1,
175 CheckStatus::Warn => warn += 1,
176 CheckStatus::Fatal => fatal += 1,
177 }
178 }
179 let summary = DoctorSummary {
180 total: checks.len(),
181 ok,
182 warn,
183 fatal,
184 blocking: fatal > 0,
185 };
186 Self {
187 schema_version: DOCTOR_SCHEMA_VERSION,
188 checks,
189 summary,
190 }
191 }
192
193 #[must_use]
197 pub const fn should_exit_nonzero(&self, strict: bool) -> bool {
198 self.summary.fatal > 0 || (strict && self.summary.warn > 0)
199 }
200}
201
202#[derive(Debug, Clone)]
207pub struct DoctorInputs {
208 pub catalog: std::path::PathBuf,
210 pub policy: std::path::PathBuf,
212 pub bundle: std::path::PathBuf,
214 pub socket: String,
216 pub socket_mode: u32,
218 pub socket_group: Option<String>,
220 pub capability_policy: crate::CapabilityPolicy,
222 pub invocation: crate::service::broker::InvocationRuntimeConfig,
225 pub unlock_passphrase_selected: bool,
227 pub unlock_bip39_selected: bool,
229 pub unlock_age_yubikey_selected: bool,
231}
232
233#[allow(clippy::struct_excessive_bools)]
241#[derive(Debug, Clone, Copy)]
242pub struct EnabledFeatures {
243 pub keystore_backend: bool,
245 pub aws_kms: bool,
247 pub gcp_kms: bool,
249 pub unlock_bip39: bool,
251 pub unlock_age_yubikey: bool,
253}
254
255impl EnabledFeatures {
256 #[must_use]
258 pub const fn current() -> Self {
259 Self {
260 keystore_backend: cfg!(feature = "keystore-backend"),
261 aws_kms: cfg!(feature = "aws-kms"),
262 gcp_kms: cfg!(feature = "gcp-kms"),
263 unlock_bip39: cfg!(feature = "unlock-bip39"),
264 unlock_age_yubikey: cfg!(feature = "unlock-age-yubikey"),
265 }
266 }
267}
268
269pub async fn run_doctor(inputs: &DoctorInputs, features: EnabledFeatures) -> DoctorReport {
280 let mut checks = Vec::new();
281
282 let loaded = load_catalog_policy(&inputs.catalog, &inputs.policy);
284 checks.push(catalog_policy_check(&loaded));
285
286 if let Ok(catalog) = loaded.as_ref() {
288 checks.push(capability_check(catalog, inputs.capability_policy));
289 checks.push(invocation_bindings_check(&inputs.invocation, catalog));
290 } else {
291 checks.push(catalog_dependent_skip("capability"));
292 checks.push(catalog_dependent_skip("invocation_bindings"));
293 }
294
295 let backends = loaded.as_ref().ok().map(|c| c.backends.clone());
296
297 checks.push(feature_compatibility_check(
298 inputs,
299 features,
300 backends.as_ref(),
301 ));
302 checks.push(backend_binary_check(backends.as_ref()));
303 checks.push(socket_check(
304 &inputs.socket,
305 inputs.socket_mode,
306 inputs.socket_group.as_deref(),
307 ));
308 checks.extend(bundle_checks(&inputs.bundle));
309 checks.push(backend_reachability_check(backends.as_ref()).await);
310
311 DoctorReport::from_checks(checks)
312}
313
314fn catalog_dependent_skip(name: &'static str) -> CheckResult {
317 CheckResult::warn(
318 name,
319 "skipped: catalog did not load",
320 "Fix the catalog/policy load first (see the catalog_policy check).",
321 )
322}
323
324#[must_use]
337pub fn key_material_rows(probe: Result<&crate::CheckReport, String>) -> Vec<CheckResult> {
338 const NAME: &str = "key_material";
339 let report = match probe {
340 Ok(report) => report,
341 Err(reason) => {
342 drop(reason);
343 return vec![CheckResult::fatal(
344 NAME,
345 "authenticated key-material probe failed",
346 "Confirm the sealed bundle unlock settings and backend credentials, then rerun `basil doctor --keys` for the detailed probe.",
347 )];
348 }
349 };
350
351 let total = report.keys.len();
352 let present = report.present_count();
353 let required_missing = report.required_missing();
354
355 let summary = if required_missing.is_empty() {
357 let optional_missing = report.missing().count();
358 if optional_missing == 0 {
359 CheckResult::ok(
360 NAME,
361 format!("all {total} catalog key(s) are present in the backend"),
362 )
363 } else {
364 CheckResult::warn(
365 NAME,
366 format!(
367 "{present}/{total} catalog key(s) present; {optional_missing} optional key(s) absent"
368 ),
369 "Provision optional keys if their operations should be available, or let startup reconcile generate keys declared missing=generate.",
370 )
371 }
372 } else {
373 CheckResult::fatal(
374 NAME,
375 format!(
376 "{present}/{total} catalog key(s) present; required key(s) absent: {}",
377 required_missing.join(", ")
378 ),
379 "Provision the missing required key material, then rerun `basil doctor --keys`.",
380 )
381 };
382
383 let mut rows = vec![summary];
384
385 for (name, policy) in report.missing() {
387 let row_name = format!("key_material:{name}");
388 let row = match policy {
389 crate::catalog::MissingPolicy::Error => CheckResult::fatal(
390 row_name,
391 format!(
392 "required key `{name}` is absent (missing=error); reconcile cannot satisfy it"
393 ),
394 "Provision this key in its backend before starting the broker.",
395 ),
396 crate::catalog::MissingPolicy::Warn => CheckResult::warn(
397 row_name,
398 format!(
399 "key `{name}` is absent (missing=warn); operations on it fail until provisioned"
400 ),
401 "Provision the key if its operations should be available.",
402 ),
403 crate::catalog::MissingPolicy::Generate => CheckResult::warn(
404 row_name,
405 format!(
406 "key `{name}` is absent (missing=generate); startup reconcile will create it"
407 ),
408 "No action required unless you expected the key to already exist.",
409 ),
410 };
411 rows.push(row);
412 }
413
414 rows
415}
416
417fn load_catalog_policy(catalog_path: &Path, policy_path: &Path) -> Result<crate::Catalog, String> {
420 let catalog_json = std::fs::read_to_string(catalog_path)
421 .map_err(|e| format!("reading catalog {}: {e}", catalog_path.display()))?;
422 let policy_json = std::fs::read_to_string(policy_path)
423 .map_err(|e| format!("reading policy {}: {e}", policy_path.display()))?;
424 let (catalog, _policy, _config, _warnings) =
425 crate::catalog::load(&catalog_json, &policy_json).map_err(|e| e.to_string())?;
426 Ok(catalog)
427}
428
429fn catalog_policy_check(loaded: &Result<crate::Catalog, String>) -> CheckResult {
430 const NAME: &str = "catalog_policy";
431 match loaded {
432 Ok(c) => CheckResult::ok(
433 NAME,
434 format!(
435 "catalog + policy load and validate; {} backend(s)",
436 c.backends.len()
437 ),
438 ),
439 Err(reason) => CheckResult::fatal(
440 NAME,
441 format!("catalog/policy did not load: {reason}"),
442 "Fix the catalog/policy export so it validates; the loader reason above names the \
443 offending field.",
444 ),
445 }
446}
447
448fn capability_check(catalog: &crate::Catalog, policy: crate::CapabilityPolicy) -> CheckResult {
453 const NAME: &str = "capability";
454 match crate::enforce_capabilities(catalog, policy) {
455 Ok(s) => CheckResult::ok(
456 NAME,
457 format!(
458 "backend capabilities cover the catalog: {} enforced, {} undeclared (skipped), {} warning(s)",
459 s.enforced, s.skipped_undeclared, s.warnings
460 ),
461 ),
462 Err(e) => CheckResult::fatal(
463 NAME,
464 format!("backend capability gap: {e}"),
465 "Grant the backend the missing capabilities, or relax `capability-policy`; under the \
466 configured policy this gap would fail broker startup.",
467 ),
468 }
469}
470
471fn invocation_bindings_check(
476 invocation: &crate::service::broker::InvocationRuntimeConfig,
477 catalog: &crate::Catalog,
478) -> CheckResult {
479 const NAME: &str = "invocation_bindings";
480 if !invocation.enabled {
481 return CheckResult::ok(
482 NAME,
483 "invocation is disabled; no broker-identity/key bindings to validate",
484 );
485 }
486 match crate::agent_cli::validate_invocation_catalog_bindings(invocation, catalog) {
487 Ok(()) => CheckResult::ok(
488 NAME,
489 "invocation broker-identity and request/response key bindings are valid",
490 ),
491 Err(e) => CheckResult::fatal(
492 NAME,
493 format!("invocation binding invalid: {e}"),
494 "Fix the invocation broker-identity/key configuration; invocation is enabled but its \
495 catalog bindings would fail broker startup.",
496 ),
497 }
498}
499
500fn feature_compatibility_check(
506 inputs: &DoctorInputs,
507 features: EnabledFeatures,
508 backends: Option<&std::collections::BTreeMap<String, crate::catalog::BackendRef>>,
509) -> CheckResult {
510 const NAME: &str = "feature_compatibility";
511 let mut gaps: Vec<String> = Vec::new();
512
513 if inputs.unlock_bip39_selected && !features.unlock_bip39 {
514 gaps.push(
515 "a bip39 break-glass phrase is configured but the binary lacks `unlock-bip39`"
516 .to_string(),
517 );
518 }
519 if inputs.unlock_age_yubikey_selected && !features.unlock_age_yubikey {
520 gaps.push(
521 "age-yubikey unlock is enabled but the binary lacks `unlock-age-yubikey`".to_string(),
522 );
523 }
524 if let Some(backends) = backends {
525 let needs_keystore = backends.values().any(|b| b.kind == BackendKind::Keystore);
526 if needs_keystore && !features.keystore_backend {
527 gaps.push(
528 "the catalog declares a `keystore` backend but the binary lacks `keystore-backend`"
529 .to_string(),
530 );
531 }
532 let needs_aws_kms = backends.values().any(|b| b.kind == BackendKind::AwsKms);
533 if needs_aws_kms && !features.aws_kms {
534 gaps.push(
535 "the catalog declares an `aws-kms` backend but the binary lacks `aws-kms`"
536 .to_string(),
537 );
538 }
539 let needs_gcp_kms = backends.values().any(|b| b.kind == BackendKind::GcpKms);
540 if needs_gcp_kms && !features.gcp_kms {
541 gaps.push(
542 "the catalog declares a `gcp-kms` backend but the binary lacks `gcp-kms`"
543 .to_string(),
544 );
545 }
546 }
547
548 if gaps.is_empty() {
549 CheckResult::ok(
550 NAME,
551 "the binary's enabled features cover the configured backend + unlock methods",
552 )
553 } else {
554 CheckResult::fatal(
555 NAME,
556 format!("feature gap(s): {}", gaps.join("; ")),
557 "Rebuild `basil` with the missing cargo feature(s) enabled \
558 (e.g. `--features unlock-bip39,keystore-backend`), or remove the \
559 corresponding config option.",
560 )
561 }
562}
563
564fn backend_binary_check(
568 backends: Option<&std::collections::BTreeMap<String, crate::catalog::BackendRef>>,
569) -> CheckResult {
570 const NAME: &str = "backend_binary";
571 let Some(backends) = backends else {
572 return catalog_dependent_skip(NAME);
573 };
574
575 let has_vault = backends.values().any(|b| b.kind == BackendKind::Vault);
576 if !has_vault {
577 return CheckResult::ok(
578 NAME,
579 "no vault-kind backend in the catalog; no external CLI required",
580 );
581 }
582
583 let bao = on_path("bao");
584 let vault = on_path("vault");
585 match (bao, vault) {
586 (true, true) => CheckResult::ok(NAME, "both `bao` and `vault` are on PATH"),
587 (true, false) => CheckResult::ok(NAME, "`bao` (OpenBao) is on PATH"),
588 (false, true) => CheckResult::ok(NAME, "`vault` (HashiCorp) is on PATH"),
589 (false, false) => CheckResult::warn(
590 NAME,
591 "neither `bao` nor `vault` is on PATH",
592 "Install the OpenBao (`bao`) or Vault (`vault`) CLI on PATH for \
593 out-of-band provisioning; the daemon talks HTTP and does not strictly \
594 require the CLI, hence advisory.",
595 ),
596 }
597}
598
599fn on_path(bin: &str) -> bool {
601 std::env::var_os("PATH")
602 .is_some_and(|paths| std::env::split_paths(&paths).any(|dir| dir.join(bin).is_file()))
603}
604
605fn socket_check(socket: &str, mode: u32, group: Option<&str>) -> CheckResult {
610 const NAME: &str = "socket";
611 let path = Path::new(socket);
612 let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) else {
613 return CheckResult::fatal(
614 NAME,
615 format!("socket path `{socket}` has no parent directory"),
616 "Use an absolute socket path under an existing run dir, e.g. /run/basil/basil.sock.",
617 );
618 };
619
620 if !parent.exists() {
621 return CheckResult::fatal(
622 NAME,
623 format!("socket parent dir {} does not exist", parent.display()),
624 format!(
625 "Create the run directory (e.g. `install -d -m 0750 {}`) before starting.",
626 parent.display()
627 ),
628 );
629 }
630 if !dir_is_writable(parent) {
631 return CheckResult::fatal(
632 NAME,
633 format!("socket parent dir {} is not writable", parent.display()),
634 "Make the run directory writable by the user that runs basil.",
635 );
636 }
637
638 if mode & 0o002 != 0 {
640 return CheckResult::warn(
641 NAME,
642 format!("configured socket mode {mode:04o} is world-writable"),
643 "Tighten `socket-mode` to 0600 (owner-only) or 0660 with a `socket-group`; \
644 a world-writable socket lets any local user connect.",
645 );
646 }
647
648 if let Some(group) = group
649 && !group_resolves(group)
650 {
651 return CheckResult::fatal(
652 NAME,
653 format!("configured socket-group `{group}` does not resolve to a gid"),
654 "Create the group, or set `socket-group` to an existing group name or numeric gid.",
655 );
656 }
657
658 CheckResult::ok(
659 NAME,
660 format!(
661 "parent dir {} exists + writable; mode {mode:04o} not world-writable",
662 parent.display()
663 ),
664 )
665}
666
667fn dir_is_writable(dir: &Path) -> bool {
671 let probe = dir.join(format!(".basil-doctor-{}.tmp", std::process::id()));
672 match std::fs::File::create(&probe) {
673 Ok(_) => {
674 let _ = std::fs::remove_file(&probe);
675 true
676 }
677 Err(_) => false,
678 }
679}
680
681fn group_resolves(group: &str) -> bool {
685 if group.parse::<u32>().is_ok() {
686 return true;
687 }
688 let Ok(body) = std::fs::read_to_string("/etc/group") else {
689 return false;
690 };
691 body.lines()
692 .filter_map(|line| line.split(':').next())
693 .any(|name| name == group)
694}
695
696fn bundle_checks(bundle: &Path) -> Vec<CheckResult> {
700 const READ_NAME: &str = "bundle_readable";
701 const PERMS_NAME: &str = "bundle_perms";
702 const FRESH_NAME: &str = "bundle_freshness";
703
704 let bytes = match std::fs::read(bundle) {
707 Ok(b) => b,
708 Err(e) => {
709 let detail = format!("sealed bundle {} is not readable: {e}", bundle.display());
710 return vec![
713 CheckResult::fatal(
714 READ_NAME,
715 detail,
716 "Place the 0600 sealed bundle at the configured path and ensure the \
717 daemon user can read it.",
718 ),
719 CheckResult::warn(
720 PERMS_NAME,
721 "skipped: bundle not readable",
722 "Resolve bundle_readable first.",
723 ),
724 CheckResult::warn(
725 FRESH_NAME,
726 "skipped: bundle not readable",
727 "Resolve bundle_readable first.",
728 ),
729 ];
730 }
731 };
732
733 let read_row = CheckResult::ok(
734 READ_NAME,
735 format!("sealed bundle {} exists and is readable", bundle.display()),
736 );
737 let perms_row = bundle_perms_check(PERMS_NAME, bundle);
738 let fresh_row = bundle_freshness_check(FRESH_NAME, bundle, &bytes);
739 vec![read_row, perms_row, fresh_row]
740}
741
742fn bundle_perms_check(name: &'static str, bundle: &Path) -> CheckResult {
746 #[cfg(unix)]
747 {
748 use std::os::unix::fs::PermissionsExt;
749 match std::fs::metadata(bundle) {
750 Ok(meta) => {
751 let mode = meta.permissions().mode() & 0o777;
752 if mode == 0o600 {
753 CheckResult::ok(name, "sealed bundle is 0600 (owner-only)")
754 } else {
755 CheckResult::warn(
756 name,
757 format!("sealed bundle has mode {mode:04o}, expected 0600"),
758 format!(
759 "Run `chmod 600 {}`; broader perms leak the sealed creds. Startup \
760 refuses this only under `strict-bundle-perms`, hence advisory.",
761 bundle.display()
762 ),
763 )
764 }
765 }
766 Err(e) => CheckResult::fatal(
767 name,
768 format!("cannot stat sealed bundle: {e}"),
769 "Ensure the bundle path is correct and accessible.",
770 ),
771 }
772 }
773 #[cfg(not(unix))]
774 {
775 let _ = bundle;
776 CheckResult::warn(
777 name,
778 "permission check is unix-only; skipped on this platform",
779 "Ensure the bundle is owner-only by your platform's equivalent.",
780 )
781 }
782}
783
784fn bundle_freshness_check(name: &'static str, bundle: &Path, bytes: &[u8]) -> CheckResult {
789 let parsed = match crate::seal::format::decode(bytes) {
790 Ok(p) => p,
791 Err(e) => {
792 return CheckResult::fatal(
793 name,
794 format!("sealed bundle does not parse (corrupt/wrong format): {e}"),
795 "Re-export the sealed bundle; the on-disk file is not a valid basil bundle.",
796 );
797 }
798 };
799 let current = parsed.body.header.epoch;
800 let sidecar = epoch_sidecar_path(bundle);
801
802 match std::fs::read_to_string(&sidecar) {
803 Ok(raw) => match raw.trim().parse::<u64>() {
804 Ok(seen) if current < seen => CheckResult::fatal(
805 name,
806 format!(
807 "STALE bundle: epoch {current} is behind the last-seen sidecar epoch {seen}"
808 ),
809 "An older bundle was swapped in. Restore the current bundle, or if the \
810 rollback is intentional, remove the stale .epoch sidecar.",
811 ),
812 Ok(seen) => CheckResult::ok(
813 name,
814 format!("bundle epoch {current} is current (sidecar last-seen {seen})"),
815 ),
816 Err(e) => CheckResult::fatal(
817 name,
818 format!(
819 "epoch sidecar {} is not a valid integer: {e}",
820 sidecar.display()
821 ),
822 "Remove the corrupt .epoch sidecar; the daemon re-initializes it at startup.",
823 ),
824 },
825 Err(e) if e.kind() == std::io::ErrorKind::NotFound => CheckResult::warn(
826 name,
827 format!("epoch sidecar {} is absent (first boot)", sidecar.display()),
828 "Expected on first boot; the daemon initializes it at startup. After the first \
829 successful start it should be present.",
830 ),
831 Err(e) => CheckResult::fatal(
832 name,
833 format!("epoch sidecar {} is not readable: {e}", sidecar.display()),
834 "Ensure the daemon user can read the .epoch sidecar next to the bundle.",
835 ),
836 }
837}
838
839fn epoch_sidecar_path(bundle: &Path) -> std::path::PathBuf {
841 let mut p = std::ffi::OsString::from(bundle.as_os_str());
842 p.push(".epoch");
843 std::path::PathBuf::from(p)
844}
845
846async fn backend_reachability_check(
851 backends: Option<&std::collections::BTreeMap<String, crate::catalog::BackendRef>>,
852) -> CheckResult {
853 const NAME: &str = "backend_reachability";
854 let Some(backends) = backends else {
855 return catalog_dependent_skip(NAME);
856 };
857
858 let mut addrs: Vec<String> = backends
860 .values()
861 .filter(|b| b.kind == BackendKind::Vault)
862 .map(|b| b.addr.clone())
863 .collect();
864 addrs.sort();
865 addrs.dedup();
866
867 if addrs.is_empty() {
868 return CheckResult::ok(
869 NAME,
870 "no vault-kind backend to probe (keystore-only or no backends)",
871 );
872 }
873
874 crate::ensure_crypto_provider();
875 let client = match reqwest::Client::builder()
876 .timeout(REACHABILITY_TIMEOUT)
877 .build()
878 {
879 Ok(c) => c,
880 Err(e) => {
881 return CheckResult::fatal(
882 NAME,
883 format!("could not build HTTP client for the reachability probe: {e}"),
884 "This is unexpected; check the host's TLS/HTTP stack.",
885 );
886 }
887 };
888
889 let mut unreachable: Vec<String> = Vec::new();
890 let mut reached = 0usize;
891 for addr in &addrs {
892 if probe_vault_health(&client, addr).await {
893 reached += 1;
894 } else {
895 unreachable.push(addr.clone());
896 }
897 }
898
899 if unreachable.is_empty() {
900 CheckResult::ok(
901 NAME,
902 format!("all {reached} vault backend address(es) responded to /v1/sys/health"),
903 )
904 } else {
905 CheckResult::fatal(
906 NAME,
907 format!(
908 "{} of {} vault backend address(es) unreachable: {}",
909 unreachable.len(),
910 addrs.len(),
911 unreachable.join(", ")
912 ),
913 "Start/reach the backend (OpenBao/Vault) at the configured address, or fix \
914 `addr` in the catalog. The probe is unauthenticated (/v1/sys/health) with a \
915 bounded timeout.",
916 )
917 }
918}
919
920async fn probe_vault_health(client: &reqwest::Client, addr: &str) -> bool {
924 let url = format!("{}/v1/sys/health", addr.trim_end_matches('/'));
925 client.get(&url).send().await.is_ok()
926}
927
928#[must_use]
930pub fn render_human(report: &DoctorReport) -> String {
931 use std::fmt::Write as _;
932 let mut out = String::new();
933 let _ = writeln!(out, "basil doctor - {} check(s)\n", report.checks.len());
934 for c in &report.checks {
935 let marker = match c.status {
936 CheckStatus::Ok => "OK ",
937 CheckStatus::Warn => "WARN ",
938 CheckStatus::Fatal => "FATAL",
939 };
940 let _ = writeln!(out, "[{marker}] {}: {}", c.name, c.detail);
941 if c.status != CheckStatus::Ok && !c.remediation.is_empty() {
942 let _ = writeln!(out, " → {}", c.remediation);
943 }
944 }
945 let s = &report.summary;
946 let _ = writeln!(
947 out,
948 "\nsummary: {} ok, {} warn, {} fatal ({} total)",
949 s.ok, s.warn, s.fatal, s.total
950 );
951 if s.blocking {
952 let _ = writeln!(out, "result: BLOCKING, at least one check is fatal");
953 } else if s.warn > 0 {
954 let _ = writeln!(
955 out,
956 "result: OK with advisories (nonzero exit only under --strict)"
957 );
958 } else {
959 let _ = writeln!(out, "result: OK");
960 }
961 out
962}
963
964pub fn render_json(report: &DoctorReport) -> Result<String, serde_json::Error> {
970 serde_json::to_string_pretty(report)
971}
972
973#[cfg(test)]
974mod tests {
975 #![allow(clippy::unwrap_used, clippy::indexing_slicing)]
978 use super::*;
979 use crate::catalog::MissingPolicy;
980 use crate::{CheckReport, KeyCheck, KeyStatus};
981 use std::io::Write as _;
982
983 fn unique_dir() -> std::path::PathBuf {
984 let dir = std::env::temp_dir().join(format!(
985 "basil-doctor-test-{}-{}",
986 std::process::id(),
987 uuid::Uuid::new_v4()
988 ));
989 std::fs::create_dir_all(&dir).unwrap();
990 dir
991 }
992
993 fn write_mode(path: &Path, contents: &[u8], mode: u32) {
994 let mut f = std::fs::File::create(path).unwrap();
995 f.write_all(contents).unwrap();
996 #[cfg(unix)]
997 {
998 use std::os::unix::fs::PermissionsExt;
999 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
1000 }
1001 #[cfg(not(unix))]
1002 let _ = mode;
1003 }
1004
1005 fn all_features_off() -> EnabledFeatures {
1006 EnabledFeatures {
1007 keystore_backend: false,
1008 aws_kms: false,
1009 gcp_kms: false,
1010 unlock_bip39: false,
1011 unlock_age_yubikey: false,
1012 }
1013 }
1014
1015 fn base_inputs(dir: &Path) -> DoctorInputs {
1016 DoctorInputs {
1017 catalog: dir.join("catalog.json"),
1018 policy: dir.join("policy.json"),
1019 bundle: dir.join("bundle.sealed"),
1020 socket: dir.join("basil.sock").to_string_lossy().into_owned(),
1021 socket_mode: 0o600,
1022 socket_group: None,
1023 capability_policy: crate::CapabilityPolicy::Off,
1024 invocation: crate::service::broker::InvocationRuntimeConfig::default(),
1025 unlock_passphrase_selected: false,
1026 unlock_bip39_selected: false,
1027 unlock_age_yubikey_selected: false,
1028 }
1029 }
1030
1031 fn key_check(name: &str, status: KeyStatus) -> KeyCheck {
1032 KeyCheck {
1033 name: name.to_string(),
1034 status,
1035 }
1036 }
1037
1038 #[test]
1041 fn feature_keystore_backend_in_catalog_without_feature_is_fatal() {
1042 let dir = unique_dir();
1043 let inputs = base_inputs(&dir);
1044 let mut backends = std::collections::BTreeMap::new();
1045 backends.insert(
1046 "ks".to_string(),
1047 serde_json::from_value::<crate::catalog::BackendRef>(serde_json::json!({
1048 "kind": "keystore",
1049 "addr": "/var/lib/basil/keystore.db"
1050 }))
1051 .unwrap(),
1052 );
1053 let res = feature_compatibility_check(&inputs, all_features_off(), Some(&backends));
1054 assert_eq!(res.status, CheckStatus::Fatal);
1055 assert!(res.detail.contains("keystore-backend"));
1056 }
1057
1058 #[test]
1059 fn feature_all_satisfied_is_ok() {
1060 let dir = unique_dir();
1061 let inputs = base_inputs(&dir);
1062 let features = EnabledFeatures {
1063 keystore_backend: true,
1064 aws_kms: true,
1065 gcp_kms: true,
1066 unlock_bip39: true,
1067 unlock_age_yubikey: true,
1068 };
1069 let res = feature_compatibility_check(&inputs, features, None);
1070 assert_eq!(res.status, CheckStatus::Ok);
1071 }
1072
1073 #[test]
1076 fn socket_missing_parent_dir_is_fatal() {
1077 let res = socket_check("/nonexistent-basil-dir-xyz/basil.sock", 0o600, None);
1078 assert_eq!(res.status, CheckStatus::Fatal);
1079 }
1080
1081 #[test]
1082 fn socket_writable_parent_owner_only_is_ok() {
1083 let dir = unique_dir();
1084 let sock = dir.join("basil.sock");
1085 let res = socket_check(&sock.to_string_lossy(), 0o600, None);
1086 assert_eq!(res.status, CheckStatus::Ok);
1087 }
1088
1089 #[test]
1090 fn socket_world_writable_mode_warns() {
1091 let dir = unique_dir();
1092 let sock = dir.join("basil.sock");
1093 let res = socket_check(&sock.to_string_lossy(), 0o666, None);
1094 assert_eq!(res.status, CheckStatus::Warn);
1095 }
1096
1097 #[test]
1098 fn socket_unresolvable_group_is_fatal() {
1099 let dir = unique_dir();
1100 let sock = dir.join("basil.sock");
1101 let res = socket_check(
1102 &sock.to_string_lossy(),
1103 0o660,
1104 Some("no-such-group-basil-zzz"),
1105 );
1106 assert_eq!(res.status, CheckStatus::Fatal);
1107 }
1108
1109 #[test]
1110 fn socket_numeric_group_resolves() {
1111 let dir = unique_dir();
1112 let sock = dir.join("basil.sock");
1113 let res = socket_check(&sock.to_string_lossy(), 0o660, Some("0"));
1114 assert_eq!(res.status, CheckStatus::Ok);
1115 }
1116
1117 #[test]
1120 fn bundle_missing_is_fatal_and_skips_dependents() {
1121 let dir = unique_dir();
1122 let bundle = dir.join("absent.sealed");
1123 let rows = bundle_checks(&bundle);
1124 assert_eq!(rows.len(), 3);
1125 assert_eq!(rows[0].status, CheckStatus::Fatal); assert_eq!(rows[1].status, CheckStatus::Warn); assert_eq!(rows[2].status, CheckStatus::Warn); }
1129
1130 #[test]
1131 fn bundle_bad_perms_warns_perms_row() {
1132 let dir = unique_dir();
1133 let bundle = dir.join("bundle.sealed");
1134 write_mode(&bundle, b"not-a-real-bundle", 0o644);
1135 let row = bundle_perms_check("bundle_perms", &bundle);
1136 #[cfg(unix)]
1139 assert_eq!(row.status, CheckStatus::Warn);
1140 #[cfg(not(unix))]
1141 assert_eq!(row.status, CheckStatus::Warn);
1142 }
1143
1144 #[test]
1145 fn bundle_good_perms_pass() {
1146 let dir = unique_dir();
1147 let bundle = dir.join("bundle.sealed");
1148 write_mode(&bundle, b"blob", 0o600);
1149 let row = bundle_perms_check("bundle_perms", &bundle);
1150 #[cfg(unix)]
1151 assert_eq!(row.status, CheckStatus::Ok);
1152 }
1153
1154 #[test]
1155 fn bundle_freshness_unparsable_blob_is_fatal() {
1156 let dir = unique_dir();
1157 let bundle = dir.join("bundle.sealed");
1158 let row = bundle_freshness_check("bundle_freshness", &bundle, b"garbage-not-a-bundle");
1159 assert_eq!(row.status, CheckStatus::Fatal);
1160 assert!(row.detail.contains("does not parse"));
1161 }
1162
1163 #[test]
1166 fn catalog_unloadable_is_fatal() {
1167 let dir = unique_dir();
1168 std::fs::write(dir.join("catalog.json"), "not json").unwrap();
1169 std::fs::write(dir.join("policy.json"), "not json").unwrap();
1170 let loaded = load_catalog_policy(&dir.join("catalog.json"), &dir.join("policy.json"));
1171 let row = catalog_policy_check(&loaded);
1172 assert_eq!(row.status, CheckStatus::Fatal);
1173 }
1174
1175 #[test]
1178 fn summary_counts_and_blocking_iff_any_fatal() {
1179 let checks = vec![
1180 CheckResult::ok("a", "fine"),
1181 CheckResult::warn("b", "meh", "do x"),
1182 ];
1183 let report = DoctorReport::from_checks(checks);
1184 assert!(!report.summary.blocking);
1185 assert_eq!(report.summary.warn, 1);
1186
1187 let checks = vec![
1188 CheckResult::ok("a", "fine"),
1189 CheckResult::fatal("c", "broken", "fix it"),
1190 ];
1191 let report = DoctorReport::from_checks(checks);
1192 assert!(report.summary.blocking);
1193 assert_eq!(report.summary.fatal, 1);
1194 }
1195
1196 #[test]
1197 fn exit_code_model_fatal_vs_warn_vs_strict() {
1198 let ok = DoctorReport::from_checks(vec![CheckResult::ok("a", "fine")]);
1200 assert!(!ok.should_exit_nonzero(false));
1201 assert!(!ok.should_exit_nonzero(true));
1202
1203 let warn = DoctorReport::from_checks(vec![CheckResult::warn("b", "meh", "do x")]);
1205 assert!(!warn.should_exit_nonzero(false));
1206 assert!(warn.should_exit_nonzero(true));
1207
1208 let fatal = DoctorReport::from_checks(vec![
1210 CheckResult::warn("b", "meh", "do x"),
1211 CheckResult::fatal("c", "broken", "fix it"),
1212 ]);
1213 assert!(fatal.should_exit_nonzero(false));
1214 assert!(fatal.should_exit_nonzero(true));
1215 }
1216
1217 #[test]
1218 fn json_shape_is_stable() {
1219 let report = DoctorReport::from_checks(vec![CheckResult::fatal("x", "d", "r")]);
1220 let json = render_json(&report).unwrap();
1221 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
1222 assert_eq!(v["schema_version"], DOCTOR_SCHEMA_VERSION);
1223 assert_eq!(v["checks"][0]["name"], "x");
1224 assert_eq!(v["checks"][0]["status"], "fatal");
1225 assert_eq!(v["checks"][0]["detail"], "d");
1226 assert_eq!(v["checks"][0]["remediation"], "r");
1227 assert_eq!(v["summary"]["fatal"], 1);
1228 assert_eq!(v["summary"]["blocking"], true);
1229 }
1230
1231 #[test]
1234 fn key_material_probe_failure_omits_secret_bearing_reason() {
1235 let rows = key_material_rows(Err(
1236 "reading passphrase from /run/credentials/basil/passphrase failed; \
1237 sealed bundle /var/lib/basil/bootstrap.bundle; \
1238 Authorization: Bearer vault-token-s.123; upstream body has credential"
1239 .to_string(),
1240 ));
1241 assert_eq!(rows.len(), 1);
1242 assert_eq!(rows[0].status, CheckStatus::Fatal);
1243 let visible = format!("{} {}", rows[0].detail, rows[0].remediation);
1244 for canary in [
1245 "/run/credentials/basil/passphrase",
1246 "/var/lib/basil/bootstrap.bundle",
1247 "vault-token-s.123",
1248 "upstream body has credential",
1249 ] {
1250 assert!(
1251 !visible.contains(canary),
1252 "doctor row leaked secret canary `{canary}` in `{visible}`"
1253 );
1254 }
1255 }
1256
1257 #[test]
1258 fn key_material_all_present_is_single_ok_row() {
1259 let report = CheckReport {
1260 keys: vec![
1261 key_check("web-tls", KeyStatus::Present),
1262 key_check("app-sign", KeyStatus::Present),
1263 ],
1264 };
1265 let rows = key_material_rows(Ok(&report));
1266 assert_eq!(rows.len(), 1);
1267 assert_eq!(rows[0].name, "key_material");
1268 assert_eq!(rows[0].status, CheckStatus::Ok);
1269 }
1270
1271 #[test]
1272 fn key_material_required_missing_is_fatal_with_per_key_detail() {
1273 let report = CheckReport {
1274 keys: vec![
1275 key_check("present-key", KeyStatus::Present),
1276 key_check("req-key", KeyStatus::Missing(MissingPolicy::Error)),
1277 key_check("gen-key", KeyStatus::Missing(MissingPolicy::Generate)),
1278 ],
1279 };
1280 let rows = key_material_rows(Ok(&report));
1281 assert_eq!(rows.len(), 3);
1283 assert_eq!(rows[0].name, "key_material");
1285 assert_eq!(rows[0].status, CheckStatus::Fatal);
1286 let req = rows
1288 .iter()
1289 .find(|r| r.name == "key_material:req-key")
1290 .expect("req-key row present");
1291 assert_eq!(req.status, CheckStatus::Fatal);
1292 let generate_row = rows
1293 .iter()
1294 .find(|r| r.name == "key_material:gen-key")
1295 .expect("gen-key row present");
1296 assert_eq!(generate_row.status, CheckStatus::Warn);
1297 }
1298
1299 #[test]
1300 fn key_material_only_generate_missing_is_warn_not_fatal() {
1301 let report = CheckReport {
1302 keys: vec![
1303 key_check("present-key", KeyStatus::Present),
1304 key_check("gen-key", KeyStatus::Missing(MissingPolicy::Generate)),
1305 ],
1306 };
1307 let rows = key_material_rows(Ok(&report));
1308 assert_eq!(rows[0].status, CheckStatus::Warn); let report = DoctorReport::from_checks(rows);
1310 assert!(!report.summary.blocking);
1311 assert!(!report.should_exit_nonzero(false));
1312 assert!(report.should_exit_nonzero(true));
1313 }
1314
1315 #[tokio::test]
1316 async fn unreachable_backend_is_fatal_within_timeout() {
1317 let mut backends = std::collections::BTreeMap::new();
1318 backends.insert(
1320 "v".to_string(),
1321 serde_json::from_value::<crate::catalog::BackendRef>(serde_json::json!({
1322 "kind": "vault",
1323 "addr": "http://127.0.0.1:1"
1324 }))
1325 .unwrap(),
1326 );
1327 let started = std::time::Instant::now();
1328 let res = backend_reachability_check(Some(&backends)).await;
1329 assert!(started.elapsed() < REACHABILITY_TIMEOUT * 3);
1330 assert_eq!(res.status, CheckStatus::Fatal);
1331 }
1332
1333 #[tokio::test]
1334 async fn keystore_only_catalog_skips_reachability() {
1335 let mut backends = std::collections::BTreeMap::new();
1336 backends.insert(
1337 "ks".to_string(),
1338 serde_json::from_value::<crate::catalog::BackendRef>(serde_json::json!({
1339 "kind": "keystore",
1340 "addr": "/var/lib/basil/keystore.db"
1341 }))
1342 .unwrap(),
1343 );
1344 let res = backend_reachability_check(Some(&backends)).await;
1345 assert_eq!(res.status, CheckStatus::Ok);
1346 }
1347}