1use std::path::Path;
50use std::time::Duration;
51
52use rand::RngCore;
53use serde::Serialize;
54
55use crate::catalog::BackendKind;
56
57pub const DOCTOR_SCHEMA_VERSION: u32 = 2;
60
61const REACHABILITY_TIMEOUT: Duration = Duration::from_secs(3);
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
72#[serde(rename_all = "lowercase")]
73pub enum CheckStatus {
74 Ok,
76 Warn,
78 Fatal,
80}
81
82#[derive(Debug, Clone, Serialize)]
91pub struct CheckResult {
92 pub name: String,
94 pub status: CheckStatus,
96 pub detail: String,
98 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#[derive(Debug, Clone, Copy, Serialize)]
141pub struct DoctorSummary {
142 pub total: usize,
144 pub ok: usize,
146 pub warn: usize,
148 pub fatal: usize,
150 pub blocking: bool,
153}
154
155#[derive(Debug, Clone, Serialize)]
157pub struct DoctorReport {
158 pub schema_version: u32,
160 pub checks: Vec<CheckResult>,
162 pub summary: DoctorSummary,
164}
165
166impl DoctorReport {
167 #[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 #[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#[derive(Debug, Clone)]
208pub struct DoctorInputs {
209 pub catalog: std::path::PathBuf,
211 pub policy: std::path::PathBuf,
213 pub bundle: std::path::PathBuf,
215 pub socket: String,
217 pub socket_mode: u32,
219 pub socket_group: Option<String>,
221 pub capability_policy: crate::CapabilityPolicy,
223 pub invocation: crate::service::broker::InvocationRuntimeConfig,
226 pub unlock_passphrase_selected: bool,
228 pub unlock_bip39_selected: bool,
230 pub unlock_age_yubikey_selected: bool,
232}
233
234#[allow(clippy::struct_excessive_bools)]
242#[derive(Debug, Clone, Copy)]
243pub struct EnabledFeatures {
244 pub keystore_backend: bool,
246 pub aws_kms: bool,
248 pub gcp_kms: bool,
250 pub unlock_bip39: bool,
252 pub unlock_age_yubikey: bool,
254}
255
256impl EnabledFeatures {
257 #[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
270pub async fn run_doctor(inputs: &DoctorInputs, features: EnabledFeatures) -> DoctorReport {
281 let mut checks = Vec::new();
282
283 let loaded = load_catalog_policy(&inputs.catalog, &inputs.policy);
285 checks.push(catalog_policy_check(&loaded));
286
287 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
315fn 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#[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 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 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
418fn 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
449fn 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
472fn 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
501fn 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
565fn 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
600fn 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
606fn 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 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
668fn 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
706fn 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
721fn 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 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 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
767fn 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
809fn 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
864fn 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
871async 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 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
945async 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#[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
989pub 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 #![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 #[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 #[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 #[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); assert_eq!(rows[1].status, CheckStatus::Warn); assert_eq!(rows[2].status, CheckStatus::Warn); }
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 #[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 #[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 #[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 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 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 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 #[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 assert_eq!(rows.len(), 3);
1331 assert_eq!(rows[0].name, "key_material");
1333 assert_eq!(rows[0].status, CheckStatus::Fatal);
1334 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); 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 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}