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