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