1use std::collections::{HashMap, HashSet};
2use std::sync::atomic::AtomicBool;
3use std::sync::{Arc, Mutex};
4
5pub struct VaultState {
7 pub(in crate::app) cert_cache: HashMap<
10 String,
11 (
12 std::time::Instant,
13 crate::vault_ssh::CertStatus,
14 Option<std::time::SystemTime>,
15 ),
16 >,
17 pub(in crate::app) cert_checks_in_flight: HashSet<String>,
19 pub(in crate::app) cert_stat_throttle: HashMap<String, std::time::Instant>,
23 pub(in crate::app) cleanup_warning: Option<String>,
25 pub(in crate::app) signing_cancel: Option<Arc<AtomicBool>>,
27 pub(in crate::app) sign_thread: Option<std::thread::JoinHandle<()>>,
29 pub(in crate::app) sign_in_flight: Arc<Mutex<HashSet<String>>>,
31 pub(in crate::app) pending_config_write: bool,
33 pub(in crate::app) pending_sign: Option<Vec<crate::vault_ssh::VaultSignTarget>>,
37}
38
39impl Default for VaultState {
40 fn default() -> Self {
41 Self {
42 cert_cache: HashMap::new(),
43 cert_checks_in_flight: HashSet::new(),
44 cert_stat_throttle: HashMap::new(),
45 cleanup_warning: None,
46 signing_cancel: None,
47 sign_thread: None,
48 sign_in_flight: Arc::new(Mutex::new(HashSet::new())),
49 pending_config_write: false,
50 pending_sign: None,
51 }
52 }
53}
54
55type CertCacheEntry = (
56 std::time::Instant,
57 crate::vault_ssh::CertStatus,
58 Option<std::time::SystemTime>,
59);
60
61impl VaultState {
62 pub fn cert_cache(&self) -> &HashMap<String, CertCacheEntry> {
63 &self.cert_cache
64 }
65
66 pub fn cert_entry(&self, alias: &str) -> Option<&CertCacheEntry> {
67 self.cert_cache.get(alias)
68 }
69
70 pub fn has_cert(&self, alias: &str) -> bool {
71 self.cert_cache.contains_key(alias)
72 }
73
74 pub fn insert_cert(&mut self, alias: String, entry: CertCacheEntry) {
75 self.cert_cache.insert(alias, entry);
76 }
77
78 pub fn remove_cert(&mut self, alias: &str) {
79 self.cert_cache.remove(alias);
80 }
81
82 pub fn clear_cert_cache(&mut self) {
83 self.cert_cache.clear();
84 }
85
86 pub fn is_cert_check_in_flight(&self, alias: &str) -> bool {
87 self.cert_checks_in_flight.contains(alias)
88 }
89
90 pub fn take_cleanup_warning(&mut self) -> Option<String> {
91 self.cleanup_warning.take()
92 }
93
94 pub fn signing_cancel(&self) -> Option<&Arc<AtomicBool>> {
95 self.signing_cancel.as_ref()
96 }
97
98 pub fn is_signing(&self) -> bool {
99 self.signing_cancel.is_some()
100 }
101
102 pub fn set_signing_cancel(&mut self, cancel: Arc<AtomicBool>) {
103 self.signing_cancel = Some(cancel);
104 }
105
106 pub fn clear_signing_cancel(&mut self) {
107 self.signing_cancel = None;
108 }
109
110 pub fn set_sign_thread(&mut self, handle: std::thread::JoinHandle<()>) {
111 self.sign_thread = Some(handle);
112 }
113
114 pub fn sign_in_flight(&self) -> &Arc<Mutex<HashSet<String>>> {
115 &self.sign_in_flight
116 }
117
118 pub fn pending_config_write(&self) -> bool {
119 self.pending_config_write
120 }
121
122 pub fn set_pending_config_write(&mut self, value: bool) {
123 self.pending_config_write = value;
124 }
125
126 pub(crate) fn mark_cert_check_started(&mut self, alias: String) {
130 self.cert_checks_in_flight.insert(alias);
131 }
132
133 pub(crate) fn last_cert_stat(&self, alias: &str) -> Option<std::time::Instant> {
135 self.cert_stat_throttle.get(alias).copied()
136 }
137
138 pub fn pending_sign(&self) -> Option<&[crate::vault_ssh::VaultSignTarget]> {
142 self.pending_sign.as_deref()
143 }
144
145 pub fn set_pending_sign(&mut self, signable: Vec<crate::vault_ssh::VaultSignTarget>) {
147 self.pending_sign = Some(signable);
148 }
149
150 pub fn take_pending_sign(&mut self) -> Option<Vec<crate::vault_ssh::VaultSignTarget>> {
153 self.pending_sign.take()
154 }
155
156 pub(crate) fn note_cert_stat(&mut self, alias: String, when: std::time::Instant) {
158 self.cert_stat_throttle.insert(alias, when);
159 }
160
161 pub(crate) fn record_cert_check(
166 &mut self,
167 alias: String,
168 status: crate::vault_ssh::CertStatus,
169 mtime: Option<std::time::SystemTime>,
170 ) {
171 self.cert_checks_in_flight.remove(&alias);
172 self.cert_cache
173 .insert(alias, (std::time::Instant::now(), status, mtime));
174 }
175
176 pub(crate) fn cancel_signing_run(&mut self) -> Option<std::thread::JoinHandle<()>> {
181 if let Some(ref cancel) = self.signing_cancel {
182 cancel.store(true, std::sync::atomic::Ordering::Relaxed);
183 }
184 self.signing_cancel = None;
185 self.sign_thread.take()
186 }
187
188 pub(crate) fn finalize_signing_run(&mut self) -> Option<std::thread::JoinHandle<()>> {
195 self.signing_cancel = None;
196 self.sign_thread.take()
197 }
198
199 pub fn prune_orphans(&mut self, valid_aliases: &HashSet<&str>) {
205 let pre_cert = self.cert_cache.len();
206 let pre_checks = self.cert_checks_in_flight.len();
207 self.cert_cache
208 .retain(|alias, _| valid_aliases.contains(alias.as_str()));
209 self.cert_checks_in_flight
210 .retain(|alias| valid_aliases.contains(alias.as_str()));
211 self.cert_stat_throttle
212 .retain(|alias, _| valid_aliases.contains(alias.as_str()));
213 let dropped_cert = pre_cert.saturating_sub(self.cert_cache.len());
214 if dropped_cert > 0 {
215 log::debug!(
216 "[purple] reload_hosts: dropped {dropped_cert} orphan cert_cache entrie(s)"
217 );
218 }
219 let dropped_checks = pre_checks.saturating_sub(self.cert_checks_in_flight.len());
220 if dropped_checks > 0 {
221 log::debug!(
222 "[purple] reload_hosts: dropped {dropped_checks} orphan cert_checks_in_flight alias(es)"
223 );
224 }
225
226 let mut sign = match self.sign_in_flight.lock() {
227 Ok(g) => g,
228 Err(p) => p.into_inner(),
229 };
230 let pre = sign.len();
231 sign.retain(|alias| valid_aliases.contains(alias.as_str()));
232 let dropped = pre.saturating_sub(sign.len());
233 if dropped > 0 {
234 log::debug!("[purple] reload_hosts: dropped {dropped} orphan sign_in_flight alias(es)");
235 }
236
237 if let Some(list) = self.pending_sign.as_mut() {
243 let pre = list.len();
244 list.retain(|t| valid_aliases.contains(t.alias.as_str()));
245 let dropped = pre.saturating_sub(list.len());
246 if dropped > 0 {
247 log::debug!(
248 "[purple] reload_hosts: dropped {dropped} orphan pending_sign target(s)"
249 );
250 }
251 if list.is_empty() {
252 self.pending_sign = None;
253 }
254 }
255 }
256
257 pub fn migrate_alias(&mut self, old: &str, new: &str) {
264 if old == new {
265 return;
266 }
267 if self.cert_checks_in_flight.remove(old) {
268 self.cert_checks_in_flight.insert(new.to_string());
269 }
270 if let Some(when) = self.cert_stat_throttle.remove(old) {
271 self.cert_stat_throttle.insert(new.to_string(), when);
272 }
273 if let Some(list) = self.pending_sign.as_mut() {
277 for target in list.iter_mut() {
278 if target.alias == old {
279 target.alias = new.to_string();
280 }
281 }
282 }
283 let mut sign = match self.sign_in_flight.lock() {
284 Ok(g) => g,
285 Err(p) => p.into_inner(),
286 };
287 if sign.remove(old) {
288 sign.insert(new.to_string());
289 }
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296 use std::sync::atomic::Ordering;
297
298 #[test]
299 fn mark_cert_check_started_inserts_alias() {
300 let mut v = VaultState::default();
301 v.mark_cert_check_started("web".to_string());
302 assert!(v.cert_checks_in_flight.contains("web"));
303 }
304
305 #[test]
306 fn mark_cert_check_started_is_idempotent() {
307 let mut v = VaultState::default();
310 v.mark_cert_check_started("web".to_string());
311 v.mark_cert_check_started("web".to_string());
312 assert_eq!(v.cert_checks_in_flight.len(), 1);
313 assert!(v.cert_checks_in_flight.contains("web"));
314 }
315
316 #[test]
317 fn record_cert_check_clears_in_flight_and_writes_cache() {
318 let mut v = VaultState::default();
319 v.mark_cert_check_started("web".to_string());
320 v.record_cert_check(
321 "web".to_string(),
322 crate::vault_ssh::CertStatus::Missing,
323 None,
324 );
325 assert!(!v.cert_checks_in_flight.contains("web"));
326 assert!(v.cert_cache.contains_key("web"));
327 let (_, status, mtime) = v.cert_cache.get("web").unwrap();
328 assert!(matches!(status, crate::vault_ssh::CertStatus::Missing));
329 assert!(mtime.is_none());
330 }
331
332 #[test]
333 fn record_cert_check_caches_even_without_prior_start() {
334 let mut v = VaultState::default();
339 v.record_cert_check(
340 "web".to_string(),
341 crate::vault_ssh::CertStatus::Invalid("nope".to_string()),
342 None,
343 );
344 assert!(v.cert_cache.contains_key("web"));
345 assert!(v.cert_checks_in_flight.is_empty());
346 }
347
348 #[test]
349 fn cancel_signing_run_with_no_active_run_returns_none() {
350 let mut v = VaultState::default();
351 let handle = v.cancel_signing_run();
352 assert!(handle.is_none());
353 assert!(v.signing_cancel.is_none());
354 assert!(v.sign_thread.is_none());
355 }
356
357 #[test]
358 fn cancel_signing_run_signals_cancel_and_clears_handle() {
359 let mut v = VaultState::default();
363 let cancel = Arc::new(AtomicBool::new(false));
364 v.signing_cancel = Some(cancel.clone());
365 v.sign_thread = Some(std::thread::spawn(|| {}));
366
367 let handle = v
368 .cancel_signing_run()
369 .expect("returned thread handle for joining");
370 let _ = handle.join();
371
372 assert!(
373 cancel.load(Ordering::Relaxed),
374 "cancel must be signalled so a long-running worker exits"
375 );
376 assert!(v.signing_cancel.is_none());
377 assert!(v.sign_thread.is_none());
378 }
379
380 #[test]
381 fn finalize_signing_run_does_not_signal_cancel() {
382 let mut v = VaultState::default();
388 let cancel = Arc::new(AtomicBool::new(false));
389 v.signing_cancel = Some(cancel.clone());
390 v.sign_thread = Some(std::thread::spawn(|| {}));
391
392 let handle = v
393 .finalize_signing_run()
394 .expect("returned thread handle for joining");
395 let _ = handle.join();
396
397 assert!(
398 !cancel.load(Ordering::Relaxed),
399 "finalize must not signal cancel: a racing newer run's Arc could be hit"
400 );
401 assert!(v.signing_cancel.is_none());
402 assert!(v.sign_thread.is_none());
403 }
404
405 #[test]
406 fn finalize_signing_run_with_cancel_but_no_thread_clears_cancel() {
407 let mut v = VaultState::default();
413 let cancel = Arc::new(AtomicBool::new(false));
414 v.signing_cancel = Some(cancel.clone());
415
416 let handle = v.finalize_signing_run();
417 assert!(handle.is_none());
418 assert!(v.signing_cancel.is_none());
419 assert!(!cancel.load(Ordering::Relaxed));
420 }
421
422 #[test]
423 fn prune_orphans_drops_unknown_aliases_across_cert_and_sign_state() {
424 let mut v = VaultState::default();
425 v.cert_cache.insert(
426 "keep".to_string(),
427 (
428 std::time::Instant::now(),
429 crate::vault_ssh::CertStatus::Missing,
430 None,
431 ),
432 );
433 v.cert_cache.insert(
434 "drop".to_string(),
435 (
436 std::time::Instant::now(),
437 crate::vault_ssh::CertStatus::Missing,
438 None,
439 ),
440 );
441 v.cert_checks_in_flight.insert("keep".to_string());
442 v.cert_checks_in_flight.insert("drop".to_string());
443 v.sign_in_flight.lock().unwrap().insert("keep".to_string());
444 v.sign_in_flight.lock().unwrap().insert("drop".to_string());
445
446 let valid: HashSet<&str> = ["keep"].into_iter().collect();
447 v.prune_orphans(&valid);
448
449 assert!(v.cert_cache.contains_key("keep"));
450 assert!(!v.cert_cache.contains_key("drop"));
451 assert!(v.cert_checks_in_flight.contains("keep"));
452 assert!(!v.cert_checks_in_flight.contains("drop"));
453 let sign = v.sign_in_flight.lock().unwrap();
454 assert!(sign.contains("keep"));
455 assert!(!sign.contains("drop"));
456 }
457
458 #[test]
459 fn migrate_alias_moves_checks_and_sign_but_not_cert_cache() {
460 let mut v = VaultState::default();
461 v.cert_cache.insert(
462 "old".to_string(),
463 (
464 std::time::Instant::now(),
465 crate::vault_ssh::CertStatus::Missing,
466 None,
467 ),
468 );
469 v.cert_checks_in_flight.insert("old".to_string());
470 v.sign_in_flight.lock().unwrap().insert("old".to_string());
471
472 v.migrate_alias("old", "new");
473
474 assert!(v.cert_cache.contains_key("old"));
477 assert!(!v.cert_cache.contains_key("new"));
478
479 assert!(!v.cert_checks_in_flight.contains("old"));
480 assert!(v.cert_checks_in_flight.contains("new"));
481
482 let sign = v.sign_in_flight.lock().unwrap();
483 assert!(!sign.contains("old"));
484 assert!(sign.contains("new"));
485 }
486
487 #[test]
488 fn note_cert_stat_records_and_last_returns_it() {
489 let mut v = VaultState::default();
490 assert!(v.last_cert_stat("web").is_none());
491 let when = std::time::Instant::now();
492 v.note_cert_stat("web".to_string(), when);
493 assert_eq!(v.last_cert_stat("web"), Some(when));
494 }
495
496 #[test]
497 fn note_cert_stat_overwrites_prior_entry() {
498 let mut v = VaultState::default();
499 let earlier = std::time::Instant::now();
500 v.note_cert_stat("web".to_string(), earlier);
501 std::thread::sleep(std::time::Duration::from_millis(1));
502 let later = std::time::Instant::now();
503 v.note_cert_stat("web".to_string(), later);
504 assert_eq!(v.last_cert_stat("web"), Some(later));
505 }
506
507 #[test]
508 fn prune_orphans_drops_stale_throttle_entries() {
509 let mut v = VaultState::default();
510 v.note_cert_stat("keep".to_string(), std::time::Instant::now());
511 v.note_cert_stat("drop".to_string(), std::time::Instant::now());
512
513 let valid: HashSet<&str> = ["keep"].into_iter().collect();
514 v.prune_orphans(&valid);
515
516 assert!(v.last_cert_stat("keep").is_some());
517 assert!(v.last_cert_stat("drop").is_none());
518 }
519
520 #[test]
521 fn migrate_alias_moves_throttle_entry() {
522 let mut v = VaultState::default();
523 let when = std::time::Instant::now();
524 v.note_cert_stat("old".to_string(), when);
525
526 v.migrate_alias("old", "new");
527
528 assert!(v.last_cert_stat("old").is_none());
529 assert_eq!(v.last_cert_stat("new"), Some(when));
530 }
531}