1use serde::{Deserialize, Serialize};
27use std::collections::BTreeMap;
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct Exposed {
33 pub entry_id: String,
34 pub provider: String,
35 #[serde(default)]
36 pub key_id: String,
37 pub field: String,
40 pub fp: String,
43 #[serde(default)]
46 pub short: bool,
47}
48
49#[derive(Debug, Clone, PartialEq, Serialize)]
51pub struct Deployment {
52 pub at: String,
53 pub via: String,
55 pub sha256: String,
57 pub ok: bool,
58 pub error: Option<String>,
59}
60
61#[derive(Debug, Clone, Default)]
63pub struct CurrentEntry {
64 pub provider: String,
65 pub key_id: String,
66 pub fps: Vec<String>,
68 pub console_url: Option<String>,
69}
70
71#[derive(Debug, Clone, PartialEq, Serialize)]
72pub struct EntryExposure {
73 pub entry_id: String,
74 pub provider: String,
75 pub key_id: String,
76 pub fields: Vec<String>,
77 pub first_seen: String,
78 pub last_seen: String,
79 pub times: usize,
80 pub still_current: bool,
83 pub removed: bool,
85 pub short: bool,
86 pub console_url: Option<String>,
87 pub via: Vec<String>,
88}
89
90#[derive(Debug, Clone, PartialEq, Serialize)]
91pub struct Report {
92 pub host: String,
93 pub since: Option<String>,
94 pub deployments: usize,
95 pub unaccounted: Vec<Deployment>,
97 pub entries: Vec<EntryExposure>,
98 pub rotate: Vec<String>,
100 pub command: String,
102}
103
104pub fn failed_apply_touched_disk(error: &str) -> bool {
107 error.contains("validate failed") || error.contains("reload failed")
108}
109
110fn entry_label(provider: &str, key_id: &str) -> String {
111 if key_id.is_empty() {
112 provider.to_string()
113 } else {
114 format!("{provider}:{key_id}")
115 }
116}
117
118fn quote(s: &str) -> Option<String> {
121 if s.contains(['\'', '"', '\n', '\r', '\0']) {
122 None
123 } else {
124 Some(format!("'{s}'"))
125 }
126}
127
128pub fn report(
132 host: &str,
133 since: Option<&str>,
134 deployments: &[Deployment],
135 lookup: &dyn Fn(&Deployment) -> Option<Vec<Exposed>>,
136 current: &dyn Fn(&str) -> Option<CurrentEntry>,
137) -> Report {
138 let mut unaccounted = Vec::new();
139 let mut by_entry: BTreeMap<String, EntryExposure> = BTreeMap::new();
140 let mut counted = 0usize;
141
142 for d in deployments {
143 if since.is_some_and(|s| d.at.as_str() < s) {
144 continue;
145 }
146 if !d.ok && !d.error.as_deref().is_some_and(failed_apply_touched_disk) {
147 continue;
148 }
149 counted += 1;
150 let Some(exposed) = lookup(d) else {
151 unaccounted.push(d.clone());
152 continue;
153 };
154 for e in exposed {
155 let cur = current(&e.entry_id);
156 let still = cur.as_ref().is_some_and(|c| c.fps.contains(&e.fp));
157 let slot = by_entry
158 .entry(e.entry_id.clone())
159 .or_insert_with(|| EntryExposure {
160 entry_id: e.entry_id.clone(),
161 provider: e.provider.clone(),
162 key_id: e.key_id.clone(),
163 fields: Vec::new(),
164 first_seen: d.at.clone(),
165 last_seen: d.at.clone(),
166 times: 0,
167 still_current: false,
168 removed: cur.is_none(),
169 short: true,
170 console_url: cur.as_ref().and_then(|c| c.console_url.clone()),
171 via: Vec::new(),
172 });
173 if !slot.fields.contains(&e.field) {
174 slot.fields.push(e.field.clone());
175 }
176 if d.at < slot.first_seen {
177 slot.first_seen = d.at.clone();
178 }
179 if d.at > slot.last_seen {
180 slot.last_seen = d.at.clone();
181 }
182 slot.times += 1;
183 slot.still_current |= still;
185 slot.short &= e.short;
186 if !slot.via.contains(&d.via) {
187 slot.via.push(d.via.clone());
188 }
189 if let Some(c) = &cur {
191 slot.provider.clone_from(&c.provider);
192 slot.key_id.clone_from(&c.key_id);
193 }
194 }
195 }
196
197 let mut entries: Vec<EntryExposure> = by_entry.into_values().collect();
198 entries.sort_by(|a, b| {
199 b.still_current
200 .cmp(&a.still_current)
201 .then_with(|| a.provider.to_lowercase().cmp(&b.provider.to_lowercase()))
202 .then_with(|| a.key_id.cmp(&b.key_id))
203 });
204 let rotate: Vec<String> = entries
205 .iter()
206 .filter(|e| e.still_current)
207 .map(|e| entry_label(&e.provider, &e.key_id))
208 .collect();
209 let mut parts = Vec::new();
210 for label in &rotate {
211 match quote(label) {
212 Some(q) => parts.push(format!("unv entry rotate {q} --generate")),
213 None => parts.push(format!(
214 "# cannot quote this name safely, rotate it by hand: {label}"
215 )),
216 }
217 }
218 Report {
219 host: host.to_string(),
220 since: since.map(String::from),
221 deployments: counted,
222 unaccounted,
223 entries,
224 rotate,
225 command: parts.join("; "),
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 fn exp(id: &str, provider: &str, field: &str, fp: &str) -> Exposed {
234 Exposed {
235 entry_id: id.into(),
236 provider: provider.into(),
237 key_id: String::new(),
238 field: field.into(),
239 fp: fp.into(),
240 short: false,
241 }
242 }
243
244 fn dep(at: &str, via: &str, sha: &str) -> Deployment {
245 Deployment {
246 at: at.into(),
247 via: via.into(),
248 sha256: sha.into(),
249 ok: true,
250 error: None,
251 }
252 }
253
254 fn cur(provider: &str, fps: &[&str]) -> Option<CurrentEntry> {
255 Some(CurrentEntry {
256 provider: provider.into(),
257 key_id: String::new(),
258 fps: fps.iter().map(|s| s.to_string()).collect(),
259 console_url: Some("https://console.example/keys".into()),
260 })
261 }
262
263 #[test]
264 fn a_still_live_secret_is_rotated_and_a_rotated_away_one_is_not() {
265 let deps = [dep("2026-10-01T00:00:00Z", "nginx", "h1")];
266 let lookup = |_: &Deployment| {
267 Some(vec![
268 exp("e1", "Stripe", "api_key", "fp-live"),
269 exp("e2", "GitHub", "api_key", "fp-old"),
270 ])
271 };
272 let current = |id: &str| match id {
273 "e1" => cur("Stripe", &["fp-live"]),
274 "e2" => cur("GitHub", &["fp-new"]), _ => None,
276 };
277 let r = report("vps-01", None, &deps, &lookup, ¤t);
278 assert_eq!(r.rotate, vec!["Stripe"]);
279 assert_eq!(r.command, "unv entry rotate 'Stripe' --generate");
280 let github = r.entries.iter().find(|e| e.provider == "GitHub").unwrap();
281 assert!(
282 !github.still_current,
283 "a value rotated away needs no rotation"
284 );
285 assert!(
286 r.entries[0].still_current,
287 "still-current entries sort first"
288 );
289 }
290
291 #[test]
292 fn a_deleted_entry_is_reported_but_never_in_the_rotate_command() {
293 let deps = [dep("2026-10-01T00:00:00Z", "nginx", "h1")];
294 let lookup = |_: &Deployment| Some(vec![exp("gone", "Old", "api_key", "fp")]);
295 let r = report("h", None, &deps, &lookup, &|_| None);
296 assert_eq!(r.entries.len(), 1);
297 assert!(r.entries[0].removed);
298 assert!(r.rotate.is_empty());
299 assert_eq!(r.command, "");
300 }
301
302 #[test]
303 fn a_deployment_with_no_recorded_contents_is_unaccounted_not_silently_clean() {
304 let deps = [dep("2026-10-01T00:00:00Z", "nginx", "unknown-hash")];
305 let r = report("h", None, &deps, &|_| None, &|_| None);
306 assert_eq!(r.unaccounted.len(), 1);
307 assert_eq!(r.deployments, 1);
308 assert!(r.entries.is_empty());
309 }
310
311 #[test]
312 fn since_filters_by_time_and_the_window_is_inclusive() {
313 let deps = [
314 dep("2026-09-01T00:00:00Z", "a", "h1"),
315 dep("2026-10-01T00:00:00Z", "b", "h2"),
316 ];
317 let lookup = |d: &Deployment| {
318 Some(vec![exp(
319 if d.sha256 == "h1" { "old" } else { "new" },
320 "P",
321 "api_key",
322 "fp",
323 )])
324 };
325 let r = report("h", Some("2026-10-01T00:00:00Z"), &deps, &lookup, &|id| {
326 cur(id, &["fp"])
327 });
328 assert_eq!(r.entries.len(), 1);
329 assert_eq!(r.entries[0].entry_id, "new");
330 }
331
332 #[test]
333 fn repeated_exposures_collapse_into_one_row_with_a_count_and_a_range() {
334 let deps = [
335 dep("2026-10-03T00:00:00Z", "nginx", "h2"),
336 dep("2026-10-01T00:00:00Z", "nginx", "h1"),
337 dep("2026-10-02T00:00:00Z", "env", "h3"),
338 ];
339 let lookup = |_: &Deployment| Some(vec![exp("e1", "Stripe", "api_key", "fp")]);
340 let r = report("h", None, &deps, &lookup, &|_| cur("Stripe", &["fp"]));
341 assert_eq!(r.entries.len(), 1);
342 let e = &r.entries[0];
343 assert_eq!(
344 (e.times, e.first_seen.as_str(), e.last_seen.as_str()),
345 (3, "2026-10-01T00:00:00Z", "2026-10-03T00:00:00Z")
346 );
347 assert_eq!(e.via, vec!["nginx", "env"]);
348 }
349
350 #[test]
351 fn a_failed_apply_counts_only_when_the_file_reached_the_disk() {
352 let mut validate = dep("2026-10-01T00:00:00Z", "t", "h1");
353 validate.ok = false;
354 validate.error = Some("validate failed, previous file restored: x".into());
355 let mut refused = dep("2026-10-01T00:00:00Z", "t", "h2");
356 refused.ok = false;
357 refused.error =
358 Some("content does not match the hash the hub announced; nothing was written".into());
359 let lookup = |_: &Deployment| Some(vec![exp("e1", "Stripe", "api_key", "fp")]);
360 let r = report("h", None, &[validate, refused], &lookup, &|_| {
361 cur("Stripe", &["fp"])
362 });
363 assert_eq!(r.deployments, 1, "the refused one never touched the disk");
364 assert_eq!(r.entries[0].times, 1);
365 }
366
367 #[test]
368 fn a_name_that_cannot_be_quoted_is_left_for_a_human_not_guessed() {
369 let deps = [dep("2026-10-01T00:00:00Z", "t", "h")];
370 let lookup = |_: &Deployment| Some(vec![exp("e1", "O'Brien", "api_key", "fp")]);
371 let r = report("h", None, &deps, &lookup, &|_| cur("O'Brien", &["fp"]));
372 assert!(r.command.starts_with("# cannot quote"), "{}", r.command);
373 assert!(!r.command.contains("unv entry rotate"));
374 }
375
376 #[test]
377 fn a_renamed_entry_is_listed_under_its_current_name() {
378 let deps = [dep("2026-10-01T00:00:00Z", "t", "h")];
379 let lookup = |_: &Deployment| Some(vec![exp("e1", "OldName", "api_key", "fp")]);
380 let r = report("h", None, &deps, &lookup, &|_| cur("NewName", &["fp"]));
381 assert_eq!(r.rotate, vec!["NewName"]);
382 }
383
384 #[test]
385 fn short_only_matches_are_marked_short() {
386 let deps = [dep("2026-10-01T00:00:00Z", "t", "h")];
387 let mut e = exp("e1", "P", "api_key", "fp");
388 e.short = true;
389 let lookup = move |_: &Deployment| Some(vec![e.clone()]);
390 let r = report("h", None, &deps, &lookup, &|_| cur("P", &["fp"]));
391 assert!(r.entries[0].short);
392 }
393}