1use std::path::Path;
33use std::time::Duration;
34
35use serde::Serialize;
36
37use crate::catalog::BackendKind;
38
39pub const DOCTOR_SCHEMA_VERSION: u32 = 1;
42
43const REACHABILITY_TIMEOUT: Duration = Duration::from_secs(3);
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
53#[serde(rename_all = "lowercase")]
54pub enum CheckStatus {
55 Ok,
57 Warn,
59 Fail,
61}
62
63#[derive(Debug, Clone, Serialize)]
71pub struct CheckResult {
72 pub name: &'static str,
74 pub status: CheckStatus,
76 pub detail: String,
78 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#[derive(Debug, Clone, Copy, Serialize)]
121pub struct DoctorSummary {
122 pub total: usize,
124 pub ok: usize,
126 pub warn: usize,
128 pub fail: usize,
130 pub blocking: bool,
132}
133
134#[derive(Debug, Clone, Serialize)]
136pub struct DoctorReport {
137 pub schema_version: u32,
139 pub checks: Vec<CheckResult>,
141 pub summary: DoctorSummary,
143}
144
145impl DoctorReport {
146 #[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 #[must_use]
175 pub const fn has_blocking_failure(&self) -> bool {
176 self.summary.blocking
177 }
178}
179
180#[derive(Debug, Clone)]
185pub struct DoctorInputs {
186 pub catalog: std::path::PathBuf,
188 pub policy: std::path::PathBuf,
190 pub bundle: std::path::PathBuf,
192 pub socket: String,
194 pub socket_mode: u32,
196 pub socket_group: Option<String>,
198 pub unlock_passphrase_selected: bool,
200 pub unlock_bip39_selected: bool,
202 pub unlock_age_yubikey_selected: bool,
204}
205
206#[allow(clippy::struct_excessive_bools)]
214#[derive(Debug, Clone, Copy)]
215pub struct EnabledFeatures {
216 pub keystore_backend: bool,
218 pub aws_kms: bool,
220 pub gcp_kms: bool,
222 pub unlock_bip39: bool,
224 pub unlock_age_yubikey: bool,
226}
227
228impl EnabledFeatures {
229 #[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
242pub async fn run_doctor(inputs: &DoctorInputs, features: EnabledFeatures) -> DoctorReport {
250 let mut checks = Vec::new();
251
252 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#[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
328struct LoadedCatalog {
330 backends: std::collections::BTreeMap<String, crate::catalog::BackendRef>,
331}
332
333fn 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
366fn 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
430fn 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
469fn 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
475fn 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 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
535fn 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
549fn 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
564fn 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 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 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
610fn 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
649fn 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
704fn 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
711async 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 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
789async 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#[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
830pub 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 #![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 #[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 #[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 #[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); assert_eq!(rows[1].status, CheckStatus::Warn); assert_eq!(rows[2].status, CheckStatus::Warn); }
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 #[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 #[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 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}