use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::audit::AuditLog;
use crate::profile;
use crate::snapshot;
use crate::vault::VaultFile;
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SignalSeverity {
Ok,
Info,
Warning,
Critical,
}
impl SignalSeverity {
pub fn is_degraded(self) -> bool {
self >= Self::Warning
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HealthSignal {
pub code: String,
pub severity: SignalSeverity,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
impl HealthSignal {
fn ok(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
severity: SignalSeverity::Ok,
message: message.into(),
detail: None,
}
}
fn info(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
severity: SignalSeverity::Info,
message: message.into(),
detail: None,
}
}
fn warning(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
severity: SignalSeverity::Warning,
message: message.into(),
detail: None,
}
}
fn critical(
code: impl Into<String>,
message: impl Into<String>,
detail: Option<String>,
) -> Self {
Self {
code: code.into(),
severity: SignalSeverity::Critical,
message: message.into(),
detail,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct VaultHealthMeta {
pub schema: String,
pub cipher: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub secret_count: usize,
pub is_team_vault: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OverallHealth {
Healthy,
Degraded,
Critical,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HealthReport {
pub profile: String,
pub generated_at: DateTime<Utc>,
pub overall: OverallHealth,
pub vault_reachable: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub vault_meta: Option<VaultHealthMeta>,
pub profile_count: usize,
pub snapshot_count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_audit_entry_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_rotate_at: Option<DateTime<Utc>>,
pub audit_entry_count: usize,
pub signals: Vec<HealthSignal>,
pub ok_count: usize,
pub info_count: usize,
pub warning_count: usize,
pub critical_count: usize,
}
impl HealthReport {
pub fn is_healthy(&self) -> bool {
self.overall == OverallHealth::Healthy
}
}
const AUDIT_SCAN_LIMIT: usize = 8_000;
pub fn collect_health(profile: &str) -> HealthReport {
let generated_at = Utc::now();
let mut signals: Vec<HealthSignal> = Vec::new();
let vault_path = profile::vault_path(profile);
let vault_reachable = vault_path.exists();
if vault_reachable {
signals.push(HealthSignal::ok(
"vault.reachable",
format!("vault file found: {}", vault_path.display()),
));
} else {
signals.push(HealthSignal::critical(
"vault.reachable",
format!(
"vault file not found for profile '{profile}' — run: tsafe --profile {profile} init"
),
None,
));
}
let vault_meta: Option<VaultHealthMeta> = if vault_reachable {
match std::fs::read_to_string(&vault_path) {
Ok(json) => match serde_json::from_str::<VaultFile>(&json) {
Ok(vf) => {
if matches!(vf.schema.as_str(), "tsafe/vault/v1" | "tsafe/vault/v2") {
signals.push(HealthSignal::ok(
"vault.schema",
format!("vault schema: {}", vf.schema),
));
} else {
signals.push(HealthSignal::critical(
"vault.schema",
format!("unknown vault schema: {}", vf.schema),
None,
));
}
if crate::crypto::parse_cipher_kind(&vf.cipher).is_ok() {
signals.push(HealthSignal::ok(
"vault.cipher",
format!("cipher: {}", vf.cipher),
));
} else {
signals.push(HealthSignal::critical(
"vault.cipher",
format!("unrecognised cipher: {}", vf.cipher),
None,
));
}
let is_team = !vf.age_recipients.is_empty() && vf.wrapped_dek.is_some();
let secret_count = vf.secrets.len();
signals.push(HealthSignal::info(
"vault.secret_count",
format!("{secret_count} secret(s) stored in vault"),
));
Some(VaultHealthMeta {
schema: vf.schema,
cipher: vf.cipher,
created_at: vf.created_at,
updated_at: vf.updated_at,
secret_count,
is_team_vault: is_team,
})
}
Err(e) => {
signals.push(HealthSignal::critical(
"vault.parse",
"vault file could not be parsed as valid vault JSON",
Some(e.to_string()),
));
None
}
},
Err(e) => {
signals.push(HealthSignal::critical(
"vault.read",
"vault file exists but could not be read",
Some(e.to_string()),
));
None
}
}
} else {
None
};
let snapshot_count = match snapshot::list(profile) {
Ok(snaps) if snaps.is_empty() => {
signals.push(HealthSignal::info(
"vault.snapshots",
"no snapshots yet — a snapshot is created on the next write operation",
));
0
}
Ok(snaps) => {
let n = snaps.len();
signals.push(HealthSignal::ok(
"vault.snapshots",
format!("{n} snapshot(s) available for this profile"),
));
n
}
Err(e) => {
signals.push(HealthSignal::critical(
"vault.snapshots",
"could not list snapshots",
Some(e.to_string()),
));
0
}
};
let profile_count = match profile::list_profiles() {
Ok(profiles) => {
let n = profiles.len();
signals.push(HealthSignal::info(
"profiles.count",
format!("{n} profile(s) found on this installation"),
));
n
}
Err(e) => {
signals.push(HealthSignal::warning(
"profiles.count",
format!("could not enumerate profiles: {e}"),
));
0
}
};
let audit_log = AuditLog::new(&profile::audit_log_path(profile));
let (last_audit_entry_at, last_rotate_at, audit_entry_count) =
match audit_log.read(Some(AUDIT_SCAN_LIMIT)) {
Ok(entries) => {
let count = entries.len();
let last_ts = entries.first().map(|e| e.timestamp);
let last_rotate = entries
.iter()
.find(|e| {
e.profile == profile
&& e.operation == "rotate"
&& matches!(e.status, crate::audit::AuditStatus::Success)
})
.map(|e| e.timestamp);
if count == 0 {
signals.push(HealthSignal::info(
"audit.entries",
"audit log is empty or does not exist yet",
));
} else {
if let Some(ts) = last_ts {
signals.push(HealthSignal::info(
"audit.last_entry",
format!("most recent audit entry: {ts}"),
));
}
if count == AUDIT_SCAN_LIMIT {
signals.push(HealthSignal::info(
"audit.scan_limit",
format!(
"audit scan capped at {AUDIT_SCAN_LIMIT} entries — log may be larger"
),
));
}
}
(last_ts, last_rotate, count)
}
Err(e) => {
signals.push(HealthSignal::warning(
"audit.read",
format!("could not read audit log: {e}"),
));
(None, None, 0)
}
};
let mut ok_count = 0usize;
let mut info_count = 0usize;
let mut warning_count = 0usize;
let mut critical_count = 0usize;
for sig in &signals {
match sig.severity {
SignalSeverity::Ok => ok_count += 1,
SignalSeverity::Info => info_count += 1,
SignalSeverity::Warning => warning_count += 1,
SignalSeverity::Critical => critical_count += 1,
}
}
let overall = if critical_count > 0 {
OverallHealth::Critical
} else if warning_count > 0 {
OverallHealth::Degraded
} else {
OverallHealth::Healthy
};
signals.sort_by(|a, b| b.severity.cmp(&a.severity).then(a.code.cmp(&b.code)));
HealthReport {
profile: profile.to_string(),
generated_at,
overall,
vault_reachable,
vault_meta,
profile_count,
snapshot_count,
last_audit_entry_at,
last_rotate_at,
audit_entry_count,
signals,
ok_count,
info_count,
warning_count,
critical_count,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vault::Vault;
use std::collections::HashMap;
use tempfile::tempdir;
fn pw() -> &'static [u8] {
b"health-test-password"
}
fn with_vault_dir<F: FnOnce()>(root: &std::path::Path, f: F) {
let vault_subdir = root.join("vaults");
std::fs::create_dir_all(&vault_subdir).unwrap();
temp_env::with_var("TSAFE_VAULT_DIR", Some(vault_subdir.to_str().unwrap()), f);
}
#[test]
fn missing_vault_yields_critical_signal_and_no_meta() {
let dir = tempdir().unwrap();
with_vault_dir(dir.path(), || {
let report = collect_health("nonexistent");
assert!(!report.vault_reachable);
assert_eq!(report.overall, OverallHealth::Critical);
assert!(report.vault_meta.is_none());
assert_eq!(report.snapshot_count, 0);
let critical = report
.signals
.iter()
.find(|s| s.code == "vault.reachable")
.unwrap();
assert_eq!(critical.severity, SignalSeverity::Critical);
});
}
#[test]
fn healthy_vault_yields_healthy_overall() {
let dir = tempdir().unwrap();
with_vault_dir(dir.path(), || {
let vault_path = crate::profile::vault_path("test");
std::fs::create_dir_all(vault_path.parent().unwrap()).unwrap();
Vault::create(&vault_path, pw()).unwrap();
let report = collect_health("test");
assert!(report.vault_reachable);
assert_eq!(report.overall, OverallHealth::Healthy);
assert!(report.vault_meta.is_some());
let meta = report.vault_meta.unwrap();
assert_eq!(meta.secret_count, 0);
assert!(!meta.is_team_vault);
assert!(meta.schema.starts_with("tsafe/vault/"));
});
}
#[test]
fn secret_count_is_reflected_in_meta() {
let dir = tempdir().unwrap();
with_vault_dir(dir.path(), || {
let vault_path = crate::profile::vault_path("counting");
std::fs::create_dir_all(vault_path.parent().unwrap()).unwrap();
let mut vault = Vault::create(&vault_path, pw()).unwrap();
vault.set("A", "alpha", HashMap::new()).unwrap();
vault.set("B", "beta", HashMap::new()).unwrap();
drop(vault);
let report = collect_health("counting");
assert_eq!(report.vault_meta.unwrap().secret_count, 2);
let sig = report
.signals
.iter()
.find(|s| s.code == "vault.secret_count")
.unwrap();
assert_eq!(sig.severity, SignalSeverity::Info);
assert!(sig.message.contains("2 secret(s)"));
});
}
#[test]
fn snapshot_count_is_reflected() {
let dir = tempdir().unwrap();
with_vault_dir(dir.path(), || {
let vault_path = crate::profile::vault_path("snap-profile");
std::fs::create_dir_all(vault_path.parent().unwrap()).unwrap();
let mut vault = Vault::create(&vault_path, pw()).unwrap();
vault.set("K", "v1", HashMap::new()).unwrap();
vault.set("K", "v2", HashMap::new()).unwrap();
drop(vault);
let report = collect_health("snap-profile");
assert!(report.snapshot_count >= 1);
});
}
#[test]
fn profile_count_includes_created_profiles() {
let dir = tempdir().unwrap();
with_vault_dir(dir.path(), || {
for name in ["p1", "p2", "p3"] {
let vault_path = crate::profile::vault_path(name);
std::fs::create_dir_all(vault_path.parent().unwrap()).unwrap();
Vault::create(&vault_path, pw()).unwrap();
}
let report = collect_health("p1");
assert_eq!(report.profile_count, 3);
});
}
#[test]
fn audit_timestamps_are_collected() {
let dir = tempdir().unwrap();
with_vault_dir(dir.path(), || {
let log_path = crate::profile::audit_log_path("audited");
std::fs::create_dir_all(log_path.parent().unwrap()).unwrap();
let log = crate::audit::AuditLog::new(&log_path);
log.append(&crate::audit::AuditEntry::success(
"audited",
"set",
Some("K"),
))
.unwrap();
log.append(&crate::audit::AuditEntry::success(
"audited", "rotate", None,
))
.unwrap();
let report = collect_health("audited");
assert!(report.last_audit_entry_at.is_some());
assert!(report.last_rotate_at.is_some());
assert_eq!(report.audit_entry_count, 2);
});
}
#[test]
fn empty_audit_log_yields_zero_counts() {
let dir = tempdir().unwrap();
with_vault_dir(dir.path(), || {
let report = collect_health("no-audit");
assert!(report.last_audit_entry_at.is_none());
assert!(report.last_rotate_at.is_none());
assert_eq!(report.audit_entry_count, 0);
});
}
#[test]
fn report_is_serializable_to_json() {
let dir = tempdir().unwrap();
with_vault_dir(dir.path(), || {
let report = collect_health("json-test");
let json = serde_json::to_string_pretty(&report).unwrap();
assert!(json.contains("\"profile\""));
assert!(json.contains("\"overall\""));
assert!(json.contains("\"signals\""));
let _: HealthReport = serde_json::from_str(&json).unwrap();
});
}
#[test]
fn signals_are_sorted_critical_first() {
let dir = tempdir().unwrap();
with_vault_dir(dir.path(), || {
let report = collect_health("sort-test");
if let (Some(first), Some(last)) = (report.signals.first(), report.signals.last()) {
assert!(first.severity >= last.severity);
}
});
}
#[test]
fn corrupt_vault_file_yields_critical_parse_signal() {
let dir = tempdir().unwrap();
with_vault_dir(dir.path(), || {
let vault_path = crate::profile::vault_path("corrupt");
std::fs::create_dir_all(vault_path.parent().unwrap()).unwrap();
std::fs::write(&vault_path, b"not valid json at all").unwrap();
let report = collect_health("corrupt");
assert!(report.vault_reachable); assert_eq!(report.overall, OverallHealth::Critical);
assert!(report.vault_meta.is_none()); let sig = report
.signals
.iter()
.find(|s| s.code == "vault.parse")
.unwrap();
assert_eq!(sig.severity, SignalSeverity::Critical);
});
}
#[test]
fn overall_health_is_healthy_when_no_warnings() {
let dir = tempdir().unwrap();
with_vault_dir(dir.path(), || {
let vault_path = crate::profile::vault_path("all-ok");
std::fs::create_dir_all(vault_path.parent().unwrap()).unwrap();
Vault::create(&vault_path, pw()).unwrap();
let report = collect_health("all-ok");
assert_eq!(report.overall, OverallHealth::Healthy);
assert_eq!(report.critical_count, 0);
assert_eq!(report.warning_count, 0);
});
}
#[test]
fn signal_severity_ordering_is_correct() {
assert!(SignalSeverity::Critical > SignalSeverity::Warning);
assert!(SignalSeverity::Warning > SignalSeverity::Info);
assert!(SignalSeverity::Info > SignalSeverity::Ok);
assert!(SignalSeverity::Critical.is_degraded());
assert!(SignalSeverity::Warning.is_degraded());
assert!(!SignalSeverity::Info.is_degraded());
assert!(!SignalSeverity::Ok.is_degraded());
}
}