Skip to main content

purple_ssh/app/
vault.rs

1use std::collections::{HashMap, HashSet};
2use std::sync::atomic::AtomicBool;
3use std::sync::{Arc, Mutex};
4
5/// Vault SSH certificate and signing state.
6pub struct VaultState {
7    /// Cached vault certificate status per host alias.
8    /// Tuple: (check timestamp, status, cert file mtime at check time).
9    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    /// Aliases currently being checked for cert status (prevent duplicate checks).
18    pub(in crate::app) cert_checks_in_flight: HashSet<String>,
19    /// When the cert file was last `stat()`-ed per alias. Lets the per-frame
20    /// freshness probe throttle its syscall while still detecting external
21    /// writes within the throttle window.
22    pub(in crate::app) cert_stat_throttle: HashMap<String, std::time::Instant>,
23    /// Side-channel warning from cert-cache cleanup.
24    pub(in crate::app) cleanup_warning: Option<String>,
25    /// Cancel flag for the V-key vault signing background thread.
26    pub(in crate::app) signing_cancel: Option<Arc<AtomicBool>>,
27    /// JoinHandle for the V-key vault signing background thread.
28    pub(in crate::app) sign_thread: Option<std::thread::JoinHandle<()>>,
29    /// Aliases currently being signed by the bulk V-key loop.
30    pub(in crate::app) sign_in_flight: Arc<Mutex<HashSet<String>>>,
31    /// Deferred config write from VaultSignAllDone (guarded while forms are open).
32    pub(in crate::app) pending_config_write: bool,
33    /// Payload of an open `Screen::ConfirmVaultSign` dialog. The Screen
34    /// variant is data-less; the precomputed signable list lives here
35    /// so the dialog transitions never clone the `VaultSignTarget` vec.
36    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    /// Reserve an alias against duplicate cert-status checks while a
127    /// background thread runs. Paired with `record_cert_check` on the
128    /// result event.
129    pub(crate) fn mark_cert_check_started(&mut self, alias: String) {
130        self.cert_checks_in_flight.insert(alias);
131    }
132
133    /// Last time the per-frame freshness probe stat-ed this alias.
134    pub(crate) fn last_cert_stat(&self, alias: &str) -> Option<std::time::Instant> {
135        self.cert_stat_throttle.get(alias).copied()
136    }
137
138    /// Read the precomputed signable list for an open
139    /// `Screen::ConfirmVaultSign` dialog. `None` when no dialog is
140    /// open.
141    pub fn pending_sign(&self) -> Option<&[crate::vault_ssh::VaultSignTarget]> {
142        self.pending_sign.as_deref()
143    }
144
145    /// Install a fresh signable list. Caller transitions the screen.
146    pub fn set_pending_sign(&mut self, signable: Vec<crate::vault_ssh::VaultSignTarget>) {
147        self.pending_sign = Some(signable);
148    }
149
150    /// Drop the signable list, returning it for use by the confirm-yes
151    /// handler.
152    pub fn take_pending_sign(&mut self) -> Option<Vec<crate::vault_ssh::VaultSignTarget>> {
153        self.pending_sign.take()
154    }
155
156    /// Record that the per-frame freshness probe just stat-ed this alias.
157    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    /// Land a finished cert-status check. Clears the in-flight reservation
162    /// and writes the result to `cert_cache` in one step so the two fields
163    /// cannot drift (a missed remove would dedupe the next lazy check
164    /// forever; a missed insert would re-spawn it every tick).
165    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    /// Tear down a bulk-sign run that may still be running. Signals
177    /// cancel to the worker, clears the cancel handle, and returns the
178    /// thread for joining. Use at App::Drop and tui_loop teardown where
179    /// the worker is asked to stop.
180    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    /// Clean up after a bulk-sign worker exited or never started.
189    /// Does NOT signal cancel: the worker is already gone, and the
190    /// cancel handle in the field may belong to a newer user-started
191    /// run that raced into existence during the dispatch window
192    /// between worker exit and event processing. Use at the
193    /// VaultSignAllDone handler and the spawn-failed path.
194    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    /// Drop cert-cache, in-flight check, and bulk-sign in-flight entries
200    /// whose alias is no longer in `valid_aliases`. Called from
201    /// `App::reload_hosts` after the new host list lands. Recovers from a
202    /// poisoned `sign_in_flight` mutex by reading the inner set: a
203    /// poisoned worker still owns live aliases that must not be wiped.
204    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        // Open bulk-sign confirm payload: drop targets whose host was
238        // removed. The next yes path then never tries to sign a cert
239        // for a deleted alias. When every target is gone, drop the
240        // whole list so the confirm handler can detect the empty state
241        // and return to HostList.
242        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    /// Move `cert_checks_in_flight` and `sign_in_flight` entries from
258    /// `old` to `new`. `cert_cache` is excluded by design: a host
259    /// rename invalidates the prior cert path, so the caller is
260    /// expected to refresh the cache rather than migrate the entry.
261    /// Recovers from a poisoned `sign_in_flight` mutex. No-op when
262    /// `old == new`.
263    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        // Open bulk-sign confirm payload: rename any target that still
274        // points at the old alias. Without this, the worker would sign
275        // and write the cert under the stale path after the rename.
276        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        // HashSet semantics; a second call must not panic and the set
308        // still contains the alias exactly once.
309        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        // Defensive: if a result event somehow lands without a matching
335        // start (e.g. spawned before App::new but result arrives after),
336        // the cache must still be updated and the in-flight set
337        // unaffected.
338        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        // A real (short-lived) thread plus an Arc<AtomicBool> exercises
360        // both halves: cancel_signing_run must set the flag to true (so
361        // a long-running worker would exit) and detach the cancel handle.
362        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        // After VaultSignAllDone arrives, the worker has already exited.
383        // signing_cancel may belong to a *newer* user-started run that
384        // raced in. finalize must NOT touch the cancel flag, only clear
385        // the field and take the thread (which is the just-finished
386        // worker's handle, ready for join).
387        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        // Spawn-failure path: signing_cancel was set in `confirm.rs`
408        // before the thread builder ran, the spawn failed, sign_thread
409        // is still None. finalize_signing_run clears the orphaned cancel
410        // without signalling (the spawned closure was dropped, no other
411        // observer of the Arc exists).
412        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        // cert_cache is intentionally left untouched: rename invalidates
475        // the cert path so the caller refreshes rather than migrating.
476        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}