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 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 cert_checks_in_flight: HashSet<String>,
19    /// Side-channel warning from cert-cache cleanup.
20    pub cleanup_warning: Option<String>,
21    /// Cancel flag for the V-key vault signing background thread.
22    pub signing_cancel: Option<Arc<AtomicBool>>,
23    /// JoinHandle for the V-key vault signing background thread.
24    pub sign_thread: Option<std::thread::JoinHandle<()>>,
25    /// Aliases currently being signed by the bulk V-key loop.
26    pub sign_in_flight: Arc<Mutex<HashSet<String>>>,
27    /// Deferred config write from VaultSignAllDone (guarded while forms are open).
28    pub pending_config_write: bool,
29}
30
31impl Default for VaultState {
32    fn default() -> Self {
33        Self {
34            cert_cache: HashMap::new(),
35            cert_checks_in_flight: HashSet::new(),
36            cleanup_warning: None,
37            signing_cancel: None,
38            sign_thread: None,
39            sign_in_flight: Arc::new(Mutex::new(HashSet::new())),
40            pending_config_write: false,
41        }
42    }
43}
44
45impl VaultState {
46    /// Reserve an alias against duplicate cert-status checks while a
47    /// background thread runs. Paired with `record_cert_check` on the
48    /// result event.
49    pub(crate) fn mark_cert_check_started(&mut self, alias: String) {
50        self.cert_checks_in_flight.insert(alias);
51    }
52
53    /// Land a finished cert-status check. Clears the in-flight reservation
54    /// and writes the result to `cert_cache` in one step so the two fields
55    /// cannot drift (a missed remove would dedupe the next lazy check
56    /// forever; a missed insert would re-spawn it every tick).
57    pub(crate) fn record_cert_check(
58        &mut self,
59        alias: String,
60        status: crate::vault_ssh::CertStatus,
61        mtime: Option<std::time::SystemTime>,
62    ) {
63        self.cert_checks_in_flight.remove(&alias);
64        self.cert_cache
65            .insert(alias, (std::time::Instant::now(), status, mtime));
66    }
67
68    /// Tear down a bulk-sign run that may still be running. Signals
69    /// cancel to the worker, clears the cancel handle, and returns the
70    /// thread for joining. Use at App::Drop and tui_loop teardown where
71    /// the worker is asked to stop.
72    pub(crate) fn cancel_signing_run(&mut self) -> Option<std::thread::JoinHandle<()>> {
73        if let Some(ref cancel) = self.signing_cancel {
74            cancel.store(true, std::sync::atomic::Ordering::Relaxed);
75        }
76        self.signing_cancel = None;
77        self.sign_thread.take()
78    }
79
80    /// Clean up after a bulk-sign worker exited or never started.
81    /// Does NOT signal cancel: the worker is already gone, and the
82    /// cancel handle in the field may belong to a newer user-started
83    /// run that raced into existence during the dispatch window
84    /// between worker exit and event processing. Use at the
85    /// VaultSignAllDone handler and the spawn-failed path.
86    pub(crate) fn finalize_signing_run(&mut self) -> Option<std::thread::JoinHandle<()>> {
87        self.signing_cancel = None;
88        self.sign_thread.take()
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use std::sync::atomic::Ordering;
96
97    #[test]
98    fn mark_cert_check_started_inserts_alias() {
99        let mut v = VaultState::default();
100        v.mark_cert_check_started("web".to_string());
101        assert!(v.cert_checks_in_flight.contains("web"));
102    }
103
104    #[test]
105    fn mark_cert_check_started_is_idempotent() {
106        // HashSet semantics; a second call must not panic and the set
107        // still contains the alias exactly once.
108        let mut v = VaultState::default();
109        v.mark_cert_check_started("web".to_string());
110        v.mark_cert_check_started("web".to_string());
111        assert_eq!(v.cert_checks_in_flight.len(), 1);
112        assert!(v.cert_checks_in_flight.contains("web"));
113    }
114
115    #[test]
116    fn record_cert_check_clears_in_flight_and_writes_cache() {
117        let mut v = VaultState::default();
118        v.mark_cert_check_started("web".to_string());
119        v.record_cert_check(
120            "web".to_string(),
121            crate::vault_ssh::CertStatus::Missing,
122            None,
123        );
124        assert!(!v.cert_checks_in_flight.contains("web"));
125        assert!(v.cert_cache.contains_key("web"));
126        let (_, status, mtime) = v.cert_cache.get("web").unwrap();
127        assert!(matches!(status, crate::vault_ssh::CertStatus::Missing));
128        assert!(mtime.is_none());
129    }
130
131    #[test]
132    fn record_cert_check_caches_even_without_prior_start() {
133        // Defensive: if a result event somehow lands without a matching
134        // start (e.g. spawned before App::new but result arrives after),
135        // the cache must still be updated and the in-flight set
136        // unaffected.
137        let mut v = VaultState::default();
138        v.record_cert_check(
139            "web".to_string(),
140            crate::vault_ssh::CertStatus::Invalid("nope".to_string()),
141            None,
142        );
143        assert!(v.cert_cache.contains_key("web"));
144        assert!(v.cert_checks_in_flight.is_empty());
145    }
146
147    #[test]
148    fn cancel_signing_run_with_no_active_run_returns_none() {
149        let mut v = VaultState::default();
150        let handle = v.cancel_signing_run();
151        assert!(handle.is_none());
152        assert!(v.signing_cancel.is_none());
153        assert!(v.sign_thread.is_none());
154    }
155
156    #[test]
157    fn cancel_signing_run_signals_cancel_and_clears_handle() {
158        // A real (short-lived) thread plus an Arc<AtomicBool> exercises
159        // both halves: cancel_signing_run must set the flag to true (so
160        // a long-running worker would exit) and detach the cancel handle.
161        let mut v = VaultState::default();
162        let cancel = Arc::new(AtomicBool::new(false));
163        v.signing_cancel = Some(cancel.clone());
164        v.sign_thread = Some(std::thread::spawn(|| {}));
165
166        let handle = v
167            .cancel_signing_run()
168            .expect("returned thread handle for joining");
169        let _ = handle.join();
170
171        assert!(
172            cancel.load(Ordering::Relaxed),
173            "cancel must be signalled so a long-running worker exits"
174        );
175        assert!(v.signing_cancel.is_none());
176        assert!(v.sign_thread.is_none());
177    }
178
179    #[test]
180    fn finalize_signing_run_does_not_signal_cancel() {
181        // After VaultSignAllDone arrives, the worker has already exited.
182        // signing_cancel may belong to a *newer* user-started run that
183        // raced in. finalize must NOT touch the cancel flag, only clear
184        // the field and take the thread (which is the just-finished
185        // worker's handle, ready for join).
186        let mut v = VaultState::default();
187        let cancel = Arc::new(AtomicBool::new(false));
188        v.signing_cancel = Some(cancel.clone());
189        v.sign_thread = Some(std::thread::spawn(|| {}));
190
191        let handle = v
192            .finalize_signing_run()
193            .expect("returned thread handle for joining");
194        let _ = handle.join();
195
196        assert!(
197            !cancel.load(Ordering::Relaxed),
198            "finalize must not signal cancel: a racing newer run's Arc could be hit"
199        );
200        assert!(v.signing_cancel.is_none());
201        assert!(v.sign_thread.is_none());
202    }
203
204    #[test]
205    fn finalize_signing_run_with_cancel_but_no_thread_clears_cancel() {
206        // Spawn-failure path: signing_cancel was set in `confirm.rs`
207        // before the thread builder ran, the spawn failed, sign_thread
208        // is still None. finalize_signing_run clears the orphaned cancel
209        // without signalling (the spawned closure was dropped, no other
210        // observer of the Arc exists).
211        let mut v = VaultState::default();
212        let cancel = Arc::new(AtomicBool::new(false));
213        v.signing_cancel = Some(cancel.clone());
214
215        let handle = v.finalize_signing_run();
216        assert!(handle.is_none());
217        assert!(v.signing_cancel.is_none());
218        assert!(!cancel.load(Ordering::Relaxed));
219    }
220}