1use std::collections::BTreeSet;
22use std::str::FromStr;
23
24use crate::coordinate::Coordinate;
25use crate::crypto::KEY_LEN;
26use crate::envrefs::{EnvRefs, Source};
27use crate::error::CoreError;
28use crate::policy::prod_forbids_fallback;
29use crate::registry::{Registry, Resolution};
30use crate::store;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum Severity {
35 Error,
37 Warning,
39 Info,
41}
42
43impl Severity {
44 pub fn tag(&self) -> &'static str {
46 match self {
47 Severity::Error => "ERROR",
48 Severity::Warning => "WARN",
49 Severity::Info => "INFO",
50 }
51 }
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct Finding {
58 pub severity: Severity,
60 pub coordinate: Option<String>,
62 pub message: String,
64}
65
66#[derive(Debug, Clone, Default, PartialEq, Eq)]
68pub struct Report {
69 pub findings: Vec<Finding>,
71}
72
73impl Report {
74 fn push(&mut self, severity: Severity, coordinate: Option<String>, message: impl Into<String>) {
75 self.findings.push(Finding {
76 severity,
77 coordinate,
78 message: message.into(),
79 });
80 }
81
82 pub fn has_errors(&self) -> bool {
84 self.findings.iter().any(|f| f.severity == Severity::Error)
85 }
86
87 pub fn count(&self, severity: Severity) -> usize {
89 self.findings
90 .iter()
91 .filter(|f| f.severity == severity)
92 .count()
93 }
94}
95
96pub fn check(
103 refs: &EnvRefs,
104 env: &str,
105 registry: &Registry,
106 key: &[u8; KEY_LEN],
107 project: Option<&str>,
108) -> Result<Report, CoreError> {
109 let mut report = Report::default();
110 let mut referenced: BTreeSet<String> = BTreeSet::new();
111
112 for (name, source) in &refs.vars {
113 let Source::Uri { uri, fallback } = source else {
114 continue; };
116
117 let coord = match Coordinate::from_str(uri) {
118 Ok(c) => c.with_env(env),
119 Err(e) => {
120 report.push(
121 Severity::Error,
122 Some(uri.clone()),
123 format!("`{name}`: malformed coordinate — {e}"),
124 );
125 continue;
126 }
127 };
128 let canonical = coord
131 .canonical_path()
132 .expect("with_env replaced the ${ENV} placeholder");
133 let effective_env = canonical.split('/').next().unwrap_or(env);
134
135 if prod_forbids_fallback(effective_env) && fallback.is_some() {
137 report.push(
138 Severity::Error,
139 Some(canonical.clone()),
140 format!("`{name}`: a `prod` coordinate must not have a `| fallback` (I4c)"),
141 );
142 }
143
144 match registry.resolve_with_key(&coord, project, key)? {
146 Resolution::Found { record, .. } => {
147 referenced.insert(canonical.clone());
148 if let Some(reference) = record.reference() {
150 let scheme = crate::reference_scheme(reference).unwrap_or("?");
151 report.push(
152 Severity::Info,
153 Some(canonical.clone()),
154 format!(
155 "`{name}`: resolves to a `{scheme}` reference; \
156 remote accessibility not probed offline"
157 ),
158 );
159 }
160 }
161 Resolution::NotFound => {
162 if fallback.is_some() {
163 report.push(
164 Severity::Warning,
165 Some(canonical.clone()),
166 format!("`{name}`: coordinate does not resolve, but a fallback is set"),
167 );
168 } else {
169 report.push(
170 Severity::Error,
171 Some(canonical.clone()),
172 format!("`{name}`: coordinate does not resolve and has no fallback"),
173 );
174 }
175 }
176 }
177 }
178
179 for record in vault_records(registry, key, project)? {
181 if record.environment() != env {
182 continue; }
184 let path = record.canonical_path();
185 if !referenced.contains(&path) {
186 report.push(
187 Severity::Warning,
188 Some(path),
189 "orphan secret: in the vault for this env but not referenced by `.env.refs`",
190 );
191 }
192 }
193
194 Ok(report)
195}
196
197fn vault_records(
200 registry: &Registry,
201 key: &[u8; KEY_LEN],
202 project: Option<&str>,
203) -> Result<Vec<crate::record::SecretRecord>, CoreError> {
204 let mut out = Vec::new();
205 out.extend(
206 store::load_all(®istry.global_dir(), key)?
207 .records
208 .into_iter()
209 .map(|(_, r)| r),
210 );
211 if let Some(name) = project {
212 let dir = registry.project_dir(name);
213 if dir.exists() {
214 out.extend(
215 store::load_all(&dir, key)?
216 .records
217 .into_iter()
218 .map(|(_, r)| r),
219 );
220 }
221 }
222 Ok(out)
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use crate::record::SecretRecord;
229 use crate::sensitivity::Sensitivity;
230 use crate::{Coordinate, SecretValue, crypto::KEY_LEN, seal};
231
232 const KEY: [u8; KEY_LEN] = [7u8; KEY_LEN];
233
234 fn registry_with(records: &[SecretRecord]) -> (tempfile::TempDir, Registry) {
235 let tmp = tempfile::tempdir().unwrap();
236 let registry = Registry::open(tmp.path()).unwrap();
237 for rec in records {
238 let coord = Coordinate::from_str(&format!("secret:{}", rec.canonical_path())).unwrap();
239 store::write_record(®istry.global_dir(), &coord, &seal(rec, &KEY).unwrap()).unwrap();
240 }
241 (tmp, registry)
242 }
243
244 fn literal(env: &str, component: &str, key: &str, sens: Sensitivity) -> SecretRecord {
245 SecretRecord::Literal {
246 value: SecretValue::new(b"x".to_vec()),
247 sensitivity: sens,
248 revealable: false,
249 environment: env.to_string(),
250 component: component.to_string(),
251 key: key.to_string(),
252 description: None,
253 created: "t".to_string(),
254 updated: "t".to_string(),
255 }
256 }
257
258 fn reference(env: &str, component: &str, key: &str) -> SecretRecord {
259 SecretRecord::Reference {
260 reference: "azure-kv://corp-kv/db-url".to_string(),
261 sensitivity: Sensitivity::High,
262 revealable: false,
263 environment: env.to_string(),
264 component: component.to_string(),
265 key: key.to_string(),
266 description: None,
267 created: "t".to_string(),
268 updated: "t".to_string(),
269 }
270 }
271
272 #[test]
273 fn clean_config_has_no_errors() {
274 let (_tmp, reg) = registry_with(&[literal("dev", "db", "password", Sensitivity::Medium)]);
275 let refs = EnvRefs::parse("DB=secret:${ENV}/db/password").unwrap();
276 let report = check(&refs, "dev", ®, &KEY, None).unwrap();
277 assert!(!report.has_errors(), "clean config: {:?}", report.findings);
278 }
279
280 #[test]
281 fn unresolved_coordinate_without_fallback_is_error() {
282 let (_tmp, reg) = registry_with(&[]);
283 let refs = EnvRefs::parse("DB=secret:${ENV}/db/password").unwrap();
284 let report = check(&refs, "dev", ®, &KEY, None).unwrap();
285 assert!(report.has_errors());
286 assert!(
287 report
288 .findings
289 .iter()
290 .any(|f| f.message.contains("does not resolve"))
291 );
292 }
293
294 #[test]
295 fn unresolved_with_fallback_is_only_a_warning() {
296 let (_tmp, reg) = registry_with(&[]);
297 let refs = EnvRefs::parse("DB=secret:${ENV}/db/password | localhost").unwrap();
298 let report = check(&refs, "dev", ®, &KEY, None).unwrap();
299 assert!(!report.has_errors());
300 assert_eq!(report.count(Severity::Warning), 1);
301 }
302
303 #[test]
304 fn prod_with_fallback_is_a_hard_error() {
305 let (_tmp, reg) = registry_with(&[literal("prod", "db", "password", Sensitivity::High)]);
306 let refs = EnvRefs::parse("DB=secret:prod/db/password | localhost").unwrap();
307 let report = check(&refs, "prod", ®, &KEY, None).unwrap();
308 assert!(report.has_errors());
309 assert!(
310 report
311 .findings
312 .iter()
313 .any(|f| f.message.contains("must not have a `| fallback`"))
314 );
315 }
316
317 #[test]
318 fn orphan_vault_entry_is_flagged() {
319 let (_tmp, reg) = registry_with(&[
320 literal("dev", "db", "password", Sensitivity::Medium),
321 literal("dev", "app", "unused", Sensitivity::Medium),
322 ]);
323 let refs = EnvRefs::parse("DB=secret:${ENV}/db/password").unwrap();
324 let report = check(&refs, "dev", ®, &KEY, None).unwrap();
325 assert!(!report.has_errors(), "orphan is a warning, not an error");
326 assert!(
327 report.findings.iter().any(|f| {
328 f.severity == Severity::Warning && f.coordinate.as_deref() == Some("dev/app/unused")
329 }),
330 "the unused vault entry must be flagged as an orphan: {:?}",
331 report.findings
332 );
333 }
334
335 #[test]
336 fn reference_is_reported_as_info_not_value() {
337 let (_tmp, reg) = registry_with(&[reference("dev", "db", "url")]);
338 let refs = EnvRefs::parse("DB=secret:${ENV}/db/url").unwrap();
339 let report = check(&refs, "dev", ®, &KEY, None).unwrap();
340 assert!(!report.has_errors());
341 let info = report
342 .findings
343 .iter()
344 .find(|f| f.severity == Severity::Info)
345 .expect("a reference yields an INFO finding");
346 assert!(info.message.contains("azure-kv"));
347 let blob = format!("{:?}", report.findings);
349 assert!(!blob.contains("db-url-value"));
350 }
351
352 #[test]
353 fn malformed_coordinate_is_an_error() {
354 let (_tmp, reg) = registry_with(&[]);
355 let refs = EnvRefs::parse("DB=secret:${ENV}/db/password").unwrap();
356 let mut bad = refs.clone();
358 bad.vars = vec![(
359 "DB".to_string(),
360 Source::Uri {
361 uri: "secret:dev/onlytwo".to_string(),
362 fallback: None,
363 },
364 )];
365 let report = check(&bad, "dev", ®, &KEY, None).unwrap();
366 assert!(report.has_errors());
367 assert!(
368 report
369 .findings
370 .iter()
371 .any(|f| f.message.contains("malformed coordinate"))
372 );
373 }
374}