use std::collections::BTreeSet;
use std::str::FromStr;
use crate::coordinate::Coordinate;
use crate::crypto::KEY_LEN;
use crate::envrefs::{EnvRefs, Source};
use crate::error::CoreError;
use crate::policy::prod_forbids_fallback;
use crate::registry::{Registry, Resolution};
use crate::store;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Error,
Warning,
Info,
}
impl Severity {
pub fn tag(&self) -> &'static str {
match self {
Severity::Error => "ERROR",
Severity::Warning => "WARN",
Severity::Info => "INFO",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Finding {
pub severity: Severity,
pub coordinate: Option<String>,
pub message: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Report {
pub findings: Vec<Finding>,
}
impl Report {
fn push(&mut self, severity: Severity, coordinate: Option<String>, message: impl Into<String>) {
self.findings.push(Finding {
severity,
coordinate,
message: message.into(),
});
}
pub fn has_errors(&self) -> bool {
self.findings.iter().any(|f| f.severity == Severity::Error)
}
pub fn count(&self, severity: Severity) -> usize {
self.findings
.iter()
.filter(|f| f.severity == severity)
.count()
}
}
pub fn check(
refs: &EnvRefs,
env: &str,
registry: &Registry,
key: &[u8; KEY_LEN],
project: Option<&str>,
) -> Result<Report, CoreError> {
let mut report = Report::default();
let mut referenced: BTreeSet<String> = BTreeSet::new();
for (name, source) in &refs.vars {
let Source::Uri { uri, fallback } = source else {
continue; };
let coord = match Coordinate::from_str(uri) {
Ok(c) => c.with_env(env),
Err(e) => {
report.push(
Severity::Error,
Some(uri.clone()),
format!("`{name}`: malformed coordinate — {e}"),
);
continue;
}
};
let canonical = coord
.canonical_path()
.expect("with_env replaced the ${ENV} placeholder");
let effective_env = canonical.split('/').next().unwrap_or(env);
if prod_forbids_fallback(effective_env) && fallback.is_some() {
report.push(
Severity::Error,
Some(canonical.clone()),
format!("`{name}`: a `prod` coordinate must not have a `| fallback` (I4c)"),
);
}
match registry.resolve_with_key(&coord, project, key)? {
Resolution::Found { record, .. } => {
referenced.insert(canonical.clone());
if let Some(reference) = record.reference() {
let scheme = crate::reference_scheme(reference).unwrap_or("?");
report.push(
Severity::Info,
Some(canonical.clone()),
format!(
"`{name}`: resolves to a `{scheme}` reference; \
remote accessibility not probed offline"
),
);
}
}
Resolution::NotFound => {
if fallback.is_some() {
report.push(
Severity::Warning,
Some(canonical.clone()),
format!("`{name}`: coordinate does not resolve, but a fallback is set"),
);
} else {
report.push(
Severity::Error,
Some(canonical.clone()),
format!("`{name}`: coordinate does not resolve and has no fallback"),
);
}
}
}
}
for record in vault_records(registry, key, project)? {
if record.environment() != env {
continue; }
let path = record.canonical_path();
if !referenced.contains(&path) {
report.push(
Severity::Warning,
Some(path),
"orphan secret: in the vault for this env but not referenced by `.env.refs`",
);
}
}
Ok(report)
}
fn vault_records(
registry: &Registry,
key: &[u8; KEY_LEN],
project: Option<&str>,
) -> Result<Vec<crate::record::SecretRecord>, CoreError> {
let mut out = Vec::new();
out.extend(
store::load_all(®istry.global_dir(), key)?
.records
.into_iter()
.map(|(_, r)| r),
);
if let Some(name) = project {
let dir = registry.project_dir(name);
if dir.exists() {
out.extend(
store::load_all(&dir, key)?
.records
.into_iter()
.map(|(_, r)| r),
);
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::record::SecretRecord;
use crate::sensitivity::Sensitivity;
use crate::{Coordinate, SecretValue, crypto::KEY_LEN, seal};
const KEY: [u8; KEY_LEN] = [7u8; KEY_LEN];
fn registry_with(records: &[SecretRecord]) -> (tempfile::TempDir, Registry) {
let tmp = tempfile::tempdir().unwrap();
let registry = Registry::open(tmp.path()).unwrap();
for rec in records {
let coord = Coordinate::from_str(&format!("secret:{}", rec.canonical_path())).unwrap();
store::write_record(®istry.global_dir(), &coord, &seal(rec, &KEY).unwrap()).unwrap();
}
(tmp, registry)
}
fn literal(env: &str, component: &str, key: &str, sens: Sensitivity) -> SecretRecord {
SecretRecord::Literal {
value: SecretValue::new(b"x".to_vec()),
sensitivity: sens,
revealable: false,
environment: env.to_string(),
component: component.to_string(),
key: key.to_string(),
description: None,
created: "t".to_string(),
updated: "t".to_string(),
}
}
fn reference(env: &str, component: &str, key: &str) -> SecretRecord {
SecretRecord::Reference {
reference: "azure-kv://corp-kv/db-url".to_string(),
sensitivity: Sensitivity::High,
revealable: false,
environment: env.to_string(),
component: component.to_string(),
key: key.to_string(),
description: None,
created: "t".to_string(),
updated: "t".to_string(),
}
}
#[test]
fn clean_config_has_no_errors() {
let (_tmp, reg) = registry_with(&[literal("dev", "db", "password", Sensitivity::Medium)]);
let refs = EnvRefs::parse("DB=secret:${ENV}/db/password").unwrap();
let report = check(&refs, "dev", ®, &KEY, None).unwrap();
assert!(!report.has_errors(), "clean config: {:?}", report.findings);
}
#[test]
fn unresolved_coordinate_without_fallback_is_error() {
let (_tmp, reg) = registry_with(&[]);
let refs = EnvRefs::parse("DB=secret:${ENV}/db/password").unwrap();
let report = check(&refs, "dev", ®, &KEY, None).unwrap();
assert!(report.has_errors());
assert!(
report
.findings
.iter()
.any(|f| f.message.contains("does not resolve"))
);
}
#[test]
fn unresolved_with_fallback_is_only_a_warning() {
let (_tmp, reg) = registry_with(&[]);
let refs = EnvRefs::parse("DB=secret:${ENV}/db/password | localhost").unwrap();
let report = check(&refs, "dev", ®, &KEY, None).unwrap();
assert!(!report.has_errors());
assert_eq!(report.count(Severity::Warning), 1);
}
#[test]
fn prod_with_fallback_is_a_hard_error() {
let (_tmp, reg) = registry_with(&[literal("prod", "db", "password", Sensitivity::High)]);
let refs = EnvRefs::parse("DB=secret:prod/db/password | localhost").unwrap();
let report = check(&refs, "prod", ®, &KEY, None).unwrap();
assert!(report.has_errors());
assert!(
report
.findings
.iter()
.any(|f| f.message.contains("must not have a `| fallback`"))
);
}
#[test]
fn orphan_vault_entry_is_flagged() {
let (_tmp, reg) = registry_with(&[
literal("dev", "db", "password", Sensitivity::Medium),
literal("dev", "app", "unused", Sensitivity::Medium),
]);
let refs = EnvRefs::parse("DB=secret:${ENV}/db/password").unwrap();
let report = check(&refs, "dev", ®, &KEY, None).unwrap();
assert!(!report.has_errors(), "orphan is a warning, not an error");
assert!(
report.findings.iter().any(|f| {
f.severity == Severity::Warning && f.coordinate.as_deref() == Some("dev/app/unused")
}),
"the unused vault entry must be flagged as an orphan: {:?}",
report.findings
);
}
#[test]
fn reference_is_reported_as_info_not_value() {
let (_tmp, reg) = registry_with(&[reference("dev", "db", "url")]);
let refs = EnvRefs::parse("DB=secret:${ENV}/db/url").unwrap();
let report = check(&refs, "dev", ®, &KEY, None).unwrap();
assert!(!report.has_errors());
let info = report
.findings
.iter()
.find(|f| f.severity == Severity::Info)
.expect("a reference yields an INFO finding");
assert!(info.message.contains("azure-kv"));
let blob = format!("{:?}", report.findings);
assert!(!blob.contains("db-url-value"));
}
#[test]
fn malformed_coordinate_is_an_error() {
let (_tmp, reg) = registry_with(&[]);
let refs = EnvRefs::parse("DB=secret:${ENV}/db/password").unwrap();
let mut bad = refs.clone();
bad.vars = vec![(
"DB".to_string(),
Source::Uri {
uri: "secret:dev/onlytwo".to_string(),
fallback: None,
},
)];
let report = check(&bad, "dev", ®, &KEY, None).unwrap();
assert!(report.has_errors());
assert!(
report
.findings
.iter()
.any(|f| f.message.contains("malformed coordinate"))
);
}
}