Skip to main content

purple_ssh/app/
key_push_state.rs

1//! Key-push state. Tracks the picker selection set, in-flight worker
2//! handles and accumulated results between Screen transitions.
3//!
4//! Lives on `App` so the event loop can land per-host `KeyPushResult`
5//! events into `results` regardless of which Screen is active (the user
6//! may switch tabs while a push is running).
7
8use std::sync::Arc;
9use std::sync::atomic::AtomicBool;
10
11use ratatui::widgets::ListState;
12
13use crate::key_push::{InflightChild, KeyPushResult};
14use std::collections::{HashSet, VecDeque};
15
16/// A question the finished run left for the user, drained one at a time
17/// once no run is in flight. Each entry carries the key its run was
18/// pushing: a later run overwrites `KeyPushState::key_path`, so a host
19/// still waiting here would otherwise be answered for the wrong key.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum KeyPushPrompt {
22    /// Strict checking refused a host not in `known_hosts` yet.
23    Trust { alias: String, key_path: String },
24    /// ssh ended with `Permission denied` and the server takes a password.
25    Password { alias: String, key_path: String },
26}
27
28impl KeyPushPrompt {
29    pub fn alias(&self) -> &str {
30        match self {
31            KeyPushPrompt::Trust { alias, .. } | KeyPushPrompt::Password { alias, .. } => alias,
32        }
33    }
34
35    /// Path of the key this question belongs to, as `SshKeyInfo::display_path`
36    /// spells it.
37    pub fn key_path(&self) -> &str {
38        match self {
39            KeyPushPrompt::Trust { key_path, .. } | KeyPushPrompt::Password { key_path, .. } => {
40                key_path
41            }
42        }
43    }
44}
45
46/// Push state owned by `App`. Empty between push runs.
47#[derive(Default)]
48pub struct KeyPushState {
49    /// Aliases the user has selected in the picker. Modified by Space
50    /// during `Screen::KeyPushPicker` and frozen into `committed` on Enter.
51    pub selected: HashSet<String>,
52    /// Snapshot of `selected` (in picker order) taken when the user
53    /// presses Enter to open `Screen::ConfirmKeyPush`. Read by the
54    /// confirm renderer and by `start_key_push`. Cleared on cancel or
55    /// after the worker spawns. Keeps `Screen::ConfirmKeyPush` payload
56    /// small.
57    pub committed: Vec<String>,
58    /// Cursor in the picker's host list. Indexes into the picker's
59    /// visible host slice.
60    pub list_state: ListState,
61    /// Results accumulated as `AppEvent::KeyPushResult` lands. Drained
62    /// when the run completes and the summary toast / sticky error is
63    /// rendered.
64    pub results: Vec<KeyPushResult>,
65    /// Total hosts the current run is targeting. Used to know when the
66    /// run is "done" so the summary can fire exactly once.
67    pub expected_count: usize,
68    /// Cancel flag observed by every worker thread. Set on push-cancel,
69    /// new push run, or App drop.
70    pub cancel: Option<Arc<AtomicBool>>,
71    /// JoinHandle for the worker pool. Joined on App drop.
72    pub worker: Option<std::thread::JoinHandle<()>>,
73    /// Monotonic run identifier. Bumped at the start of every push so
74    /// stale `KeyPushResult` events from a previously-cancelled run can
75    /// be dropped instead of contaminating the next run's accumulator.
76    pub run_id: u64,
77    /// The ssh child the worker is running right now. Cancel and shutdown
78    /// kill it through here instead of waiting for the worker to notice the
79    /// flag between hosts.
80    pub inflight: InflightChild,
81    /// Path of the key the current run pushes, as `SshKeyInfo::display_path`
82    /// spells it. A retry after a dialog looks the key up by this path: the
83    /// list is rebuilt after a successful push, so a position would not
84    /// survive.
85    pub key_path: String,
86    /// True when the current run follows an accepted trust dialog, so its
87    /// ssh may record a host key it has not seen before. Reset by every
88    /// `start_run`, so it never carries into an ordinary push.
89    pub trust_new_host_key: bool,
90    /// Hosts of the finished run that still need an answer, in picker order.
91    /// Drained one dialog at a time, never while a run is in flight.
92    pub pending_prompts: VecDeque<KeyPushPrompt>,
93}
94
95impl KeyPushState {
96    /// Drop the worker handle gracefully. Called from `App::drop` so a
97    /// panicking unwind cannot leave the push thread running with a
98    /// dangling sender. The running child is killed first so the join
99    /// returns within the SIGTERM grace window.
100    pub fn shutdown(&mut self) {
101        if let Some(ref cancel) = self.cancel {
102            cancel.store(true, std::sync::atomic::Ordering::Relaxed);
103        }
104        self.inflight.terminate();
105        if let Some(handle) = self.worker.take() {
106            let _ = handle.join();
107        }
108    }
109
110    /// Reset picker-only state without touching in-flight worker. Called
111    /// before opening the picker for a new key so the previous run's
112    /// selection set does not bleed in.
113    pub fn reset_picker(&mut self) {
114        self.selected.clear();
115        self.list_state.select(Some(0));
116    }
117
118    /// Begin a new push run. Clears the result accumulator, sets the
119    /// expected host count and the path of the key being pushed, bumps the monotonic
120    /// run_id (so any stale KeyPushResult events from an aborted previous
121    /// run can be dropped), constructs a fresh cancel flag and an empty
122    /// inflight slot and stores them on state. Returns the new run_id
123    /// together with the cancel handle so the spawned worker can share it.
124    pub fn start_run(
125        &mut self,
126        expected: usize,
127        key_path: String,
128        trust_new_host_key: bool,
129    ) -> (u64, Arc<AtomicBool>) {
130        self.results.clear();
131        self.expected_count = expected;
132        self.key_path = key_path;
133        self.trust_new_host_key = trust_new_host_key;
134        self.run_id = self.run_id.wrapping_add(1);
135        let cancel = Arc::new(AtomicBool::new(false));
136        self.cancel = Some(cancel.clone());
137        self.inflight = InflightChild::default();
138        (self.run_id, cancel)
139    }
140
141    /// Completion path. The worker loop has finished naturally; clear the
142    /// accumulators and join the worker handle. Safe to call when the
143    /// worker has already exited.
144    pub fn finish_run(&mut self) {
145        self.results.clear();
146        self.expected_count = 0;
147        self.selected.clear();
148        self.cancel = None;
149        if let Some(handle) = self.worker.take() {
150            let _ = handle.join();
151        }
152    }
153
154    /// User-cancel path. The running child is killed and the cancel flag is
155    /// raised for the worker. Accumulators and pending prompts are cleared
156    /// and run_id is bumped so in-flight KeyPushResult events from the
157    /// aborted worker arrive with a stale run_id and are dropped. The worker
158    /// handle is intentionally NOT joined here so the UI does not block
159    /// while the thread reaps the child.
160    pub fn cancel_run(&mut self) {
161        if let Some(ref cancel) = self.cancel {
162            cancel.store(true, std::sync::atomic::Ordering::Relaxed);
163        }
164        self.inflight.terminate();
165        self.results.clear();
166        self.expected_count = 0;
167        self.cancel = None;
168        self.selected.clear();
169        self.pending_prompts.clear();
170        self.run_id = self.run_id.wrapping_add(1);
171    }
172
173    /// Failure recovery after a failed worker spawn. Drops the cancel
174    /// handle, zeroes the expected count, and clears the worker slot.
175    /// Distinct from `finish_run`: the worker handle is None here (spawn
176    /// failed), and the result accumulator is left intact for any caller
177    /// that may want to surface it in the error path.
178    pub fn clear_inflight_state(&mut self) {
179        self.cancel = None;
180        self.expected_count = 0;
181        self.worker = None;
182    }
183}
184
185#[cfg(test)]
186#[allow(clippy::field_reassign_with_default)]
187mod tests {
188    use super::*;
189    use std::sync::atomic::Ordering;
190
191    #[test]
192    fn default_is_empty() {
193        let s = KeyPushState::default();
194        assert!(s.selected.is_empty());
195        assert_eq!(s.list_state.selected(), None);
196        assert!(s.results.is_empty());
197        assert_eq!(s.expected_count, 0);
198        assert!(s.cancel.is_none());
199        assert!(s.worker.is_none());
200    }
201
202    #[test]
203    fn reset_picker_clears_selection_and_resets_cursor() {
204        let mut s = KeyPushState::default();
205        s.selected.insert("host-a".to_string());
206        s.selected.insert("host-b".to_string());
207        s.list_state.select(Some(5));
208        s.reset_picker();
209        assert!(s.selected.is_empty());
210        assert_eq!(s.list_state.selected(), Some(0));
211    }
212
213    #[test]
214    fn reset_picker_leaves_inflight_state_alone() {
215        // shutdown is the path that touches worker/cancel; reset_picker
216        // is the picker-open path and must not interfere with a run that
217        // is still finalising.
218        let mut s = KeyPushState::default();
219        s.cancel = Some(Arc::new(AtomicBool::new(false)));
220        s.expected_count = 5;
221        s.results.push(crate::key_push::KeyPushResult {
222            alias: "h".into(),
223            outcome: crate::key_push::KeyPushOutcome::Appended,
224        });
225        s.reset_picker();
226        assert!(s.cancel.is_some());
227        assert_eq!(s.expected_count, 5);
228        assert_eq!(s.results.len(), 1);
229    }
230
231    #[test]
232    fn shutdown_sets_cancel_flag() {
233        let mut s = KeyPushState::default();
234        let flag = Arc::new(AtomicBool::new(false));
235        s.cancel = Some(flag.clone());
236        s.shutdown();
237        assert!(flag.load(Ordering::Relaxed));
238    }
239
240    #[test]
241    fn shutdown_joins_worker_and_takes_handle() {
242        let mut s = KeyPushState::default();
243        let flag = Arc::new(AtomicBool::new(false));
244        let cancel = flag.clone();
245        // A trivial worker that observes the cancel flag and exits.
246        let handle = std::thread::spawn(move || {
247            while !cancel.load(Ordering::Relaxed) {
248                std::thread::sleep(std::time::Duration::from_millis(1));
249            }
250        });
251        s.cancel = Some(flag);
252        s.worker = Some(handle);
253        s.shutdown();
254        assert!(s.worker.is_none(), "worker handle should be taken");
255    }
256
257    #[test]
258    fn shutdown_is_idempotent_with_no_worker() {
259        let mut s = KeyPushState::default();
260        // Should not panic when called on an empty state.
261        s.shutdown();
262        s.shutdown();
263    }
264
265    #[test]
266    fn start_run_clears_results_sets_expected_and_stores_cancel() {
267        let mut s = KeyPushState::default();
268        s.results.push(crate::key_push::KeyPushResult {
269            alias: "old".into(),
270            outcome: crate::key_push::KeyPushOutcome::Appended,
271        });
272
273        let (_run_id, cancel) = s.start_run(4, "~/.ssh/id_a".into(), false);
274
275        assert!(s.results.is_empty());
276        assert_eq!(s.expected_count, 4);
277        assert!(s.cancel.is_some());
278        // Returned cancel arc points to the same flag stored on state.
279        cancel.store(true, Ordering::Relaxed);
280        assert!(s.cancel.as_ref().unwrap().load(Ordering::Relaxed));
281    }
282
283    #[test]
284    fn start_run_bumps_run_id_and_returns_it() {
285        let mut s = KeyPushState {
286            run_id: 41,
287            ..Default::default()
288        };
289        let (run_id, _cancel) = s.start_run(1, "~/.ssh/id_a".into(), false);
290        assert_eq!(run_id, 42);
291        assert_eq!(s.run_id, 42);
292    }
293
294    #[test]
295    fn start_run_records_the_key_path_for_retries() {
296        let mut s = KeyPushState::default();
297        let _ = s.start_run(1, "~/.ssh/id_ed25519".into(), false);
298        assert_eq!(s.key_path, "~/.ssh/id_ed25519");
299    }
300
301    #[test]
302    fn start_run_records_and_resets_the_trust_flag() {
303        let mut s = KeyPushState::default();
304        let _ = s.start_run(1, "~/.ssh/id_a".into(), true);
305        assert!(s.trust_new_host_key, "a trust retry carries the flag");
306        let _ = s.start_run(1, "~/.ssh/id_a".into(), false);
307        assert!(!s.trust_new_host_key, "an ordinary push does not");
308    }
309
310    #[test]
311    fn start_run_preserves_picker_state_and_committed() {
312        let mut s = KeyPushState::default();
313        s.selected.insert("host-a".into());
314        s.committed = vec!["host-a".into(), "host-b".into()];
315        s.list_state.select(Some(2));
316
317        let _ = s.start_run(2, "~/.ssh/id_a".into(), false);
318
319        assert!(s.selected.contains("host-a"));
320        assert_eq!(s.committed, vec!["host-a".to_string(), "host-b".into()]);
321        assert_eq!(s.list_state.selected(), Some(2));
322    }
323
324    #[test]
325    fn start_run_keeps_pending_prompts_from_the_previous_run() {
326        // A retry for one host runs while the other hosts still wait for
327        // their own dialog; starting that run must not drop them.
328        let mut s = KeyPushState::default();
329        s.pending_prompts.push_back(KeyPushPrompt::Password {
330            alias: "h2".into(),
331            key_path: "~/.ssh/id_b".into(),
332        });
333        let _ = s.start_run(1, "~/.ssh/id_a".into(), false);
334        assert_eq!(s.pending_prompts.len(), 1);
335    }
336
337    #[test]
338    fn cancel_run_sets_the_flag_and_drops_pending_prompts() {
339        let mut s = KeyPushState::default();
340        let (_id, cancel) = s.start_run(2, "~/.ssh/id_a".into(), false);
341        s.pending_prompts.push_back(KeyPushPrompt::Trust {
342            alias: "h".into(),
343            key_path: "~/.ssh/id_a".into(),
344        });
345        s.cancel_run();
346        assert!(cancel.load(Ordering::Relaxed), "worker must see the cancel");
347        assert!(s.pending_prompts.is_empty());
348    }
349
350    #[test]
351    fn key_push_prompt_alias_names_the_host() {
352        let t = KeyPushPrompt::Trust {
353            alias: "a".into(),
354            key_path: "~/.ssh/id_a".into(),
355        };
356        assert_eq!(t.alias(), "a");
357        assert_eq!(t.key_path(), "~/.ssh/id_a");
358        let p = KeyPushPrompt::Password {
359            alias: "b".into(),
360            key_path: "~/.ssh/id_b".into(),
361        };
362        assert_eq!(p.alias(), "b");
363        assert_eq!(p.key_path(), "~/.ssh/id_b");
364    }
365
366    #[test]
367    fn finish_run_clears_run_accumulators() {
368        let mut s = KeyPushState {
369            expected_count: 5,
370            cancel: Some(Arc::new(AtomicBool::new(false))),
371            ..Default::default()
372        };
373        s.selected.insert("h".into());
374        s.results.push(crate::key_push::KeyPushResult {
375            alias: "h".into(),
376            outcome: crate::key_push::KeyPushOutcome::Appended,
377        });
378
379        s.finish_run();
380
381        assert!(s.results.is_empty());
382        assert_eq!(s.expected_count, 0);
383        assert!(s.selected.is_empty());
384        assert!(s.cancel.is_none());
385    }
386
387    #[test]
388    fn finish_run_joins_worker_and_takes_handle() {
389        let mut s = KeyPushState::default();
390        let handle = std::thread::spawn(|| {});
391        s.worker = Some(handle);
392        s.finish_run();
393        assert!(s.worker.is_none(), "worker handle should be taken");
394    }
395
396    #[test]
397    fn finish_run_preserves_committed_and_list_state() {
398        let mut s = KeyPushState::default();
399        s.committed = vec!["host-a".into()];
400        s.list_state.select(Some(3));
401
402        s.finish_run();
403
404        assert_eq!(s.committed, vec!["host-a".to_string()]);
405        assert_eq!(s.list_state.selected(), Some(3));
406    }
407
408    #[test]
409    fn cancel_run_clears_accumulators_and_bumps_run_id() {
410        let mut s = KeyPushState {
411            expected_count: 3,
412            run_id: 10,
413            cancel: Some(Arc::new(AtomicBool::new(false))),
414            ..Default::default()
415        };
416        s.selected.insert("h".into());
417        s.results.push(crate::key_push::KeyPushResult {
418            alias: "h".into(),
419            outcome: crate::key_push::KeyPushOutcome::Appended,
420        });
421
422        s.cancel_run();
423
424        assert!(s.results.is_empty());
425        assert_eq!(s.expected_count, 0);
426        assert!(s.cancel.is_none());
427        assert!(s.selected.is_empty());
428        assert_eq!(s.run_id, 11);
429    }
430
431    #[test]
432    fn cancel_run_preserves_worker_handle() {
433        // Cancel is async via the cancel flag; the worker thread drains
434        // itself. We must not block the UI on join here.
435        let mut s = KeyPushState::default();
436        let handle = std::thread::spawn(|| {});
437        s.worker = Some(handle);
438        s.cancel_run();
439        assert!(s.worker.is_some(), "cancel must not take the worker handle");
440        // Clean up for the test harness.
441        if let Some(h) = s.worker.take() {
442            let _ = h.join();
443        }
444    }
445
446    #[test]
447    fn clear_inflight_state_drops_cancel_expected_count_worker() {
448        let mut s = KeyPushState {
449            expected_count: 7,
450            cancel: Some(Arc::new(AtomicBool::new(false))),
451            ..Default::default()
452        };
453        // Channel-based blocking thread so clear_inflight_state can drop
454        // the JoinHandle without leaving a detached thread spinning in
455        // the test harness. drop(tx) at the end signals the thread to
456        // exit cleanly via the rx disconnect.
457        let (tx, rx) = std::sync::mpsc::channel::<()>();
458        let handle = std::thread::spawn(move || {
459            let _ = rx.recv();
460        });
461        s.worker = Some(handle);
462
463        s.clear_inflight_state();
464
465        assert_eq!(s.expected_count, 0);
466        assert!(s.cancel.is_none());
467        assert!(s.worker.is_none());
468
469        drop(tx);
470    }
471
472    #[test]
473    fn clear_inflight_state_preserves_committed_run_id_and_results() {
474        let mut s = KeyPushState {
475            run_id: 9,
476            ..Default::default()
477        };
478        s.committed = vec!["host".into()];
479        s.results.push(crate::key_push::KeyPushResult {
480            alias: "host".into(),
481            outcome: crate::key_push::KeyPushOutcome::Appended,
482        });
483
484        s.clear_inflight_state();
485
486        assert_eq!(s.run_id, 9);
487        assert_eq!(s.committed, vec!["host".to_string()]);
488        assert_eq!(s.results.len(), 1);
489    }
490}