use anyhow::Result;
use chrono::Utc;
use colored::Colorize;
use serde::Serialize;
#[cfg(feature = "biometric")]
use tsafe_core::keyring_store;
use tsafe_core::{audit::AuditLog, crypto, env as tsenv, profile, snapshot, vault::VaultFile};
use crate::cmd_audit_cmd::compute_chain_coverage;
struct DoctorEnvVar {
name: &'static str,
description: &'static str,
warn_if_set: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
enum DoctorCheckStatus {
Ok,
Info,
Note,
Warning,
Critical,
}
impl DoctorCheckStatus {
fn exit_code(self) -> i32 {
match self {
Self::Ok | Self::Info | Self::Note => 0,
Self::Warning => 1,
Self::Critical => 2,
}
}
fn icon(self) -> colored::ColoredString {
match self {
Self::Ok => "✓".green(),
Self::Info => "i".blue(),
Self::Note => "!".yellow(),
Self::Warning => "!".yellow().bold(),
Self::Critical => "✗".red().bold(),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
enum DoctorOverallStatus {
Healthy,
Warning,
Critical,
}
#[derive(Debug, Serialize)]
struct DoctorCheck {
code: String,
status: DoctorCheckStatus,
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
detail: Option<String>,
}
#[derive(Debug, Serialize)]
struct DoctorSummary {
check_count: usize,
ok_count: usize,
info_count: usize,
note_count: usize,
warning_count: usize,
critical_count: usize,
secret_count: usize,
}
#[derive(Debug, Serialize)]
struct DoctorReport {
profile: String,
status: DoctorOverallStatus,
exit_code: i32,
summary: DoctorSummary,
#[serde(skip_serializing_if = "Option::is_none")]
chain_coverage_pct: Option<u8>,
checks: Vec<DoctorCheck>,
}
pub(crate) fn cmd_doctor(profile: &str, json: bool) -> Result<()> {
profile::validate_profile_name(profile)?;
let report = build_report(profile);
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
render_human(&report);
}
if report.exit_code != 0 {
std::process::exit(report.exit_code);
}
Ok(())
}
fn build_report(profile: &str) -> DoctorReport {
let vault_path = profile::vault_path(profile);
let mut checks = Vec::new();
let mut secret_count = 0usize;
if vault_path.exists() {
checks.push(DoctorCheck {
code: "vault.file".to_string(),
status: DoctorCheckStatus::Ok,
message: format!("vault: {}", vault_path.display()),
detail: None,
});
} else {
checks.push(DoctorCheck {
code: "vault.file".to_string(),
status: DoctorCheckStatus::Critical,
message: format!("vault not found for profile '{profile}'"),
detail: Some(format!("run: tsafe --profile {profile} init")),
});
}
checks.push(quick_unlock_check(profile));
match snapshot::list(profile) {
Ok(snaps) if snaps.is_empty() => checks.push(DoctorCheck {
code: "snapshots".to_string(),
status: DoctorCheckStatus::Info,
message: "snapshots: none (created on next write)".to_string(),
detail: None,
}),
Ok(snaps) => checks.push(DoctorCheck {
code: "snapshots".to_string(),
status: DoctorCheckStatus::Ok,
message: format!("snapshots: {}", snaps.len()),
detail: None,
}),
Err(_) => checks.push(DoctorCheck {
code: "snapshots".to_string(),
status: DoctorCheckStatus::Critical,
message: "snapshots: could not list".to_string(),
detail: None,
}),
}
for var in doctor_env_vars() {
let (status, message) = match std::env::var(var.name) {
Ok(v) if v.is_empty() => (
DoctorCheckStatus::Note,
format!("{}: set but empty ({})", var.name, var.description),
),
Ok(_) if var.warn_if_set => (
DoctorCheckStatus::Warning,
format!(
"{}: REMOVE after CI — never set in production ({})",
var.name, var.description
),
),
Ok(_) => (
DoctorCheckStatus::Ok,
format!("{}: set ({})", var.name, var.description),
),
Err(_) => (
DoctorCheckStatus::Info,
format!("{}: not set ({})", var.name, var.description),
),
};
checks.push(DoctorCheck {
code: format!("env.{}", var.name),
status,
message,
detail: None,
});
}
if vault_path.exists() {
match std::fs::read_to_string(&vault_path) {
Ok(json) => match serde_json::from_str::<VaultFile>(&json) {
Ok(vf) => {
secret_count = vf.secrets.len();
checks.extend(vault_checks(profile, &vf));
}
Err(err) => checks.push(DoctorCheck {
code: "vault.parse".to_string(),
status: DoctorCheckStatus::Critical,
message: "vault metadata: could not parse".to_string(),
detail: Some(err.to_string()),
}),
},
Err(err) => checks.push(DoctorCheck {
code: "vault.read".to_string(),
status: DoctorCheckStatus::Critical,
message: "vault metadata: could not read".to_string(),
detail: Some(err.to_string()),
}),
}
}
let audit_log_path = profile::audit_log_path(profile);
let cov = compute_chain_coverage(&audit_log_path);
let chain_check = match cov.coverage_pct {
None => DoctorCheck {
code: "audit.chain".to_string(),
status: DoctorCheckStatus::Info,
message: "audit chain: no audit log yet".to_string(),
detail: None,
},
Some(0) | Some(_) if cov.chained == 0 => DoctorCheck {
code: "audit.chain".to_string(),
status: DoctorCheckStatus::Note,
message: format!(
"audit chain: 0% coverage ({} entries, all pre-C8 — no HMAC chain introduced yet)",
cov.total
),
detail: Some(
"Entries written before the HMAC chain upgrade (C8) cannot be retroactively chained. \
Coverage will grow as new entries are written."
.to_string(),
),
},
Some(pct) if pct < 80 => DoctorCheck {
code: "audit.chain".to_string(),
status: DoctorCheckStatus::Warning,
message: format!(
"audit chain: {pct}% coverage — below 80% threshold ({} of {} entries chained)",
cov.chained, cov.total
),
detail: Some(
"Run `tsafe audit verify` for details. Low coverage may indicate entries \
written before the HMAC chain was introduced (pre-C8) or log rotation."
.to_string(),
),
},
Some(pct) => DoctorCheck {
code: "audit.chain".to_string(),
status: DoctorCheckStatus::Ok,
message: format!(
"audit chain: {pct}% coverage ({} of {} entries chained)",
cov.chained, cov.total
),
detail: None,
},
};
checks.push(chain_check);
finalize_report(profile, checks, secret_count, cov.coverage_pct)
}
fn vault_checks(profile: &str, vf: &VaultFile) -> Vec<DoctorCheck> {
let mut checks = Vec::new();
checks.push(DoctorCheck {
code: "vault.schema".to_string(),
status: if matches!(vf.schema.as_str(), "tsafe/vault/v1" | "tsafe/vault/v2") {
DoctorCheckStatus::Ok
} else {
DoctorCheckStatus::Critical
},
message: format!("schema: {}", vf.schema),
detail: None,
});
checks.push(DoctorCheck {
code: "vault.cipher".to_string(),
status: if crypto::parse_cipher_kind(&vf.cipher).is_ok() {
DoctorCheckStatus::Ok
} else {
DoctorCheckStatus::Critical
},
message: format!("cipher: {}", vf.cipher),
detail: None,
});
let today = Utc::now().date_naive();
let mut keys: Vec<&String> = vf.secrets.keys().collect();
keys.sort();
let mut expired_count = 0usize;
let mut expiry_warning_count = 0usize;
for key in keys {
let entry = &vf.secrets[key];
if let Some(exp) = entry.tags.get("expires") {
if let Ok(date) = chrono::NaiveDate::parse_from_str(exp, "%Y-%m-%d") {
let days = (date - today).num_days();
if days < 0 {
expired_count += 1;
} else if days <= 30 {
expiry_warning_count += 1;
}
}
}
}
if expired_count > 0 {
checks.push(DoctorCheck {
code: "secret.expiry_expired".to_string(),
status: DoctorCheckStatus::Critical,
message: format!("{expired_count} key(s) have expired"),
detail: None,
});
}
if expiry_warning_count > 0 {
checks.push(DoctorCheck {
code: "secret.expiry_warning".to_string(),
status: DoctorCheckStatus::Note,
message: format!("{expiry_warning_count} key(s) approaching expiry (within 30 days)"),
detail: None,
});
}
let due = tsafe_core::vault::rotation_due(vf);
if !due.is_empty() {
checks.push(DoctorCheck {
code: "secret.rotation_overdue".to_string(),
status: DoctorCheckStatus::Warning,
message: format!("{} key(s) overdue for rotation", due.len()),
detail: None,
});
}
const SUGGEST_MASTER_ROTATE_AFTER_DAYS: i64 = 180;
let audit_log = AuditLog::new(&profile::audit_log_path(profile));
match audit_log.last_successful_operation(profile, "rotate", 8000) {
Ok(Some(ts)) => {
let days = (Utc::now() - ts).num_days();
if days > SUGGEST_MASTER_ROTATE_AFTER_DAYS {
checks.push(DoctorCheck {
code: "vault.master_password".to_string(),
status: DoctorCheckStatus::Warning,
message: format!(
"Master password: last re-keyed {days} day(s) ago (audit) — consider tsafe rotate"
),
detail: None,
});
} else {
checks.push(DoctorCheck {
code: "vault.master_password".to_string(),
status: DoctorCheckStatus::Ok,
message: format!("Master password: re-keyed {days} day(s) ago (audit)"),
detail: None,
});
}
}
Ok(None) => {
let age_days = (Utc::now() - vf.created_at).num_days();
if age_days > SUGGEST_MASTER_ROTATE_AFTER_DAYS {
checks.push(DoctorCheck {
code: "vault.master_password".to_string(),
status: DoctorCheckStatus::Warning,
message: format!(
"Master password: no successful `rotate` in audit; vault is {age_days} day(s) old — consider periodic tsafe rotate"
),
detail: None,
});
} else {
checks.push(DoctorCheck {
code: "vault.master_password".to_string(),
status: DoctorCheckStatus::Info,
message: format!(
"Master password: no `rotate` in audit yet (vault {age_days} day(s) old)"
),
detail: None,
});
}
}
Err(_) => checks.push(DoctorCheck {
code: "vault.master_password".to_string(),
status: DoctorCheckStatus::Info,
message: "audit log: could not read master-password rotation hint".to_string(),
detail: None,
}),
}
checks.push(DoctorCheck {
code: "vault.secret_count".to_string(),
status: DoctorCheckStatus::Info,
message: format!("{} secret(s) in vault", vf.secrets.len()),
detail: None,
});
checks.push(DoctorCheck {
code: "exec.preview".to_string(),
status: DoctorCheckStatus::Info,
message: "exec: preview injectable names with tsafe exec --dry-run".to_string(),
detail: None,
});
if cwd_has_layered_env_files() {
checks.push(DoctorCheck {
code: "exec.layered_env".to_string(),
status: DoctorCheckStatus::Info,
message:
"exec: `.env` or `.envrc` found near cwd — parent env may layer with vault; tsafe explain exec"
.to_string(),
detail: None,
});
}
if std::env::var("GITHUB_ACTIONS").ok().as_deref() == Some("true") {
checks.push(DoctorCheck {
code: "exec.ci".to_string(),
status: DoctorCheckStatus::Info,
message: ci_exec_hint(),
detail: None,
});
}
let risky_exec_count = vf
.secrets
.keys()
.filter(|k| tsenv::is_dangerous_injected_env_name(k))
.count();
if risky_exec_count > 0 {
checks.push(DoctorCheck {
code: "exec.risky_env_name".to_string(),
status: DoctorCheckStatus::Note,
message: format!(
"exec: {risky_exec_count} vault key(s) use high-risk env names when injected — tsafe explain exec-security"
),
detail: None,
});
}
checks
}
fn quick_unlock_check(profile: &str) -> DoctorCheck {
#[cfg(feature = "biometric")]
{
if keyring_store::has_password(profile) {
if let Some(note) = keyring_store::quick_unlock_storage_note(profile) {
return DoctorCheck {
code: "quick_unlock".to_string(),
status: if note.contains("login keychain fallback") {
DoctorCheckStatus::Note
} else {
DoctorCheckStatus::Ok
},
message: "quick unlock: enabled".to_string(),
detail: Some(note),
};
}
return DoctorCheck {
code: "quick_unlock".to_string(),
status: DoctorCheckStatus::Ok,
message: "quick unlock: enabled (OS credential store)".to_string(),
detail: None,
};
}
DoctorCheck {
code: "quick_unlock".to_string(),
status: DoctorCheckStatus::Info,
message: "quick unlock: not configured (tsafe biometric enable)".to_string(),
detail: None,
}
}
#[cfg(not(feature = "biometric"))]
{
let _ = profile;
DoctorCheck {
code: "quick_unlock".to_string(),
status: DoctorCheckStatus::Info,
message: format!("quick unlock: {}", quick_unlock_unavailable_note()),
detail: None,
}
}
}
fn finalize_report(
profile: &str,
checks: Vec<DoctorCheck>,
secret_count: usize,
chain_coverage_pct: Option<u8>,
) -> DoctorReport {
let mut ok_count = 0usize;
let mut info_count = 0usize;
let mut note_count = 0usize;
let mut warning_count = 0usize;
let mut critical_count = 0usize;
let mut exit_code = 0i32;
for check in &checks {
match check.status {
DoctorCheckStatus::Ok => ok_count += 1,
DoctorCheckStatus::Info => info_count += 1,
DoctorCheckStatus::Note => note_count += 1,
DoctorCheckStatus::Warning => warning_count += 1,
DoctorCheckStatus::Critical => critical_count += 1,
}
exit_code = exit_code.max(check.status.exit_code());
}
let status = match exit_code {
0 => DoctorOverallStatus::Healthy,
1 => DoctorOverallStatus::Warning,
_ => DoctorOverallStatus::Critical,
};
DoctorReport {
profile: profile.to_string(),
status,
exit_code,
summary: DoctorSummary {
check_count: checks.len(),
ok_count,
info_count,
note_count,
warning_count,
critical_count,
secret_count,
},
chain_coverage_pct,
checks,
}
}
fn render_human(report: &DoctorReport) {
for check in &report.checks {
println!("{} {}", check.status.icon(), check.message);
if let Some(detail) = &check.detail {
for line in detail.lines() {
println!(" {line}");
}
}
}
if report.exit_code == 0 {
println!("\n{} vault is healthy", "✓".green().bold());
} else {
let total = report.summary.warning_count + report.summary.critical_count;
let icon = if report.exit_code == 2 {
"✗".red().bold()
} else {
"!".yellow().bold()
};
eprintln!("\n{icon} {total} issue(s) found — review output above");
}
}
#[cfg(not(feature = "biometric"))]
fn quick_unlock_unavailable_note() -> &'static str {
"unavailable in this build"
}
fn doctor_env_vars() -> Vec<DoctorEnvVar> {
#[allow(unused_mut)]
let mut vars = vec![
DoctorEnvVar {
name: "TSAFE_PROFILE",
description: "active profile override",
warn_if_set: false,
},
DoctorEnvVar {
name: "TSAFE_PASSWORD",
description: "CI password bypass — REMOVE after testing",
warn_if_set: true,
},
DoctorEnvVar {
name: "TSAFE_NEW_MASTER_PASSWORD",
description: "new master password for `tsafe rotate` automation — REMOVE after use",
warn_if_set: true,
},
DoctorEnvVar {
name: "TSAFE_VAULT_DIR",
description: "custom vault directory",
warn_if_set: false,
},
];
#[cfg(feature = "akv-pull")]
vars.push(DoctorEnvVar {
name: "TSAFE_AKV_URL",
description: "Azure Key Vault URL",
warn_if_set: false,
});
#[cfg(feature = "cloud-pull-vault")]
vars.push(DoctorEnvVar {
name: "TSAFE_HCP_URL",
description: "HashiCorp Vault address",
warn_if_set: false,
});
#[cfg(feature = "ots-sharing")]
vars.push(DoctorEnvVar {
name: "TSAFE_OTS_BASE_URL",
description: "one-time secret (OTS) HTTPS origin for share-once",
warn_if_set: false,
});
vars
}
fn ci_exec_hint() -> String {
#[cfg(feature = "agent")]
{
"exec: CI job — use scoped job secrets / TSAFE_PASSWORD or tsafe agent unlock".to_string()
}
#[cfg(not(feature = "agent"))]
{
"exec: CI job — use scoped job secrets / TSAFE_PASSWORD".to_string()
}
}
fn cwd_has_layered_env_files() -> bool {
let Ok(mut dir) = std::env::current_dir() else {
return false;
};
for _ in 0..6 {
if dir.join(".envrc").is_file() || dir.join(".env").is_file() {
return true;
}
let Some(parent) = dir.parent() else {
break;
};
dir = parent.to_path_buf();
}
false
}
#[cfg(test)]
mod tests {
use super::{ci_exec_hint, doctor_env_vars};
#[test]
fn doctor_env_vars_follow_compiled_feature_shape() {
let vars: Vec<&str> = doctor_env_vars().iter().map(|var| var.name).collect();
assert!(vars.contains(&"TSAFE_PROFILE"));
assert!(vars.contains(&"TSAFE_PASSWORD"));
assert!(vars.contains(&"TSAFE_NEW_MASTER_PASSWORD"));
assert!(vars.contains(&"TSAFE_VAULT_DIR"));
assert_eq!(vars.contains(&"TSAFE_AKV_URL"), cfg!(feature = "akv-pull"));
assert_eq!(
vars.contains(&"TSAFE_HCP_URL"),
cfg!(feature = "cloud-pull-vault")
);
assert_eq!(
vars.contains(&"TSAFE_OTS_BASE_URL"),
cfg!(feature = "ots-sharing")
);
}
#[test]
fn ci_exec_hint_follows_compiled_feature_shape() {
let hint = ci_exec_hint();
assert!(hint.contains("CI job"));
assert!(hint.contains("TSAFE_PASSWORD"));
assert_eq!(hint.contains("tsafe agent unlock"), cfg!(feature = "agent"));
}
#[cfg(not(feature = "biometric"))]
#[test]
fn quick_unlock_guidance_is_absent_when_biometric_is_not_compiled() {
assert_eq!(
super::quick_unlock_unavailable_note(),
"unavailable in this build"
);
}
}